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
62 changes: 41 additions & 21 deletions packages/opencode/src/cli/cmd/run/scrollback.writer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { toolFiletype, toolStructuredFinal } from "./tool"
import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from "./types"

const MAX_DIFF_RENDER_CHARS = 512 * 1024
const MAX_DIFF_RENDER_LINES = 5_000

function todoText(item: { status: string; content: string }): string {
if (item.status === "completed") {
return `[✓] ${item.content}`
Expand All @@ -27,6 +30,17 @@ function todoColor(theme: RunTheme, status: string) {
return status === "in_progress" ? theme.footer.warning : theme.block.muted
}

function tooLargeDiff(diff: string) {
if (diff.length > MAX_DIFF_RENDER_CHARS) return true
let lines = 1
for (let i = 0; i < diff.length; i++) {
if (diff.charCodeAt(i) !== 10) continue
lines++
if (lines > MAX_DIFF_RENDER_LINES) return true
}
return false
}

export function entryGroupKey(commit: StreamCommit): string | undefined {
if (!commit.partID) {
return undefined
Expand Down Expand Up @@ -193,27 +207,33 @@ export function RunEntryContent(props: {
{item.title}
</text>
{item.diff.trim() ? (
<box width="100%" paddingLeft={1}>
<diff
diff={item.diff}
view="unified"
filetype={toolFiletype(item.file)}
syntaxStyle={syntax()}
showLineNumbers={true}
width="100%"
wrapMode="word"
fg={theme().block.text}
addedBg={diffBg(theme().block.diffAddedBg)}
removedBg={diffBg(theme().block.diffRemovedBg)}
contextBg={diffBg(theme().block.diffContextBg)}
addedSignColor={theme().block.diffHighlightAdded}
removedSignColor={theme().block.diffHighlightRemoved}
lineNumberFg={theme().block.diffLineNumber}
lineNumberBg={diffBg(theme().block.diffContextBg)}
addedLineNumberBg={diffBg(theme().block.diffAddedLineNumberBg)}
removedLineNumberBg={diffBg(theme().block.diffRemovedLineNumberBg)}
/>
</box>
tooLargeDiff(item.diff) ? (
<text width="100%" wrapMode="word" fg={theme().block.muted}>
Diff too large to render safely; open the file or inspect the patch from disk.
</text>
) : (
<box width="100%" paddingLeft={1}>
<diff
diff={item.diff}
view="unified"
filetype={toolFiletype(item.file)}
syntaxStyle={syntax()}
showLineNumbers={true}
width="100%"
wrapMode="word"
fg={theme().block.text}
addedBg={diffBg(theme().block.diffAddedBg)}
removedBg={diffBg(theme().block.diffRemovedBg)}
contextBg={diffBg(theme().block.diffContextBg)}
addedSignColor={theme().block.diffHighlightAdded}
removedSignColor={theme().block.diffHighlightRemoved}
lineNumberFg={theme().block.diffLineNumber}
lineNumberBg={diffBg(theme().block.diffContextBg)}
addedLineNumberBg={diffBg(theme().block.diffAddedLineNumberBg)}
removedLineNumberBg={diffBg(theme().block.diffRemovedLineNumberBg)}
/>
</box>
)
) : (
<text width="100%" wrapMode="word" fg={theme().block.diffRemoved}>
-{item.deletions ?? 0} line{item.deletions === 1 ? "" : "s"}
Expand Down
203 changes: 61 additions & 142 deletions packages/opencode/src/snapshot/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Cause, Duration, Effect, Layer, Schedule, Schema, Semaphore, Context } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import { AppProcess } from "@opencode-ai/core/process"
import { InstanceState } from "@/effect/instance-state"
Expand Down Expand Up @@ -31,6 +30,9 @@ export type FileDiff = typeof FileDiff.Type
const log = Log.create({ service: "snapshot" })
const prune = "7.days"
const limit = 2 * 1024 * 1024
const diffPatchLineLimit = 1_000
const diffPatchByteLimit = 256 * 1024
const diffPatchTotalByteLimit = 2 * 1024 * 1024
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
const cfg = ["-c", "core.autocrlf=false", ...core]
const quote = [...cfg, "-c", "core.quotepath=false"]
Expand Down Expand Up @@ -506,133 +508,6 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
deletions: number
}

type Ref = {
file: string
side: "before" | "after"
ref: string
}

const show = Effect.fnUntraced(function* (row: Row) {
if (row.binary) return ["", ""]
if (row.status === "added") {
return [
"",
yield* git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(
Effect.map((item) => item.text),
),
]
}
if (row.status === "deleted") {
return [
yield* git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(
Effect.map((item) => item.text),
),
"",
]
}
return yield* Effect.all(
[
git([...cfg, ...args(["show", `${from}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
git([...cfg, ...args(["show", `${to}:${row.file}`])]).pipe(Effect.map((item) => item.text)),
],
{ concurrency: 2 },
)
})

const load = Effect.fnUntraced(
function* (rows: Row[]) {
const refs = rows.flatMap((row) => {
if (row.binary) return []
if (row.status === "added")
return [{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref]
if (row.status === "deleted") {
return [{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref]
}
return [
{ file: row.file, side: "before", ref: `${from}:${row.file}` } satisfies Ref,
{ file: row.file, side: "after", ref: `${to}:${row.file}` } satisfies Ref,
]
})
if (!refs.length) return new Map<string, { before: string; after: string }>()

const batch = yield* appProcess.run(
ChildProcess.make("git", [...cfg, ...args(["cat-file", "--batch"])], {
cwd: state.directory,
extendEnv: true,
}),
{ stdin: refs.map((item) => item.ref).join("\n") + "\n" },
)
if (batch.exitCode !== 0) {
log.info("git cat-file --batch failed during snapshot diff, falling back to per-file git show", {
stderr: batch.stderr.toString("utf8"),
refs: refs.length,
})
return
}
const out = batch.stdout

const fail = (msg: string, extra?: Record<string, string>) => {
log.info(msg, { ...extra, refs: refs.length })
return undefined
}

const map = new Map<string, { before: string; after: string }>()
const dec = new TextDecoder()
let i = 0
for (const ref of refs) {
let end = i
while (end < out.length && out[end] !== 10) end += 1
if (end >= out.length) {
return fail(
"git cat-file --batch returned a truncated header during snapshot diff, falling back to per-file git show",
)
}

const head = dec.decode(out.slice(i, end))
i = end + 1
const hit = map.get(ref.file) ?? { before: "", after: "" }
if (head.endsWith(" missing")) {
map.set(ref.file, hit)
continue
}

const match = head.match(/^[0-9a-f]+ blob (\d+)$/)
if (!match) {
return fail(
"git cat-file --batch returned an unexpected header during snapshot diff, falling back to per-file git show",
{ head },
)
}

const size = Number(match[1])
if (!Number.isInteger(size) || size < 0 || i + size >= out.length || out[i + size] !== 10) {
return fail(
"git cat-file --batch returned truncated content during snapshot diff, falling back to per-file git show",
{ head },
)
}

const text = dec.decode(out.slice(i, i + size))
if (ref.side === "before") hit.before = text
if (ref.side === "after") hit.after = text
map.set(ref.file, hit)
i += size + 1
}

if (i !== out.length) {
return fail(
"git cat-file --batch returned trailing data during snapshot diff, falling back to per-file git show",
)
}

return map
},
Effect.scoped,
Effect.catch(() =>
Effect.succeed<Map<string, { before: string; after: string }> | undefined>(undefined),
),
)

const result: FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">()

Expand Down Expand Up @@ -684,25 +559,69 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | AppProce
rows.push(...filtered)
}

const step = 100
const patch = (file: string, before: string, after: string) =>
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
let totalPatchBytes = 0
const patch = Effect.fnUntraced(function* (row: Row) {
if (row.binary) return ""
const changed = row.additions + row.deletions
if (changed > diffPatchLineLimit) {
log.info("snapshot diff patch skipped because changed line limit was exceeded", {
file: row.file,
changed,
max: diffPatchLineLimit,
})
return
}
if (totalPatchBytes >= diffPatchTotalByteLimit) return

for (let i = 0; i < rows.length; i += step) {
const run = rows.slice(i, i + step)
const text = yield* load(run)
const patch = yield* git(
[
...quote,
...args(["diff", "--no-ext-diff", "--no-renames", "--unified=3", from, to, "--", row.file]),
],
{ cwd: state.directory },
)
if (patch.code !== 0) {
log.warn("failed to get snapshot file patch", {
file: row.file,
exitCode: patch.code,
stderr: patch.stderr,
})
return
}

for (const row of run) {
const hit = text?.get(row.file) ?? { before: "", after: "" }
const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row)
result.push({
const text = patch.text.trimEnd()
if (!text) return ""
const bytes = Buffer.byteLength(text)
if (bytes > diffPatchByteLimit) {
log.info("snapshot diff patch skipped because byte limit was exceeded", {
file: row.file,
bytes,
max: diffPatchByteLimit,
})
return
}
if (totalPatchBytes + bytes > diffPatchTotalByteLimit) {
log.info("snapshot diff patch skipped because total byte limit was exceeded", {
file: row.file,
patch: row.binary ? "" : patch(row.file, before, after),
additions: row.additions,
deletions: row.deletions,
status: row.status,
bytes: totalPatchBytes + bytes,
max: diffPatchTotalByteLimit,
})
return
}

totalPatchBytes += bytes
return text
})

for (const row of rows) {
const item = yield* patch(row)
result.push({
file: row.file,
...(item === undefined ? {} : { patch: item }),
additions: row.additions,
deletions: row.deletions,
status: row.status,
})
}

return result
Expand Down
19 changes: 19 additions & 0 deletions packages/opencode/test/snapshot/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,25 @@ it.instance(
{ git: true },
)

it.instance(
"diffFull omits patches that are too large to render safely",
withTrackedSnapshot(({ tmp, snapshot, before }) =>
Effect.gen(function* () {
const lines = Array.from({ length: 1_200 }, (_, i) => `line-${i}`).join("\n")
yield* write(`${tmp.path}/large.txt`, lines)
const after = yield* snapshot.track()
expect(after).toBeTruthy()
const diffs = yield* snapshot.diffFull(before, after!)
const diff = diffs.find((item) => item.file === "large.txt")
expect(diff).toBeDefined()
expect(diff!.additions).toBe(1_200)
expect(diff!.deletions).toBe(0)
expect(diff!.patch).toBeUndefined()
}),
),
{ git: true },
)

it.instance(
"diffFull with file modifications",
withTrackedSnapshot(({ tmp, snapshot, before }) =>
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/components/session-turn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -459,8 +459,8 @@ export function SessionTurn(
>
<For each={visible()}>
{(diff) => {
const view = normalize(diff)
const active = createMemo(() => expanded().includes(diff.file))
const view = createMemo(() => normalize(diff))
const [shown, setShown] = createSignal(false)

createEffect(
Expand Down Expand Up @@ -508,7 +508,7 @@ export function SessionTurn(
<Accordion.Content>
<Show when={shown()}>
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} />
<Dynamic component={fileComponent} mode="diff" fileDiff={view().fileDiff} />
</div>
</Show>
</Accordion.Content>
Expand Down
Loading