summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax <[email protected]>2026-04-07 19:48:23 -0400
committerGitHub <[email protected]>2026-04-07 19:48:23 -0400
commitb7fab49b64275b83bcec8200d7492fc5d15ffe06 (patch)
treed9dadf65ca69eb4b8fe75654eb15666ee2b23774
parent463318486f94fa20e8d864d77708a347fa8423e3 (diff)
downloadopencode-b7fab49b64275b83bcec8200d7492fc5d15ffe06.tar.gz
opencode-b7fab49b64275b83bcec8200d7492fc5d15ffe06.zip
refactor(snapshot): store unified patches in file diffs (#21244)
Co-authored-by: Adam <[email protected]>
-rw-r--r--bun.lock1
-rw-r--r--packages/app/src/context/global-sync/event-reducer.ts4
-rw-r--r--packages/app/src/context/global-sync/session-cache.test.ts6
-rw-r--r--packages/app/src/context/global-sync/session-cache.ts4
-rw-r--r--packages/app/src/context/global-sync/types.ts4
-rw-r--r--packages/app/src/pages/session.tsx82
-rw-r--r--packages/app/src/pages/session/review-tab.tsx6
-rw-r--r--packages/app/src/pages/session/session-side-panel.tsx4
-rw-r--r--packages/enterprise/src/core/share.ts4
-rw-r--r--packages/enterprise/src/routes/share/[shareID].tsx4
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/index.tsx2
-rw-r--r--packages/opencode/src/project/vcs.ts24
-rw-r--r--packages/opencode/src/server/instance.ts2
-rw-r--r--packages/opencode/src/share/share-next.ts2
-rw-r--r--packages/opencode/src/snapshot/index.ts15
-rw-r--r--packages/opencode/src/tool/apply_patch.ts4
-rw-r--r--packages/opencode/src/tool/edit.ts3
-rw-r--r--packages/opencode/test/share/share-next.test.ts15
-rw-r--r--packages/opencode/test/snapshot/snapshot.test.ts35
-rw-r--r--packages/opencode/test/tool/apply_patch.test.ts14
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts27
-rw-r--r--packages/ui/package.json1
-rw-r--r--packages/ui/src/components/file-media.tsx2
-rw-r--r--packages/ui/src/components/file-ssr.tsx37
-rw-r--r--packages/ui/src/components/file.tsx44
-rw-r--r--packages/ui/src/components/session-diff.test.ts37
-rw-r--r--packages/ui/src/components/session-diff.ts83
-rw-r--r--packages/ui/src/components/session-review.tsx36
-rw-r--r--packages/ui/src/components/session-turn.tsx20
-rw-r--r--packages/ui/src/context/data.tsx4
30 files changed, 343 insertions, 183 deletions
diff --git a/bun.lock b/bun.lock
index 897ca4fa9..df1485f40 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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>[]