From f7311b461f9f8ed28b3d61b21d7c58ac604c9944 Mon Sep 17 00:00:00 2001 From: Nick DeJesus Date: Fri, 29 May 2026 14:35:55 -0400 Subject: [PATCH] fix(desktop): collapse full-context git diffs --- .../ui/src/components/session-diff.test.ts | 55 +++++++++ packages/ui/src/components/session-diff.ts | 115 ++++++++++++++++-- 2 files changed, 161 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/components/session-diff.test.ts b/packages/ui/src/components/session-diff.test.ts index ba8fd395ea0d..273b2f6fe0bd 100644 --- a/packages/ui/src/components/session-diff.test.ts +++ b/packages/ui/src/components/session-diff.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { formatPatch, structuredPatch } from "diff" import { normalize, resolveFileDiff, text } from "./session-diff" describe("session diff", () => { @@ -46,6 +47,60 @@ describe("session diff", () => { expect(fileDiff.hunks[1]?.collapsedBefore).toBeGreaterThan(0) }) + test("renders full-context generated patches as concise complete diffs", () => { + const before = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`).join("\n") + "\n" + const after = before.replace("line 15", "changed line 15") + const view = normalize({ + file: "big.txt", + patch: formatPatch( + structuredPatch("big.txt", "big.txt", before, after, "", "", { context: Number.MAX_SAFE_INTEGER }), + ), + additions: 1, + deletions: 1, + status: "modified" as const, + }) + + expect(view.fileDiff.isPartial).toBe(false) + expect(view.fileDiff.hunks).toHaveLength(1) + expect(view.fileDiff.hunks[0]?.collapsedBefore).toBeGreaterThan(0) + expect(view.fileDiff.hunks[0]?.splitLineCount).toBeLessThan(30) + expect(text(view, "deletions")).toBe(before) + expect(text(view, "additions")).toBe(after) + }) + + test("renders generated patches with edge changes as concise complete diffs", () => { + const before = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`).join("\n") + "\n" + const after = before.replace("line 4", "changed line 4").replace("line 27", "changed line 27") + const view = normalize({ + file: "big.txt", + patch: formatPatch( + structuredPatch("big.txt", "big.txt", before, after, "", "", { context: Number.MAX_SAFE_INTEGER }), + ), + additions: 2, + deletions: 2, + status: "modified" as const, + }) + + expect(view.fileDiff.isPartial).toBe(false) + expect(view.fileDiff.hunks).toHaveLength(2) + expect(view.fileDiff.hunks[0]?.splitLineCount).toBeLessThan(30) + expect(text(view, "deletions")).toBe(before) + expect(text(view, "additions")).toBe(after) + }) + + test("keeps top-of-file patches with standard context partial", () => { + const fileDiff = resolveFileDiff({ + file: "a.ts", + patch: + "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,4 +1,4 @@\n-old\n+new\n line 2\n line 3\n line 4\n", + }) + const view = { file: "a.ts", additions: 1, deletions: 1, fileDiff } + + expect(fileDiff.isPartial).toBe(true) + expect(text(view, "deletions")).toBe("old\nline 2\nline 3\nline 4\n") + expect(text(view, "additions")).toBe("new\nline 2\nline 3\nline 4\n") + }) + test("renders headerless persisted patches", () => { const view = normalize({ file: "a.ts", diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index 01f0e2b9f972..685122e9ce0c 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -1,5 +1,5 @@ import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs" -import { parsePatch } from "diff" +import { parsePatch, type StructuredPatch, type StructuredPatchHunk } from "diff" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" type LegacyDiff = { @@ -25,6 +25,7 @@ export type ViewDiff = { } const diffCacheLimit = 16 +const generatedContextThreshold = 10 const patchFileDiffCache = new Map() export function resolveFileDiff(diff: DiffSource) { @@ -60,25 +61,121 @@ 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 source = patchSource(file, patch) + const value = source.complete + ? fileDiffFromContent(file, source.complete.before, source.complete.after) + : (source.input ? parsePatchFiles(source.input)[0]?.files[0] : undefined) ?? emptyFileDiff(file) patchFileDiffCache.set(key, value) while (patchFileDiffCache.size > diffCacheLimit) patchFileDiffCache.delete(patchFileDiffCache.keys().next().value!) return value } -function patchInput(file: string, patch: string) { +function patchSource(file: string, patch: string) { try { const parsed = parsePatch(patch)[0] - if (!parsed) return - if (parsed.index || parsed.oldFileName || parsed.newFileName) return patch - if (!parsed.hunks.length) return - return `Index: ${file}\n===================================================================\n--- ${file}\t\n+++ ${file}\t\n${patch}` + if (!parsed) return {} + return { + complete: completeFileContentsFromPatch(parsed), + input: patchInput(file, patch, parsed), + } } catch { - return + return {} } } +function completeFileContentsFromPatch(patch: StructuredPatch) { + if (!hasFileHeader(patch)) return + if (!hasCompleteFileHunks(patch.hunks)) return + return contentsFromHunks(patch.hunks) +} + +function hasFileHeader(patch: StructuredPatch) { + return "index" in patch || "oldFileName" in patch || "newFileName" in patch +} + +function hasCompleteFileHunks(hunks: StructuredPatchHunk[]) { + const first = hunks[0] + if (!first) return false + if (!startsAtFileBoundary(first.oldStart, first.oldLines)) return false + if (!startsAtFileBoundary(first.newStart, first.newLines)) return false + if (!hasGeneratedContext(hunks)) return false + + let oldStart = first.oldStart + first.oldLines + let newStart = first.newStart + first.newLines + for (const hunk of hunks.slice(1)) { + if (hunk.oldStart !== oldStart) return false + if (hunk.newStart !== newStart) return false + oldStart += hunk.oldLines + newStart += hunk.newLines + } + return true +} + +function startsAtFileBoundary(start: number, lines: number) { + if (start === 1) return true + return start === 0 && lines === 0 +} + +function hasGeneratedContext(hunks: StructuredPatchHunk[]) { + return hunks.some((hunk) => hunk.lines.some(longContextRun)) +} + +function longContextRun(line: string, index: number, lines: string[]) { + if (!contextLine(line)) return false + if (index > 0 && contextLine(lines[index - 1])) return false + const end = lines.slice(index).findIndex((line) => !contextLine(line)) + return (end === -1 ? lines.length - index : end) > generatedContextThreshold +} + +function contextLine(line: string) { + return line.startsWith(" ") +} + +function contentsFromHunks(hunks: StructuredPatchHunk[]) { + const beforeLines: Array<{ text: string; newline: boolean }> = [] + const afterLines: Array<{ text: string; newline: boolean }> = [] + let previous: "-" | "+" | " " | undefined + + for (const hunk of hunks) { + for (const line of hunk.lines) { + if (line.startsWith("\\")) { + const before = beforeLines.at(-1) + const after = afterLines.at(-1) + if ((previous === "-" || previous === " ") && before) before.newline = false + if ((previous === "+" || previous === " ") && after) after.newline = false + continue + } + + if (line.startsWith("-")) { + beforeLines.push({ text: line.slice(1), newline: true }) + previous = "-" + continue + } + + if (line.startsWith("+")) { + afterLines.push({ text: line.slice(1), newline: true }) + previous = "+" + continue + } + + beforeLines.push({ text: line.slice(1), newline: true }) + afterLines.push({ text: line.slice(1), newline: true }) + previous = " " + } + } + + return { + before: beforeLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""), + after: afterLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""), + } +} + +function patchInput(file: string, patch: string, parsed: StructuredPatch) { + if (parsed.index || parsed.oldFileName || parsed.newFileName) return patch + if (!parsed.hunks.length) return + return `Index: ${file}\n===================================================================\n--- ${file}\t\n+++ ${file}\t\n${patch}` +} + function fileDiffFromContent(file: string, before: string, after: string) { if (!before && !after) return emptyFileDiff(file) return parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after })