Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions packages/ui/src/components/session-diff.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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",
Expand Down
115 changes: 106 additions & 9 deletions packages/ui/src/components/session-diff.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -25,6 +25,7 @@ export type ViewDiff = {
}

const diffCacheLimit = 16
const generatedContextThreshold = 10
const patchFileDiffCache = new Map<string, FileDiffMetadata>()

export function resolveFileDiff(diff: DiffSource) {
Expand Down Expand Up @@ -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 })
Expand Down
Loading