First version

This commit is contained in:
2026-08-11 12:38:02 -03:00
parent 42e875d866
commit 89d1a06204
37 changed files with 5660 additions and 4 deletions
+299
View File
@@ -0,0 +1,299 @@
//
// diff.js — previewing a change before you approve it.
//
// Nova has no scriptable diff viewer, so proposed edits are rendered as a
// unified diff in a scratch tab. That is enough to answer the only question
// that matters at approval time: what exactly is about to change on disk.
//
const { relativize, warn } = require("./util.js");
const CONTEXT_LINES = 3;
const MAX_DIFF_LINES = 400;
function readFile(path) {
try {
if (!nova.fs.access(path, nova.fs.R_OK)) return null;
const file = nova.fs.open(path, "r");
const contents = file.readlines().join("");
file.close();
return contents;
} catch (err) {
warn("could not read", path, err);
return null;
}
}
function splitLines(text) {
if (text === null || text === undefined) return [];
const body = String(text);
if (body === "") return [];
// A trailing newline terminates the last line rather than starting a new
// empty one — otherwise every diff carries a phantom blank line.
const lines = body.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
return lines;
}
/**
* Longest-common-subsequence line diff.
* Common prefixes and suffixes are trimmed first, so the quadratic table only
* ever covers the part that actually differs.
*/
function diffLines(beforeLines, afterLines) {
let start = 0;
while (
start < beforeLines.length &&
start < afterLines.length &&
beforeLines[start] === afterLines[start]
) {
start++;
}
let endBefore = beforeLines.length;
let endAfter = afterLines.length;
while (
endBefore > start &&
endAfter > start &&
beforeLines[endBefore - 1] === afterLines[endAfter - 1]
) {
endBefore--;
endAfter--;
}
const a = beforeLines.slice(start, endBefore);
const b = afterLines.slice(start, endAfter);
const ops = [];
for (let i = 0; i < start; i++) ops.push({ type: "equal", text: beforeLines[i] });
// Guard the table: very large rewrites are shown as a plain replacement.
if (a.length * b.length > 1000000) {
for (const line of a) ops.push({ type: "delete", text: line });
for (const line of b) ops.push({ type: "insert", text: line });
} else {
const table = [];
for (let i = 0; i <= a.length; i++) {
table.push(new Array(b.length + 1).fill(0));
}
for (let i = a.length - 1; i >= 0; i--) {
for (let j = b.length - 1; j >= 0; j--) {
table[i][j] =
a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
}
}
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
if (a[i] === b[j]) {
ops.push({ type: "equal", text: a[i] });
i++;
j++;
} else if (table[i + 1][j] >= table[i][j + 1]) {
ops.push({ type: "delete", text: a[i] });
i++;
} else {
ops.push({ type: "insert", text: b[j] });
j++;
}
}
while (i < a.length) ops.push({ type: "delete", text: a[i++] });
while (j < b.length) ops.push({ type: "insert", text: b[j++] });
}
for (let i = endBefore; i < beforeLines.length; i++) {
ops.push({ type: "equal", text: beforeLines[i] });
}
return ops;
}
/** Render ops as a unified diff with limited context. */
function renderUnified(ops) {
const keep = new Array(ops.length).fill(false);
for (let i = 0; i < ops.length; i++) {
if (ops[i].type === "equal") continue;
for (let j = Math.max(0, i - CONTEXT_LINES); j <= Math.min(ops.length - 1, i + CONTEXT_LINES); j++) {
keep[j] = true;
}
}
const lines = [];
let skipping = false;
let shown = 0;
for (let i = 0; i < ops.length; i++) {
if (!keep[i]) {
if (!skipping) {
lines.push("@@ …");
skipping = true;
}
continue;
}
skipping = false;
if (shown >= MAX_DIFF_LINES) {
lines.push(`@@ … diff truncated after ${MAX_DIFF_LINES} lines`);
break;
}
const op = ops[i];
const marker = op.type === "insert" ? "+" : op.type === "delete" ? "-" : " ";
lines.push(marker + op.text);
shown++;
}
return lines.join("\n");
}
function counts(ops) {
let additions = 0;
let deletions = 0;
for (const op of ops) {
if (op.type === "insert") additions++;
else if (op.type === "delete") deletions++;
}
return { additions, deletions };
}
/**
* Work out what a file would contain if a pending tool call ran.
* Returns { path, before, after, title, exists } or null when the tool does not
* describe a file change we can reconstruct.
*/
function proposedChange(toolName, input) {
if (!input) return null;
const path = input.file_path;
if (!path) return null;
if (toolName === "Write") {
const before = readFile(path);
return {
path: path,
before: before,
after: String(input.content || ""),
exists: before !== null,
title: before === null ? `Create ${relativize(path)}` : `Overwrite ${relativize(path)}`,
};
}
if (toolName === "Edit" || toolName === "Update") {
if (typeof input.old_string !== "string") return null;
const before = readFile(path);
if (before === null) {
// The file is not readable — fall back to showing the replacement
// on its own, which still tells the user what Claude intends.
return {
path: path,
before: input.old_string,
after: String(input.new_string || ""),
exists: false,
title: `Edit ${relativize(path)}`,
};
}
const after = input.replace_all
? before.split(input.old_string).join(input.new_string || "")
: before.replace(input.old_string, input.new_string || "");
return {
path: path,
before: before,
after: after,
exists: true,
title: `Edit ${relativize(path)}`,
};
}
if (toolName === "MultiEdit" && Array.isArray(input.edits)) {
const before = readFile(path);
if (before === null) return null;
let after = before;
for (const edit of input.edits) {
if (typeof edit.old_string !== "string") continue;
after = edit.replace_all
? after.split(edit.old_string).join(edit.new_string || "")
: after.replace(edit.old_string, edit.new_string || "");
}
return {
path: path,
before: before,
after: after,
exists: true,
title: `Edit ${relativize(path)} (${input.edits.length} changes)`,
};
}
return null;
}
/**
* Build a preview for a pending tool call.
* Returns { title, path, diff, additions, deletions, before, after } or null.
*/
function previewForTool(toolName, input) {
const change = proposedChange(toolName, input);
if (!change) return null;
const ops = diffLines(splitLines(change.before), splitLines(change.after));
const stats = counts(ops);
return {
title: change.title,
path: change.path,
before: change.before,
after: change.after,
exists: change.exists,
diff: renderUnified(ops),
additions: stats.additions,
deletions: stats.deletions,
};
}
/** Line number (1-based) where `needle` starts in the file, or null. */
function lineOfMatch(path, needle) {
if (!needle) return null;
const contents = readFile(path);
if (contents === null) return null;
const index = contents.indexOf(needle);
if (index < 0) return null;
return contents.slice(0, index).split("\n").length;
}
/** Open a scratch tab showing the proposed change. */
async function showPreview(preview) {
if (!preview) return null;
const header = [
`--- a/${relativize(preview.path)}`,
`+++ b/${relativize(preview.path)}`,
].join("\n");
const content = `${header}\n${preview.diff}\n`;
try {
return await nova.workspace.openNewTextDocument({
content: content,
syntax: "diff",
});
} catch (err) {
warn("could not open the change preview:", err);
return null;
}
}
module.exports = {
previewForTool,
proposedChange,
showPreview,
readFile,
lineOfMatch,
diffLines,
renderUnified,
};