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 | |
| 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]>
30 files changed, 343 insertions, 183 deletions
@@ -533,6 +533,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/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 4af636553..01248e20e 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -1,7 +1,6 @@ import { Binary } from "@opencode-ai/util/binary" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { - FileDiff, Message, Part, PermissionRequest, @@ -9,6 +8,7 @@ import type { QuestionRequest, Session, SessionStatus, + SnapshotFileDiff, Todo, } from "@opencode-ai/sdk/v2/client" import type { State, VcsCache } from "./types" @@ -161,7 +161,7 @@ export function applyDirectoryEvent(input: { break } case "session.diff": { - const props = event.properties as { sessionID: string; diff: FileDiff[] } + const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" })) break } diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 8e11110e3..472ac219e 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from "bun:test" import type { - FileDiff, Message, Part, PermissionRequest, QuestionRequest, SessionStatus, + SnapshotFileDiff, Todo, } from "@opencode-ai/sdk/v2/client" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" @@ -33,7 +33,7 @@ describe("app session cache", () => { test("dropSessionCaches clears orphaned parts without message rows", () => { const store: { session_status: Record<string, SessionStatus | undefined> - session_diff: Record<string, FileDiff[] | undefined> + session_diff: Record<string, SnapshotFileDiff[] | undefined> todo: Record<string, Todo[] | undefined> message: Record<string, Message[] | undefined> part: Record<string, Part[] | undefined> @@ -64,7 +64,7 @@ describe("app session cache", () => { const m = msg("msg_1", "ses_1") const store: { session_status: Record<string, SessionStatus | undefined> - session_diff: Record<string, FileDiff[] | undefined> + session_diff: Record<string, SnapshotFileDiff[] | undefined> todo: Record<string, Todo[] | undefined> message: Record<string, Message[] | undefined> part: Record<string, Part[] | undefined> diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 0177ebbe1..6f4d81062 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -1,10 +1,10 @@ import type { - FileDiff, Message, Part, PermissionRequest, QuestionRequest, SessionStatus, + SnapshotFileDiff, Todo, } from "@opencode-ai/sdk/v2/client" @@ -12,7 +12,7 @@ export const SESSION_CACHE_LIMIT = 40 type SessionCache = { session_status: Record<string, SessionStatus | undefined> - session_diff: Record<string, FileDiff[] | undefined> + session_diff: Record<string, SnapshotFileDiff[] | undefined> todo: Record<string, Todo[] | undefined> message: Record<string, Message[] | undefined> part: Record<string, Part[] | undefined> diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 1d6e550f8..b0f340a90 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -2,7 +2,6 @@ import type { Agent, Command, Config, - FileDiff, LspStatus, McpStatus, Message, @@ -14,6 +13,7 @@ import type { QuestionRequest, Session, SessionStatus, + SnapshotFileDiff, Todo, VcsInfo, } from "@opencode-ai/sdk/v2/client" @@ -48,7 +48,7 @@ export type State = { [sessionID: string]: SessionStatus } session_diff: { - [sessionID: string]: FileDiff[] + [sessionID: string]: SnapshotFileDiff[] } todo: { [sessionID: string]: Todo[] diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 0c6764726..cf50fbe90 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,4 @@ -import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2" +import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useMutation } from "@tanstack/solid-query" import { @@ -68,7 +68,7 @@ type FollowupItem = FollowupDraft & { id: string } type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context"> const emptyFollowups: FollowupItem[] = [] -type ChangeMode = "git" | "branch" | "session" | "turn" +type ChangeMode = "git" | "branch" | "turn" type VcsMode = "git" | "branch" type SessionHistoryWindowInput = { @@ -463,13 +463,6 @@ export default function Page() { if (!id) return false return sync.session.history.loading(id) }) - const diffsReady = createMemo(() => { - const id = params.id - if (!id) return true - if (!hasSessionReview()) return true - return sync.data.session_diff[id] !== undefined - }) - const userMessages = createMemo( () => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages, @@ -527,10 +520,19 @@ export default function Page() { deferRender: false, }) - const [vcs, setVcs] = createStore({ + const [vcs, setVcs] = createStore<{ + diff: { + git: VcsFileDiff[] + branch: VcsFileDiff[] + } + ready: { + git: boolean + branch: boolean + } + }>({ diff: { - git: [] as FileDiff[], - branch: [] as FileDiff[], + git: [] as VcsFileDiff[], + branch: [] as VcsFileDiff[], }, ready: { git: false, @@ -648,6 +650,7 @@ export default function Page() { }, desktopReviewOpen()) const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) + const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git") const changesOptions = createMemo<ChangeMode[]>(() => { const list: ChangeMode[] = [] if (sync.project?.vcs === "git") list.push("git") @@ -659,7 +662,7 @@ export default function Page() { ) { list.push("branch") } - list.push("session", "turn") + list.push("turn") return list }) const vcsMode = createMemo<VcsMode | undefined>(() => { @@ -668,20 +671,17 @@ export default function Page() { const reviewDiffs = createMemo(() => { if (store.changes === "git") return vcs.diff.git if (store.changes === "branch") return vcs.diff.branch - if (store.changes === "session") return diffs() return turnDiffs() }) const reviewCount = createMemo(() => { if (store.changes === "git") return vcs.diff.git.length if (store.changes === "branch") return vcs.diff.branch.length - if (store.changes === "session") return sessionCount() return turnDiffs().length }) const hasReview = createMemo(() => reviewCount() > 0) const reviewReady = createMemo(() => { if (store.changes === "git") return vcs.ready.git if (store.changes === "branch") return vcs.ready.branch - if (store.changes === "session") return !hasSessionReview() || diffsReady() return true }) @@ -749,13 +749,6 @@ export default function Page() { scrollToMessage(msgs[targetIndex], "auto") } - const sessionEmptyKey = createMemo(() => { - const project = sync.project - if (project && !project.vcs) return "session.review.noVcs" - if (sync.data.config.snapshot === false) return "session.review.noSnapshot" - return "session.review.empty" - }) - function upsert(next: Project) { const list = globalSync.data.project sync.set("project", next.id) @@ -1156,7 +1149,6 @@ export default function Page() { const label = (option: ChangeMode) => { if (option === "git") return language.t("ui.sessionReview.title.git") if (option === "branch") return language.t("ui.sessionReview.title.branch") - if (option === "session") return language.t("ui.sessionReview.title") return language.t("ui.sessionReview.title.lastTurn") } @@ -1179,11 +1171,26 @@ export default function Page() { </div> ) + const createGit = (input: { emptyClass: string }) => ( + <div class={input.emptyClass}> + <div class="flex flex-col gap-3"> + <div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div> + <div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}> + {language.t("session.review.noVcs.createGit.description")} + </div> + </div> + <Button size="large" disabled={gitMutation.isPending} onClick={initGit}> + {gitMutation.isPending + ? language.t("session.review.noVcs.createGit.actionLoading") + : language.t("session.review.noVcs.createGit.action")} + </Button> + </div> + ) + const reviewEmptyText = createMemo(() => { if (store.changes === "git") return language.t("session.review.noUncommittedChanges") if (store.changes === "branch") return language.t("session.review.noBranchChanges") - if (store.changes === "turn") return language.t("session.review.noChanges") - return language.t(sessionEmptyKey()) + return language.t("session.review.noChanges") }) const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => { @@ -1193,31 +1200,10 @@ export default function Page() { } if (store.changes === "turn") { + if (nogit()) return createGit(input) return empty(reviewEmptyText()) } - if (hasSessionReview() && !diffsReady()) { - return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div> - } - - if (sessionEmptyKey() === "session.review.noVcs") { - return ( - <div class={input.emptyClass}> - <div class="flex flex-col gap-3"> - <div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div> - <div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}> - {language.t("session.review.noVcs.createGit.description")} - </div> - </div> - <Button size="large" disabled={gitMutation.isPending} onClick={initGit}> - {gitMutation.isPending - ? language.t("session.review.noVcs.createGit.actionLoading") - : language.t("session.review.noVcs.createGit.action")} - </Button> - </div> - ) - } - return ( <div class={input.emptyClass}> <div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div> diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index b68128645..71dfe375e 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -1,6 +1,6 @@ import { createEffect, createSignal, onCleanup, type JSX } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" -import type { FileDiff } from "@opencode-ai/sdk/v2" +import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import { SessionReview } from "@opencode-ai/ui/session-review" import type { SessionReviewCommentActions, @@ -14,10 +14,12 @@ import type { LineComment } from "@/context/comments" export type DiffStyle = "unified" | "split" +type ReviewDiff = SnapshotFileDiff | VcsFileDiff + export interface SessionReviewTabProps { title?: JSX.Element empty?: JSX.Element - diffs: () => FileDiff[] + diffs: () => ReviewDiff[] view: () => ReturnType<ReturnType<typeof useLayout>["view"]> diffStyle: DiffStyle onDiffStyleChange?: (style: DiffStyle) => void diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 86f932ea2..cddbea84d 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -8,7 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Mark } from "@opencode-ai/ui/logo" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" -import type { FileDiff } from "@opencode-ai/sdk/v2" +import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -27,7 +27,7 @@ import { useSessionLayout } from "@/pages/session/session-layout" export function SessionSidePanel(props: { canReview: () => boolean - diffs: () => FileDiff[] + diffs: () => (SnapshotFileDiff | VcsFileDiff)[] diffsReady: () => boolean empty: () => string hasReview: () => boolean diff --git a/packages/enterprise/src/core/share.ts b/packages/enterprise/src/core/share.ts index c6291b75d..18fcd7a07 100644 --- a/packages/enterprise/src/core/share.ts +++ b/packages/enterprise/src/core/share.ts @@ -1,4 +1,4 @@ -import { FileDiff, Message, Model, Part, Session } from "@opencode-ai/sdk/v2" +import { Message, Model, Part, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2" import { fn } from "@opencode-ai/util/fn" import { iife } from "@opencode-ai/util/iife" import z from "zod" @@ -27,7 +27,7 @@ export namespace Share { }), z.object({ type: z.literal("session_diff"), - data: z.custom<FileDiff[]>(), + data: z.custom<SnapshotFileDiff[]>(), }), z.object({ type: z.literal("model"), diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index e755ea75a..edeeaf1ad 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -1,4 +1,4 @@ -import { FileDiff, Message, Model, Part, Session, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" +import { Message, Model, Part, Session, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { SessionReview } from "@opencode-ai/ui/session-review" import { DataProvider } from "@opencode-ai/ui/context" @@ -51,7 +51,7 @@ const getData = query(async (shareID) => { shareID: string session: Session[] session_diff: { - [sessionID: string]: FileDiff[] + [sessionID: string]: SnapshotFileDiff[] } session_status: { [sessionID: string]: SessionStatus diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 91baca52a..396d75630 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -2124,7 +2124,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) { </text> } > - <Diff diff={file.diff} filePath={file.filePath} /> + <Diff diff={file.patch} filePath={file.filePath} /> <Diagnostics diagnostics={props.metadata.diagnostics} filePath={file.movePath ?? file.filePath} /> </Show> </BlockTool> diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 5142079b1..ec6e415c8 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,4 +1,5 @@ import { Effect, Layer, ServiceMap, Stream } from "effect" +import { formatPatch, structuredPatch } from "diff" import path from "path" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" @@ -7,7 +8,6 @@ import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@/filesystem" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" -import { Snapshot } from "@/snapshot" import { Log } from "@/util/log" import { Instance } from "./instance" import z from "zod" @@ -49,6 +49,8 @@ export namespace Vcs { map: Map<string, { additions: number; deletions: number }>, ) { const base = ref ? yield* git.prefix(cwd) : "" + const patch = (file: string, before: string, after: string) => + formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) const next = yield* Effect.forEach( list, (item) => @@ -58,12 +60,11 @@ export namespace Vcs { const stat = map.get(item.file) return { file: item.file, - before, - after, + patch: patch(item.file, before, after), additions: stat?.additions ?? (item.status === "added" ? count(after) : 0), deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0), status: item.status, - } satisfies Snapshot.FileDiff + } satisfies FileDiff }), { concurrency: 8 }, ) @@ -125,11 +126,24 @@ export namespace Vcs { }) export type Info = z.infer<typeof Info> + export const FileDiff = z + .object({ + file: z.string(), + patch: z.string(), + additions: z.number(), + deletions: z.number(), + status: z.enum(["added", "deleted", "modified"]).optional(), + }) + .meta({ + ref: "VcsFileDiff", + }) + export type FileDiff = z.infer<typeof FileDiff> + export interface Interface { readonly init: () => Effect.Effect<void> readonly branch: () => Effect.Effect<string | undefined> readonly defaultBranch: () => Effect.Effect<string | undefined> - readonly diff: (mode: Mode) => Effect.Effect<Snapshot.FileDiff[]> + readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]> } interface State { diff --git a/packages/opencode/src/server/instance.ts b/packages/opencode/src/server/instance.ts index 7cc7886b0..65ea2fac2 100644 --- a/packages/opencode/src/server/instance.ts +++ b/packages/opencode/src/server/instance.ts @@ -154,7 +154,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono() description: "VCS diff", content: { "application/json": { - schema: resolver(Snapshot.FileDiff.array()), + schema: resolver(Vcs.FileDiff.array()), }, }, }, diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 2eb9887ea..0cd0055c8 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -59,7 +59,7 @@ export namespace ShareNext { } | { type: "session_diff" - data: SDK.FileDiff[] + data: SDK.SnapshotFileDiff[] } | { type: "model" diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 2db67695f..569c834bf 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,6 +1,6 @@ -import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Cause, Duration, Effect, Layer, Schedule, Semaphore, ServiceMap, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { formatPatch, structuredPatch } from "diff" import path from "path" import z from "zod" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" @@ -22,14 +22,13 @@ export namespace Snapshot { export const FileDiff = z .object({ file: z.string(), - before: z.string(), - after: z.string(), + patch: z.string(), additions: z.number(), deletions: z.number(), status: z.enum(["added", "deleted", "modified"]).optional(), }) .meta({ - ref: "FileDiff", + ref: "SnapshotFileDiff", }) export type FileDiff = z.infer<typeof FileDiff> @@ -521,8 +520,6 @@ export namespace Snapshot { const map = new Map<string, { before: string; after: string }>() const dec = new TextDecoder() let i = 0 - // Parse the default `git cat-file --batch` stream: one header line, - // then exactly `size` bytes of blob content, then a trailing newline. for (const ref of refs) { let end = i while (end < out.length && out[end] !== 10) end += 1 @@ -620,8 +617,9 @@ export namespace Snapshot { ] }) const step = 100 + const patch = (file: string, before: string, after: string) => + formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) - // Keep batches bounded so a large diff does not buffer every blob at once. for (let i = 0; i < rows.length; i += step) { const run = rows.slice(i, i + step) const text = yield* load(run) @@ -631,8 +629,7 @@ export namespace Snapshot { const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row) result.push({ file: row.file, - before, - after, + patch: row.binary ? "" : patch(row.file, before, after), additions: row.additions, deletions: row.deletions, status: row.status, diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index c23c0dd3d..30b2e91ac 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -164,9 +164,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { filePath: change.filePath, relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"), type: change.type, - diff: change.diff, - before: change.oldContent, - after: change.newContent, + patch: change.diff, additions: change.additions, deletions: change.deletions, movePath: change.movePath, diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 554d547d0..9505dd9ea 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -123,8 +123,7 @@ export const EditTool = Tool.define("edit", { const filediff: Snapshot.FileDiff = { file: filePath, - before: contentOld, - after: contentNew, + patch: diff, additions: 0, deletions: 0, } diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 12d71f19a..6619b3c60 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -272,8 +272,8 @@ describe("ShareNext", () => { diff: [ { file: "a.ts", - before: "one", - after: "two", + patch: + "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,1 +1,1 @@\n-one\n\\ No newline at end of file\n+two\n\\ No newline at end of file\n", additions: 1, deletions: 1, status: "modified", @@ -285,8 +285,8 @@ describe("ShareNext", () => { diff: [ { file: "b.ts", - before: "old", - after: "new", + patch: + "Index: b.ts\n===================================================================\n--- b.ts\t\n+++ b.ts\t\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n", additions: 2, deletions: 0, status: "modified", @@ -304,8 +304,7 @@ describe("ShareNext", () => { type: string data: Array<{ file: string - before: string - after: string + patch: string additions: number deletions: number status?: string @@ -318,8 +317,8 @@ describe("ShareNext", () => { expect(body.data[0].data).toEqual([ { file: "b.ts", - before: "old", - after: "new", + patch: + "Index: b.ts\n===================================================================\n--- b.ts\t\n+++ b.ts\t\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n", additions: 2, deletions: 0, status: "modified", diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index f53f1e811..3cedfb941 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -974,8 +974,7 @@ test("diffFull with new file additions", async () => { const newFileDiff = diffs[0] expect(newFileDiff.file).toBe("new.txt") - expect(newFileDiff.before).toBe("") - expect(newFileDiff.after).toBe("new content") + expect(newFileDiff.patch).toContain("+new content") expect(newFileDiff.additions).toBe(1) expect(newFileDiff.deletions).toBe(0) }, @@ -1020,26 +1019,23 @@ test("diffFull with a large interleaved mixed diff", async () => { for (let i = 0; i < ids.length; i++) { const m = map.get(fwd("mix", `${ids[i]}-mod.txt`)) expect(m).toBeDefined() - expect(m!.before).toBe(`before-${ids[i]}-é\n🙂\nline`) - expect(m!.after).toBe(`after-${ids[i]}-é\n🚀\nline`) + expect(m!.patch).toContain(`-before-${ids[i]}-é`) + expect(m!.patch).toContain(`+after-${ids[i]}-é`) expect(m!.status).toBe("modified") const d = map.get(fwd("mix", `${ids[i]}-del.txt`)) expect(d).toBeDefined() - expect(d!.before).toBe(`gone-${ids[i]}\n你好`) - expect(d!.after).toBe("") + expect(d!.patch).toContain(`-gone-${ids[i]}`) expect(d!.status).toBe("deleted") const a = map.get(fwd("mix", `${ids[i]}-add.txt`)) expect(a).toBeDefined() - expect(a!.before).toBe("") - expect(a!.after).toBe(`new-${ids[i]}\nこんにちは`) + expect(a!.patch).toContain(`+new-${ids[i]}`) expect(a!.status).toBe("added") const b = map.get(fwd("mix", `${ids[i]}-bin.bin`)) expect(b).toBeDefined() - expect(b!.before).toBe("") - expect(b!.after).toBe("") + expect(b!.patch).toBe("") expect(b!.additions).toBe(0) expect(b!.deletions).toBe(0) expect(b!.status).toBe("modified") @@ -1092,8 +1088,8 @@ test("diffFull with file modifications", async () => { const modifiedFileDiff = diffs[0] expect(modifiedFileDiff.file).toBe("b.txt") - expect(modifiedFileDiff.before).toBe(tmp.extra.bContent) - expect(modifiedFileDiff.after).toBe("modified content") + expect(modifiedFileDiff.patch).toContain(`-${tmp.extra.bContent}`) + expect(modifiedFileDiff.patch).toContain("+modified content") expect(modifiedFileDiff.additions).toBeGreaterThan(0) expect(modifiedFileDiff.deletions).toBeGreaterThan(0) }, @@ -1118,8 +1114,7 @@ test("diffFull with file deletions", async () => { const removedFileDiff = diffs[0] expect(removedFileDiff.file).toBe("a.txt") - expect(removedFileDiff.before).toBe(tmp.extra.aContent) - expect(removedFileDiff.after).toBe("") + expect(removedFileDiff.patch).toContain(`-${tmp.extra.aContent}`) expect(removedFileDiff.additions).toBe(0) expect(removedFileDiff.deletions).toBe(1) }, @@ -1144,8 +1139,8 @@ test("diffFull with multiple line additions", async () => { const multiDiff = diffs[0] expect(multiDiff.file).toBe("multi.txt") - expect(multiDiff.before).toBe("") - expect(multiDiff.after).toBe("line1\nline2\nline3") + expect(multiDiff.patch).toContain("+line1") + expect(multiDiff.patch).toContain("+line3") expect(multiDiff.additions).toBe(3) expect(multiDiff.deletions).toBe(0) }, @@ -1171,15 +1166,13 @@ test("diffFull with addition and deletion", async () => { const addedFileDiff = diffs.find((d) => d.file === "added.txt") expect(addedFileDiff).toBeDefined() - expect(addedFileDiff!.before).toBe("") - expect(addedFileDiff!.after).toBe("added content") + expect(addedFileDiff!.patch).toContain("+added content") expect(addedFileDiff!.additions).toBe(1) expect(addedFileDiff!.deletions).toBe(0) const removedFileDiff = diffs.find((d) => d.file === "a.txt") expect(removedFileDiff).toBeDefined() - expect(removedFileDiff!.before).toBe(tmp.extra.aContent) - expect(removedFileDiff!.after).toBe("") + expect(removedFileDiff!.patch).toContain(`-${tmp.extra.aContent}`) expect(removedFileDiff!.additions).toBe(0) expect(removedFileDiff!.deletions).toBe(1) }, @@ -1263,7 +1256,7 @@ test("diffFull with binary file changes", async () => { const binaryDiff = diffs[0] expect(binaryDiff.file).toBe("binary.bin") - expect(binaryDiff.before).toBe("") + expect(binaryDiff.patch).toBe("") }, }) }) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 4e276517f..19c8cfefd 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -27,9 +27,7 @@ type AskInput = { filePath: string relativePath: string type: "add" | "update" | "delete" | "move" - diff: string - before: string - after: string + patch: string additions: number deletions: number movePath?: string @@ -112,12 +110,12 @@ describe("tool.apply_patch freeform", () => { const addFile = permissionCall.metadata.files.find((f) => f.type === "add") expect(addFile).toBeDefined() expect(addFile!.relativePath).toBe("nested/new.txt") - expect(addFile!.after).toBe("created\n") + expect(addFile!.patch).toContain("+created") const updateFile = permissionCall.metadata.files.find((f) => f.type === "update") expect(updateFile).toBeDefined() - expect(updateFile!.before).toContain("line2") - expect(updateFile!.after).toContain("changed") + expect(updateFile!.patch).toContain("-line2") + expect(updateFile!.patch).toContain("+changed") const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8") expect(added).toBe("created\n") @@ -151,8 +149,8 @@ describe("tool.apply_patch freeform", () => { expect(moveFile.type).toBe("move") expect(moveFile.relativePath).toBe("renamed/dir/name.txt") expect(moveFile.movePath).toBe(path.join(fixture.path, "renamed/dir/name.txt")) - expect(moveFile.before).toBe("old content\n") - expect(moveFile.after).toBe("new content\n") + expect(moveFile.patch).toContain("-old content") + expect(moveFile.patch).toContain("+new content") }, }) }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fc1616c4f..0a9aa4358 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -347,10 +347,9 @@ export type EventCommandExecuted = { } } -export type FileDiff = { +export type SnapshotFileDiff = { file: string - before: string - after: string + patch: string additions: number deletions: number status?: "added" | "deleted" | "modified" @@ -360,7 +359,7 @@ export type EventSessionDiff = { type: "session.diff" properties: { sessionID: string - diff: Array<FileDiff> + diff: Array<SnapshotFileDiff> } } @@ -542,7 +541,7 @@ export type UserMessage = { summary?: { title?: string body?: string - diffs: Array<FileDiff> + diffs: Array<SnapshotFileDiff> } agent: string model: { @@ -917,7 +916,7 @@ export type Session = { additions: number deletions: number files: number - diffs?: Array<FileDiff> + diffs?: Array<SnapshotFileDiff> } share?: { url: string @@ -1078,7 +1077,7 @@ export type SyncEventSessionUpdated = { additions: number deletions: number files: number - diffs?: Array<FileDiff> + diffs?: Array<SnapshotFileDiff> } | null share?: { url: string | null @@ -1803,7 +1802,7 @@ export type GlobalSession = { additions: number deletions: number files: number - diffs?: Array<FileDiff> + diffs?: Array<SnapshotFileDiff> } share?: { url: string @@ -2009,6 +2008,14 @@ export type VcsInfo = { default_branch?: string } +export type VcsFileDiff = { + file: string + patch: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" +} + export type Command = { name: string description?: string @@ -3503,7 +3510,7 @@ export type SessionDiffResponses = { /** * Successfully retrieved diff */ - 200: Array<FileDiff> + 200: Array<SnapshotFileDiff> } export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses] @@ -5159,7 +5166,7 @@ export type VcsDiffResponses = { /** * VCS diff */ - 200: Array<FileDiff> + 200: Array<VcsFileDiff> } export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] 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>[] |
