diff options
| author | Dax <[email protected]> | 2026-04-07 19:48:23 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-07 19:48:23 -0400 |
| commit | b7fab49b64275b83bcec8200d7492fc5d15ffe06 (patch) | |
| tree | d9dadf65ca69eb4b8fe75654eb15666ee2b23774 /packages/ui | |
| parent | 463318486f94fa20e8d864d77708a347fa8423e3 (diff) | |
| download | opencode-b7fab49b64275b83bcec8200d7492fc5d15ffe06.tar.gz opencode-b7fab49b64275b83bcec8200d7492fc5d15ffe06.zip | |
refactor(snapshot): store unified patches in file diffs (#21244)
Co-authored-by: Adam <[email protected]>
Diffstat (limited to 'packages/ui')
| -rw-r--r-- | packages/ui/package.json | 1 | ||||
| -rw-r--r-- | packages/ui/src/components/file-media.tsx | 2 | ||||
| -rw-r--r-- | packages/ui/src/components/file-ssr.tsx | 37 | ||||
| -rw-r--r-- | packages/ui/src/components/file.tsx | 44 | ||||
| -rw-r--r-- | packages/ui/src/components/session-diff.test.ts | 37 | ||||
| -rw-r--r-- | packages/ui/src/components/session-diff.ts | 83 | ||||
| -rw-r--r-- | packages/ui/src/components/session-review.tsx | 36 | ||||
| -rw-r--r-- | packages/ui/src/components/session-turn.tsx | 20 | ||||
| -rw-r--r-- | packages/ui/src/context/data.tsx | 4 |
9 files changed, 215 insertions, 49 deletions
diff --git a/packages/ui/package.json b/packages/ui/package.json index 64520f707..d3e1cc942 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -53,6 +53,7 @@ "@solid-primitives/resize-observer": "2.1.3", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", + "diff": "catalog:", "dompurify": "3.3.1", "fuzzysort": "catalog:", "katex": "0.16.27", diff --git a/packages/ui/src/components/file-media.tsx b/packages/ui/src/components/file-media.tsx index 2fd54588a..f066019d7 100644 --- a/packages/ui/src/components/file-media.tsx +++ b/packages/ui/src/components/file-media.tsx @@ -16,6 +16,7 @@ export type FileMediaOptions = { current?: unknown before?: unknown after?: unknown + deleted?: boolean readFile?: (path: string) => Promise<FileContent | undefined> onLoad?: () => void onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void @@ -49,6 +50,7 @@ export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX const media = cfg() const k = kind() if (!media || !k) return false + if (media.deleted) return true if (k === "svg") return false if (media.current !== undefined) return false return !hasMediaValue(media.after as any) && hasMediaValue(media.before as any) diff --git a/packages/ui/src/components/file-ssr.tsx b/packages/ui/src/components/file-ssr.tsx index 952690783..fed5c8931 100644 --- a/packages/ui/src/components/file-ssr.tsx +++ b/packages/ui/src/components/file-ssr.tsx @@ -1,5 +1,5 @@ import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs" -import { type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" +import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js" import { Dynamic, isServer } from "solid-js/web" import { useWorkerPool } from "../context/worker-pool" @@ -16,8 +16,10 @@ import { import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer" import { File, type DiffFileProps, type FileProps } from "./file" +type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T> + type SSRDiffFileProps<T> = DiffFileProps<T> & { - preloadedDiff: PreloadMultiFileDiffResult<T> + preloadedDiff: DiffPreload<T> } function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) { @@ -32,6 +34,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) { const [local, others] = splitProps(props, [ "mode", "media", + "fileDiff", "before", "after", "class", @@ -90,12 +93,13 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) { onCleanup(observeViewerScheme(() => fileDiffRef)) const virtualizer = getVirtualizer() + const annotations = local.annotations ?? local.preloadedDiff.annotations ?? [] fileDiffInstance = virtualizer ? new VirtualizedFileDiff<T>( { ...createDefaultOptions(props.diffStyle), ...others, - ...local.preloadedDiff, + ...(local.preloadedDiff.options ?? {}), }, virtualizer, virtualMetrics, @@ -105,7 +109,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) { { ...createDefaultOptions(props.diffStyle), ...others, - ...local.preloadedDiff, + ...(local.preloadedDiff.options ?? {}), }, workerPool, ) @@ -114,13 +118,24 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) { // @ts-expect-error private field required for hydration fileDiffInstance.fileContainer = fileDiffRef - fileDiffInstance.hydrate({ - oldFile: local.before, - newFile: local.after, - lineAnnotations: local.annotations ?? [], - fileContainer: fileDiffRef, - containerWrapper: container, - }) + fileDiffInstance.hydrate( + local.fileDiff + ? { + fileDiff: local.fileDiff, + lineAnnotations: annotations, + fileContainer: fileDiffRef, + containerWrapper: container, + prerenderedHTML: local.preloadedDiff.prerenderedHTML, + } + : { + oldFile: local.before, + newFile: local.after, + lineAnnotations: annotations, + fileContainer: fileDiffRef, + containerWrapper: container, + prerenderedHTML: local.preloadedDiff.prerenderedHTML, + }, + ) notifyRendered() }) diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx index fb488729e..b78f0bae4 100644 --- a/packages/ui/src/components/file.tsx +++ b/packages/ui/src/components/file.tsx @@ -3,6 +3,7 @@ import { DEFAULT_VIRTUAL_FILE_METRICS, type DiffLineAnnotation, type FileContents, + type FileDiffMetadata, File as PierreFile, type FileDiffOptions, FileDiff, @@ -14,7 +15,7 @@ import { VirtualizedFileDiff, Virtualizer, } from "@pierre/diffs" -import { type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" +import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { createMediaQuery } from "@solid-primitives/media" import { makeEventListener } from "@solid-primitives/event-listener" import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js" @@ -80,15 +81,29 @@ export type TextFileProps<T = {}> = FileOptions<T> & preloadedDiff?: PreloadMultiFileDiffResult<T> } -export type DiffFileProps<T = {}> = FileDiffOptions<T> & +type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T> + +type DiffBaseProps<T> = FileDiffOptions<T> & SharedProps<T> & { mode: "diff" - before: FileContents - after: FileContents annotations?: DiffLineAnnotation<T>[] - preloadedDiff?: PreloadMultiFileDiffResult<T> + preloadedDiff?: DiffPreload<T> } +type DiffPairProps<T> = DiffBaseProps<T> & { + before: FileContents + after: FileContents + fileDiff?: undefined +} + +type DiffPatchProps<T> = DiffBaseProps<T> & { + fileDiff: FileDiffMetadata + before?: undefined + after?: undefined +} + +export type DiffFileProps<T = {}> = DiffPairProps<T> | DiffPatchProps<T> + export type FileProps<T = {}> = TextFileProps<T> | DiffFileProps<T> const sharedKeys = [ @@ -108,7 +123,7 @@ const sharedKeys = [ ] as const const textKeys = ["file", ...sharedKeys] as const -const diffKeys = ["before", "after", ...sharedKeys] as const +const diffKeys = ["fileDiff", "before", "after", ...sharedKeys] as const // --------------------------------------------------------------------------- // Shared viewer hook @@ -976,6 +991,12 @@ function DiffViewer<T>(props: DiffFileProps<T>) { const virtuals = createSharedVirtualStrategy(() => viewer.container) const large = createMemo(() => { + if (local.fileDiff) { + const before = local.fileDiff.deletionLines.join("") + const after = local.fileDiff.additionLines.join("") + return Math.max(before.length, after.length) > 500_000 + } + const before = typeof local.before?.contents === "string" ? local.before.contents : "" const after = typeof local.after?.contents === "string" ? local.after.contents : "" return Math.max(before.length, after.length) > 500_000 @@ -1054,6 +1075,17 @@ function DiffViewer<T>(props: DiffFileProps<T>) { instance = value }, draw: (value) => { + if (local.fileDiff) { + value.render({ + fileDiff: local.fileDiff, + lineAnnotations: [], + containerWrapper: viewer.container, + }) + return + } + + if (!local.before || !local.after) return + value.render({ oldFile: { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) }, newFile: { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) }, diff --git a/packages/ui/src/components/session-diff.test.ts b/packages/ui/src/components/session-diff.test.ts new file mode 100644 index 000000000..463a72977 --- /dev/null +++ b/packages/ui/src/components/session-diff.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { normalize, text } from "./session-diff" + +describe("session diff", () => { + test("keeps unified patch content", () => { + const diff = { + file: "a.ts", + patch: + "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n+three\n", + additions: 1, + deletions: 1, + status: "modified" as const, + } + const view = normalize(diff) + + expect(view.patch).toBe(diff.patch) + expect(view.fileDiff.name).toBe("a.ts") + expect(text(view, "deletions")).toBe("one\ntwo\n") + expect(text(view, "additions")).toBe("one\nthree\n") + }) + + test("converts legacy content into a patch", () => { + const diff = { + file: "a.ts", + before: "one\n", + after: "two\n", + additions: 1, + deletions: 1, + status: "modified" as const, + } + const view = normalize(diff) + + expect(view.patch).toContain("@@ -1,1 +1,1 @@") + expect(text(view, "deletions")).toBe("one\n") + expect(text(view, "additions")).toBe("two\n") + }) +}) diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts new file mode 100644 index 000000000..cc2b1ce52 --- /dev/null +++ b/packages/ui/src/components/session-diff.ts @@ -0,0 +1,83 @@ +import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs" +import { sampledChecksum } from "@opencode-ai/util/encode" +import { formatPatch, structuredPatch } from "diff" +import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" + +type LegacyDiff = { + file: string + patch?: string + before?: string + after?: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" +} + +type ReviewDiff = SnapshotFileDiff | VcsFileDiff | LegacyDiff + +export type ViewDiff = { + file: string + patch: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" + fileDiff: FileDiffMetadata +} + +const cache = new Map<string, FileDiffMetadata>() + +function empty(file: string, key: string) { + return { + name: file, + type: "change", + hunks: [], + splitLineCount: 0, + unifiedLineCount: 0, + isPartial: true, + deletionLines: [], + additionLines: [], + cacheKey: key, + } satisfies FileDiffMetadata +} + +function patch(diff: ReviewDiff) { + if (typeof diff.patch === "string") return diff.patch + return formatPatch( + structuredPatch( + diff.file, + diff.file, + "before" in diff && typeof diff.before === "string" ? diff.before : "", + "after" in diff && typeof diff.after === "string" ? diff.after : "", + "", + "", + { context: Number.MAX_SAFE_INTEGER }, + ), + ) +} + +function file(file: string, patch: string) { + const hit = cache.get(patch) + if (hit) return hit + + const key = sampledChecksum(patch) ?? file + const value = parsePatchFiles(patch, key).flatMap((item) => item.files)[0] ?? empty(file, key) + cache.set(patch, value) + return value +} + +export function normalize(diff: ReviewDiff): ViewDiff { + const next = patch(diff) + return { + file: diff.file, + patch: next, + additions: diff.additions, + deletions: diff.deletions, + status: diff.status, + fileDiff: file(diff.file, next), + } +} + +export function text(diff: ViewDiff, side: "deletions" | "additions") { + if (side === "deletions") return diff.fileDiff.deletionLines.join("") + return diff.fileDiff.additionLines.join("") +} diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 3b582d66f..90da853ef 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -15,7 +15,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js" import { createStore } from "solid-js/store" -import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2" +import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@opencode-ai/sdk/v2" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { type SelectedLineRange } from "@pierre/diffs" import { Dynamic } from "solid-js/web" @@ -23,6 +23,7 @@ import { mediaKindFromPath } from "../pierre/media" import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge" import { createLineCommentController } from "./line-comment-annotations" import type { LineCommentEditorProps } from "./line-comment" +import { normalize, text, type ViewDiff } from "./session-diff" const MAX_DIFF_CHANGED_LINES = 500 const REVIEW_MOUNT_MARGIN = 300 @@ -61,7 +62,8 @@ export type SessionReviewCommentActions = { export type SessionReviewFocus = { file: string; id: string } -type ReviewDiff = FileDiff & { preloaded?: PreloadMultiFileDiffResult<any> } +type ReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult<any> } +type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<any> } export interface SessionReviewProps { title?: JSX.Element @@ -155,8 +157,8 @@ export const SessionReview = (props: SessionReviewProps) => { const opened = () => store.opened const open = () => props.open ?? store.open - const files = createMemo(() => props.diffs.map((diff) => diff.file)) - const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const))) + const items = createMemo<Item[]>(() => props.diffs.map((diff) => ({ ...normalize(diff), preloaded: diff.preloaded }))) + const files = createMemo(() => items().map((diff) => diff.file)) const grouped = createMemo(() => { const next = new Map<string, SessionReviewComment[]>() for (const comment of props.comments ?? []) { @@ -246,10 +248,10 @@ export const SessionReview = (props: SessionReviewProps) => { const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions" - const selectionPreview = (diff: FileDiff, range: SelectedLineRange) => { + const selectionPreview = (diff: ViewDiff, range: SelectedLineRange) => { const side = selectionSide(range) - const contents = side === "deletions" ? diff.before : diff.after - if (typeof contents !== "string" || contents.length === 0) return undefined + const contents = text(diff, side) + if (contents.length === 0) return undefined return previewSelectedLines(contents, range) } @@ -359,7 +361,7 @@ export const SessionReview = (props: SessionReviewProps) => { <Show when={hasDiffs()} fallback={props.empty}> <div class="pb-6"> <Accordion multiple value={open()} onChange={handleChange}> - <For each={props.diffs}> + <For each={items()}> {(diff) => { let wrapper: HTMLDivElement | undefined const file = diff.file @@ -371,8 +373,8 @@ export const SessionReview = (props: SessionReviewProps) => { const comments = createMemo(() => grouped().get(file) ?? []) const commentedLines = createMemo(() => comments().map((c) => c.selection)) - const beforeText = () => (typeof diff.before === "string" ? diff.before : "") - const afterText = () => (typeof diff.after === "string" ? diff.after : "") + const beforeText = () => text(diff, "deletions") + const afterText = () => text(diff, "additions") const changedLines = () => diff.additions + diff.deletions const mediaKind = createMemo(() => mediaKindFromPath(file)) @@ -581,6 +583,7 @@ export const SessionReview = (props: SessionReviewProps) => { <Dynamic component={fileComponent} mode="diff" + fileDiff={diff.fileDiff} preloadedDiff={diff.preloaded} diffStyle={diffStyle()} onRendered={() => { @@ -596,20 +599,11 @@ export const SessionReview = (props: SessionReviewProps) => { renderHoverUtility={props.onLineComment ? commentsUi.renderHoverUtility : undefined} selectedLines={selectedLines()} commentedLines={commentedLines()} - before={{ - name: file, - contents: typeof diff.before === "string" ? diff.before : "", - }} - after={{ - name: file, - contents: typeof diff.after === "string" ? diff.after : "", - }} media={{ mode: "auto", path: file, - before: diff.before, - after: diff.after, - readFile: props.readFile, + deleted: diff.status === "deleted", + readFile: diff.status === "deleted" ? undefined : props.readFile, }} /> </Match> diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index c20e5fb1c..bb699a77e 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -1,4 +1,9 @@ -import { AssistantMessage, type FileDiff, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client" +import { + AssistantMessage, + type SnapshotFileDiff, + Message as MessageType, + Part as PartType, +} from "@opencode-ai/sdk/v2/client" import type { SessionStatus } from "@opencode-ai/sdk/v2" import { useData } from "../context" import { useFileComponent } from "../context/file" @@ -19,6 +24,7 @@ import { SessionRetry } from "./session-retry" import { TextReveal } from "./text-reveal" import { createAutoScroll } from "../hooks" import { useI18n } from "../context/i18n" +import { normalize } from "./session-diff" function record(value: unknown): value is Record<string, unknown> { return !!value && typeof value === "object" && !Array.isArray(value) @@ -163,7 +169,7 @@ export function SessionTurn( const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] const emptyAssistant: AssistantMessage[] = [] - const emptyDiffs: FileDiff[] = [] + const emptyDiffs: SnapshotFileDiff[] = [] const idle = { type: "idle" as const } const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages)) @@ -232,7 +238,7 @@ export function SessionTurn( const seen = new Set<string>() return files - .reduceRight<FileDiff[]>((result, diff) => { + .reduceRight<SnapshotFileDiff[]>((result, diff) => { if (seen.has(diff.file)) return result seen.add(diff.file) result.push(diff) @@ -447,6 +453,7 @@ export function SessionTurn( > <For each={visible()}> {(diff) => { + const view = normalize(diff) const active = createMemo(() => expanded().includes(diff.file)) const [shown, setShown] = createSignal(false) @@ -495,12 +502,7 @@ export function SessionTurn( <Accordion.Content> <Show when={shown()}> <div data-slot="session-turn-diff-view" data-scrollable> - <Dynamic - component={fileComponent} - mode="diff" - before={{ name: diff.file, contents: diff.before }} - after={{ name: diff.file, contents: diff.after }} - /> + <Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} /> </div> </Show> </Accordion.Content> diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index 93368c2a0..632bed0cf 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -1,4 +1,4 @@ -import type { Message, Session, Part, FileDiff, SessionStatus, ProviderListResponse } from "@opencode-ai/sdk/v2" +import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, ProviderListResponse } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -13,7 +13,7 @@ type Data = { [sessionID: string]: SessionStatus } session_diff: { - [sessionID: string]: FileDiff[] + [sessionID: string]: SnapshotFileDiff[] } session_diff_preload?: { [sessionID: string]: PreloadMultiFileDiffResult<any>[] |
