diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index 4b8e6359c8c6..fdb59af22afa 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -18,6 +18,7 @@ import type { SessionReviewFocus, SessionReviewLineComment, } from "@opencode-ai/session-ui/session-review" +import { truncateDiffs } from "@opencode-ai/core/util/truncate-diff" import FileTreeV2 from "@/components/file-tree-v2" import { useLanguage } from "@/context/language" import { useSDK } from "@/context/sdk" @@ -54,7 +55,16 @@ export type ReviewPanelV2Props = { export function ReviewPanelV2(props: ReviewPanelV2Props) { const sdk = useSDK() - const diffs = createMemo(() => props.diffs().filter(filterRenderableDiff)) + const rawDiffs = createMemo(() => props.diffs().filter(filterRenderableDiff)) + const truncateResult = createMemo(() => { + const config = sdk().config + return truncateDiffs(rawDiffs(), { + maxFiles: config?.diff?.max_files, + maxPatchBytes: config?.diff?.max_patch_bytes, + }) + }) + const diffs = createMemo(() => truncateResult().diffs) + const truncated = createMemo(() => truncateResult().truncated) const filteredFiles = createMemo(() => filterReviewFiles( diffs().map((diff) => diff.file), @@ -88,7 +98,21 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { return ( } + stats={ + <> + + + + ({diffs().length}/{truncated().totalFiles} files - truncated) + + + 0}> + + ({truncated().patches} large patches truncated) + + + + } empty={props.empty} sidebarOpen={props.state.sidebarOpened()} sidebarToggle={ @@ -107,6 +131,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { searching={searching} kinds={kinds} activeDiff={activeDiff} + truncated={truncated} /> } activeFile={activeDiff()} @@ -157,6 +182,7 @@ function ReviewPanelV2Sidebar(props: { searching: () => boolean kinds: () => ReturnType activeDiff: () => string | undefined + truncated: () => { files: boolean; patches: number; totalFiles: number } }) { const language = useLanguage() const [explicitHighlight, setExplicitHighlight] = createSignal() @@ -181,7 +207,16 @@ function ReviewPanelV2Sidebar(props: { } + stats={ + <> + + + + ({props.diffs().length}/{props.truncated().totalFiles} truncated) + + + + } filter={props.state.filter()} onFilterChange={props.state.setFilter} onFilterKeyDown={onFilterKeyDown} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index c76486968b07..f710c2a58548 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -14,6 +14,7 @@ import { ConfigAgent } from "./config/agent" import { ConfigAttachments } from "./config/attachments" import { ConfigCompaction } from "./config/compaction" import { ConfigCommand } from "./config/command" +import { ConfigDiff } from "./config/diff" import { ConfigExperimental } from "./config/experimental" import { ConfigFormatter } from "./config/formatter" import { ConfigLSP } from "./config/lsp" @@ -81,6 +82,9 @@ export class Info extends Schema.Class("Config.Info")({ tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({ description: "Tool output truncation thresholds", }), + diff: ConfigDiff.Info.pipe(Schema.optional).annotate({ + description: "Diff viewer rendering limits", + }), mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({ description: "MCP server configuration", }), diff --git a/packages/core/src/config/diff.ts b/packages/core/src/config/diff.ts new file mode 100644 index 000000000000..efb8b2a03328 --- /dev/null +++ b/packages/core/src/config/diff.ts @@ -0,0 +1,16 @@ +export * as ConfigDiff from "./diff" + +import { Schema } from "effect" +import { PositiveInt } from "../schema" + +export class Info extends Schema.Class("ConfigV2.Diff")({ + max_files: PositiveInt.pipe(Schema.optional).annotate({ + description: "Maximum number of files to render in diff viewers (default: 1000)", + }), + max_patch_bytes: PositiveInt.pipe(Schema.optional).annotate({ + description: "Maximum size in bytes for a single file patch (default: 102400 = 100KB)", + }), +}) {} + +export const DEFAULT_MAX_FILES = 1000 +export const DEFAULT_MAX_PATCH_BYTES = 102400 // 100KB diff --git a/packages/core/src/util/truncate-diff.ts b/packages/core/src/util/truncate-diff.ts new file mode 100644 index 000000000000..3b44f2ee9c28 --- /dev/null +++ b/packages/core/src/util/truncate-diff.ts @@ -0,0 +1,70 @@ +import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/schema/vcs" +import { ConfigDiff } from "../config/diff" + +export type DiffLike = SnapshotFileDiff | VcsFileDiff + +export interface TruncateDiffOptions { + maxFiles?: number + maxPatchBytes?: number +} + +export interface TruncateDiffResult { + diffs: T[] + truncated: { + files: boolean + patches: number + totalFiles: number + } +} + +/** + * Truncate diffs to prevent UI freezing with large changesets. + * + * Applies two limits: + * - `maxFiles`: Maximum number of file diffs to return (default: 1000) + * - `maxPatchBytes`: Maximum size for a single file patch in bytes (default: 100KB) + * + * When a patch exceeds `maxPatchBytes`, it is replaced with a truncation notice. + */ +export function truncateDiffs( + diffs: readonly T[], + options: TruncateDiffOptions = {}, +): TruncateDiffResult { + const maxFiles = options.maxFiles ?? ConfigDiff.DEFAULT_MAX_FILES + const maxPatchBytes = options.maxPatchBytes ?? ConfigDiff.DEFAULT_MAX_PATCH_BYTES + + const totalFiles = diffs.length + const truncatedFiles = totalFiles > maxFiles + const selectedDiffs = truncatedFiles ? diffs.slice(0, maxFiles) : diffs + + let truncatedPatches = 0 + const processedDiffs = selectedDiffs.map((diff) => { + if (!diff.patch) return diff + + const patchBytes = Buffer.byteLength(diff.patch, "utf8") + if (patchBytes <= maxPatchBytes) return diff + + truncatedPatches++ + const truncationNotice = `[Patch truncated: ${formatBytes(patchBytes)} exceeds ${formatBytes(maxPatchBytes)} limit]\n\nThis file has too many changes to render safely. To review:\n- View the file directly\n- Use git diff on the command line\n- Increase diff.max_patch_bytes in your config` + + return { + ...diff, + patch: truncationNotice, + } as T + }) + + return { + diffs: processedDiffs, + truncated: { + files: truncatedFiles, + patches: truncatedPatches, + totalFiles, + }, + } +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} diff --git a/packages/core/test/util/truncate-diff.test.ts b/packages/core/test/util/truncate-diff.test.ts new file mode 100644 index 000000000000..352da13a0bac --- /dev/null +++ b/packages/core/test/util/truncate-diff.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test" +import { truncateDiffs } from "../../src/util/truncate-diff" +import type { VcsFileDiff } from "@opencode-ai/schema/vcs" + +describe("truncateDiffs", () => { + test("returns all diffs when under file limit", () => { + const diffs: VcsFileDiff[] = [ + { file: "a.ts", patch: "diff content", additions: 1, deletions: 0 }, + { file: "b.ts", patch: "diff content", additions: 2, deletions: 1 }, + ] + + const result = truncateDiffs(diffs, { maxFiles: 10 }) + + expect(result.diffs).toHaveLength(2) + expect(result.truncated.files).toBe(false) + expect(result.truncated.patches).toBe(0) + expect(result.truncated.totalFiles).toBe(2) + }) + + test("truncates files when over limit", () => { + const diffs: VcsFileDiff[] = Array.from({ length: 15 }, (_, i) => ({ + file: `file-${i}.ts`, + patch: "small diff", + additions: 1, + deletions: 0, + })) + + const result = truncateDiffs(diffs, { maxFiles: 10 }) + + expect(result.diffs).toHaveLength(10) + expect(result.truncated.files).toBe(true) + expect(result.truncated.totalFiles).toBe(15) + }) + + test("truncates large patches", () => { + const smallPatch = "a".repeat(100) + const largePatch = "b".repeat(200000) // 200KB + const diffs: VcsFileDiff[] = [ + { file: "small.ts", patch: smallPatch, additions: 1, deletions: 0 }, + { file: "large.ts", patch: largePatch, additions: 1000, deletions: 500 }, + ] + + const result = truncateDiffs(diffs, { maxPatchBytes: 100000 }) + + expect(result.diffs).toHaveLength(2) + expect(result.diffs[0].patch).toBe(smallPatch) + expect(result.diffs[1].patch).toContain("[Patch truncated:") + expect(result.diffs[1].patch).toContain("exceeds") + expect(result.truncated.patches).toBe(1) + }) + + test("uses default limits when not specified", () => { + const diffs: VcsFileDiff[] = Array.from({ length: 5 }, (_, i) => ({ + file: `file-${i}.ts`, + patch: "a".repeat(1000), + additions: 1, + deletions: 0, + })) + + const result = truncateDiffs(diffs) + + expect(result.diffs).toHaveLength(5) + expect(result.truncated.files).toBe(false) + expect(result.truncated.patches).toBe(0) + }) + + test("handles diffs without patches", () => { + const diffs: VcsFileDiff[] = [ + { file: "binary.png", additions: 0, deletions: 0 }, + { file: "text.ts", patch: "diff", additions: 1, deletions: 0 }, + ] + + const result = truncateDiffs(diffs) + + expect(result.diffs).toHaveLength(2) + expect(result.diffs[0].patch).toBeUndefined() + expect(result.diffs[1].patch).toBe("diff") + }) + + test("combines file and patch truncation", () => { + const largePatch = "x".repeat(150000) + const diffs: VcsFileDiff[] = Array.from({ length: 15 }, (_, i) => ({ + file: `file-${i}.ts`, + patch: i < 5 ? largePatch : "small", + additions: 10, + deletions: 5, + })) + + const result = truncateDiffs(diffs, { maxFiles: 10, maxPatchBytes: 100000 }) + + expect(result.diffs).toHaveLength(10) + expect(result.truncated.files).toBe(true) + expect(result.truncated.totalFiles).toBe(15) + expect(result.truncated.patches).toBe(5) // First 5 have large patches + }) +}) diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index ed88a1107f9d..f9f047470c50 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -8,6 +8,7 @@ import { type DiffRenderable, type ScrollBoxRenderable, } from "@opentui/core" +import { truncateDiffs } from "@opencode-ai/core/util/truncate-diff" import { LANGUAGE_EXTENSIONS } from "../../util/filetype" import { useBindings, useCommandShortcut } from "../../keymap" import { useTheme } from "../../context/theme" @@ -114,21 +115,30 @@ function DiffViewer(props: { api: TuiPluginApi }) { const [diff] = createResource(diffInput, async (input) => { if (input.mode === "last-turn") { const sessionID = input.sessionID - if (!sessionID) return [] + if (!sessionID) return { diffs: [], truncated: { files: false, patches: 0, totalFiles: 0 } } const result = await props.api.client.session.diff( { sessionID, messageID: input.messageID }, { throwOnError: true }, ) - return normalizeDiffs(result.data ?? []) + const normalized = normalizeDiffs(result.data ?? []) + return truncateDiffs(normalized, { + maxFiles: props.api.config?.diff?.max_files, + maxPatchBytes: props.api.config?.diff?.max_patch_bytes, + }) } const result = await props.api.client.vcs.diff( { directory: input.directory, mode: input.mode, context: VCS_DIFF_CONTEXT_LINES }, { throwOnError: true }, ) - return normalizeDiffs(result.data ?? []) + const normalized = normalizeDiffs(result.data ?? []) + return truncateDiffs(normalized, { + maxFiles: props.api.config?.diff?.max_files, + maxPatchBytes: props.api.config?.diff?.max_patch_bytes, + }) }) - const files = createMemo(() => diff() ?? []) + const files = createMemo(() => diff()?.diffs ?? []) + const truncated = createMemo(() => diff()?.truncated) const [focus, setFocus] = createSignal("patches") const [fileTreeEnabled, setFileTreeEnabled] = createSignal( props.api.kv.get(KV_SHOW_FILE_TREE, true) !== false, @@ -756,9 +766,16 @@ function DiffViewer(props: { api: TuiPluginApi }) { Diff {diffSourceLabel(mode())} - - {files().length} {files().length === 1 ? "file" : "files"} - + + + {files().length}/{truncated()?.totalFiles} files (truncated) + + + + + {files().length} {files().length === 1 ? "file" : "files"} + +