diff --git a/packages/ui/src/components/apply-patch-file.test.ts b/packages/ui/src/components/apply-patch-file.test.ts index 5176eb998..f7a8e7788 100644 --- a/packages/ui/src/components/apply-patch-file.test.ts +++ b/packages/ui/src/components/apply-patch-file.test.ts @@ -18,6 +18,7 @@ describe("apply patch file", () => { expect(file).toBeDefined() expect(file?.view.fileDiff.name).toBe("a.ts") + expect(file?.view.fileDiff.isPartial).toBe(false) expect(text(file!.view, "deletions")).toBe("one\ntwo\n") expect(text(file!.view, "additions")).toBe("one\nthree\n") }) diff --git a/packages/ui/src/components/session-diff.test.ts b/packages/ui/src/components/session-diff.test.ts index ba8fd395e..302844b40 100644 --- a/packages/ui/src/components/session-diff.test.ts +++ b/packages/ui/src/components/session-diff.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { normalize, resolveFileDiff, text } from "./session-diff" describe("session diff", () => { - test("keeps unified patch content", () => { + test("renders whole-file unified patches as complete diffs", () => { const diff = { file: "a.ts", patch: @@ -14,7 +14,7 @@ describe("session diff", () => { const view = normalize(diff) expect(view.fileDiff.name).toBe("a.ts") - expect(view.fileDiff.isPartial).toBe(true) + expect(view.fileDiff.isPartial).toBe(false) expect(text(view, "deletions")).toBe("one\ntwo\n") expect(text(view, "additions")).toBe("one\nthree\n") }) @@ -34,6 +34,26 @@ describe("session diff", () => { expect(text(view, "additions")).toBe("one\nthree") }) + test("renders whole-file VCS patches as complete diffs", () => { + const fileDiff = resolveFileDiff({ + file: "a.ts", + patch: "diff --git a/a.ts b/a.ts\nindex 1a2b3c4..5d6e7f8 100644\n--- a/a.ts\n+++ b/a.ts\n@@ -1,2 +1,2 @@\n one\n-old\n+new\n", + }) + + expect(fileDiff.isPartial).toBe(false) + expect(fileDiff.additionLines).toEqual(["one\n", "new\n"]) + }) + + test("keeps ordinary leading tool patches partial", () => { + const fileDiff = resolveFileDiff({ + file: "a.ts", + patch: "Index: a.ts\n===================================================================\n--- a.ts\n+++ a.ts\n@@ -1,5 +1,5 @@\n-old\n+new\n two\n three\n four\n five\n", + }) + + expect(fileDiff.isPartial).toBe(true) + expect(fileDiff.additionLines).toEqual(["new\n", "two\n", "three\n", "four\n", "five\n"]) + }) + test("keeps separated patch hunks partial without complete file contents", () => { const fileDiff = resolveFileDiff({ file: "project.ts", diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index 01f0e2b9f..7c9cdf01b 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -60,13 +60,68 @@ function fileDiffFromPatch(file: string, patch: string) { return hit } - const input = patchInput(file, patch) - const value = (input ? parsePatchFiles(input)[0]?.files[0] : undefined) ?? emptyFileDiff(file) + const contents = completePatchContents(patch) + const input = contents ? undefined : patchInput(file, patch) + const value = contents + ? fileDiffFromContent(file, contents.before, contents.after) + : (input ? parsePatchFiles(input)[0]?.files[0] : undefined) ?? emptyFileDiff(file) patchFileDiffCache.set(key, value) while (patchFileDiffCache.size > diffCacheLimit) patchFileDiffCache.delete(patchFileDiffCache.keys().next().value!) return value } +function completePatchContents(patch: string) { + try { + const parsed = parsePatch(patch)[0] + if (!parsed || (!parsed.index && !parsed.oldFileName && !parsed.newFileName)) return + // Snapshot and VCS producers request full context. Tool patches use jsdiff's shorter default context. + if (!patch.startsWith("diff --git ") && !/^--- [^\n]*\t\r?\n\+\+\+ [^\n]*\t(?:\r?\n|$)/m.test(patch)) return + // Full patches collapse into one leading hunk. Separated hunks omit ranges and must stay partial. + if (parsed.hunks.length !== 1) return + + const hunk = parsed.hunks[0] + if (!hunk || hunk.oldStart > 1 || hunk.newStart > 1) return + + const before: Array<{ text: string; newline: boolean }> = [] + const after: Array<{ text: string; newline: boolean }> = [] + let previous: "-" | "+" | " " | undefined + + for (const line of hunk.lines) { + if (line.startsWith("\\")) { + if (previous === "-" || previous === " ") { + const value = before.at(-1) + if (value) value.newline = false + } + if (previous === "+" || previous === " ") { + const value = after.at(-1) + if (value) value.newline = false + } + continue + } + if (line.startsWith("-")) { + before.push({ text: line.slice(1), newline: true }) + previous = "-" + continue + } + if (line.startsWith("+")) { + after.push({ text: line.slice(1), newline: true }) + previous = "+" + continue + } + if (!line.startsWith(" ")) return + before.push({ text: line.slice(1), newline: true }) + after.push({ text: line.slice(1), newline: true }) + previous = " " + } + + const text = (lines: Array<{ text: string; newline: boolean }>) => + lines.map((line) => line.text + (line.newline ? "\n" : "")).join("") + return { before: text(before), after: text(after) } + } catch { + return + } +} + function patchInput(file: string, patch: string) { try { const parsed = parsePatch(patch)[0]