First version
This commit is contained in:
+299
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user