// // 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, };