refactor(snapshot): store unified patches in file diffs (#21244)

Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
message-v3
Dax 2026-04-07 19:48:23 -04:00 committed by GitHub
parent 463318486f
commit b7fab49b64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 343 additions and 183 deletions

View File

@ -533,6 +533,7 @@
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"diff": "catalog:",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"katex": "0.16.27", "katex": "0.16.27",

View File

@ -1,7 +1,6 @@
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { import type {
FileDiff,
Message, Message,
Part, Part,
PermissionRequest, PermissionRequest,
@ -9,6 +8,7 @@ import type {
QuestionRequest, QuestionRequest,
Session, Session,
SessionStatus, SessionStatus,
SnapshotFileDiff,
Todo, Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
@ -161,7 +161,7 @@ export function applyDirectoryEvent(input: {
break break
} }
case "session.diff": { 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" })) input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
break break
} }

View File

@ -1,11 +1,11 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { import type {
FileDiff,
Message, Message,
Part, Part,
PermissionRequest, PermissionRequest,
QuestionRequest, QuestionRequest,
SessionStatus, SessionStatus,
SnapshotFileDiff,
Todo, Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@ -33,7 +33,7 @@ describe("app session cache", () => {
test("dropSessionCaches clears orphaned parts without message rows", () => { test("dropSessionCaches clears orphaned parts without message rows", () => {
const store: { const store: {
session_status: Record<string, SessionStatus | undefined> session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined> session_diff: Record<string, SnapshotFileDiff[] | undefined>
todo: Record<string, Todo[] | undefined> todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined> message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>
@ -64,7 +64,7 @@ describe("app session cache", () => {
const m = msg("msg_1", "ses_1") const m = msg("msg_1", "ses_1")
const store: { const store: {
session_status: Record<string, SessionStatus | undefined> session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined> session_diff: Record<string, SnapshotFileDiff[] | undefined>
todo: Record<string, Todo[] | undefined> todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined> message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>

View File

@ -1,10 +1,10 @@
import type { import type {
FileDiff,
Message, Message,
Part, Part,
PermissionRequest, PermissionRequest,
QuestionRequest, QuestionRequest,
SessionStatus, SessionStatus,
SnapshotFileDiff,
Todo, Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
@ -12,7 +12,7 @@ export const SESSION_CACHE_LIMIT = 40
type SessionCache = { type SessionCache = {
session_status: Record<string, SessionStatus | undefined> session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined> session_diff: Record<string, SnapshotFileDiff[] | undefined>
todo: Record<string, Todo[] | undefined> todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined> message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined> part: Record<string, Part[] | undefined>

View File

@ -2,7 +2,6 @@ import type {
Agent, Agent,
Command, Command,
Config, Config,
FileDiff,
LspStatus, LspStatus,
McpStatus, McpStatus,
Message, Message,
@ -14,6 +13,7 @@ import type {
QuestionRequest, QuestionRequest,
Session, Session,
SessionStatus, SessionStatus,
SnapshotFileDiff,
Todo, Todo,
VcsInfo, VcsInfo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
@ -48,7 +48,7 @@ export type State = {
[sessionID: string]: SessionStatus [sessionID: string]: SessionStatus
} }
session_diff: { session_diff: {
[sessionID: string]: FileDiff[] [sessionID: string]: SnapshotFileDiff[]
} }
todo: { todo: {
[sessionID: string]: Todo[] [sessionID: string]: Todo[]

View File

@ -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 { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
import { import {
@ -68,7 +68,7 @@ type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context"> type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
const emptyFollowups: FollowupItem[] = [] const emptyFollowups: FollowupItem[] = []
type ChangeMode = "git" | "branch" | "session" | "turn" type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch" type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = { type SessionHistoryWindowInput = {
@ -463,13 +463,6 @@ export default function Page() {
if (!id) return false if (!id) return false
return sync.session.history.loading(id) 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( const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[], () => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages, emptyUserMessages,
@ -527,10 +520,19 @@ export default function Page() {
deferRender: false, deferRender: false,
}) })
const [vcs, setVcs] = createStore({ const [vcs, setVcs] = createStore<{
diff: { diff: {
git: [] as FileDiff[], git: VcsFileDiff[]
branch: [] as FileDiff[], branch: VcsFileDiff[]
}
ready: {
git: boolean
branch: boolean
}
}>({
diff: {
git: [] as VcsFileDiff[],
branch: [] as VcsFileDiff[],
}, },
ready: { ready: {
git: false, git: false,
@ -648,6 +650,7 @@ export default function Page() {
}, desktopReviewOpen()) }, desktopReviewOpen())
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git")
const changesOptions = createMemo<ChangeMode[]>(() => { const changesOptions = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = [] const list: ChangeMode[] = []
if (sync.project?.vcs === "git") list.push("git") if (sync.project?.vcs === "git") list.push("git")
@ -659,7 +662,7 @@ export default function Page() {
) { ) {
list.push("branch") list.push("branch")
} }
list.push("session", "turn") list.push("turn")
return list return list
}) })
const vcsMode = createMemo<VcsMode | undefined>(() => { const vcsMode = createMemo<VcsMode | undefined>(() => {
@ -668,20 +671,17 @@ export default function Page() {
const reviewDiffs = createMemo(() => { const reviewDiffs = createMemo(() => {
if (store.changes === "git") return vcs.diff.git if (store.changes === "git") return vcs.diff.git
if (store.changes === "branch") return vcs.diff.branch if (store.changes === "branch") return vcs.diff.branch
if (store.changes === "session") return diffs()
return turnDiffs() return turnDiffs()
}) })
const reviewCount = createMemo(() => { const reviewCount = createMemo(() => {
if (store.changes === "git") return vcs.diff.git.length if (store.changes === "git") return vcs.diff.git.length
if (store.changes === "branch") return vcs.diff.branch.length if (store.changes === "branch") return vcs.diff.branch.length
if (store.changes === "session") return sessionCount()
return turnDiffs().length return turnDiffs().length
}) })
const hasReview = createMemo(() => reviewCount() > 0) const hasReview = createMemo(() => reviewCount() > 0)
const reviewReady = createMemo(() => { const reviewReady = createMemo(() => {
if (store.changes === "git") return vcs.ready.git if (store.changes === "git") return vcs.ready.git
if (store.changes === "branch") return vcs.ready.branch if (store.changes === "branch") return vcs.ready.branch
if (store.changes === "session") return !hasSessionReview() || diffsReady()
return true return true
}) })
@ -749,13 +749,6 @@ export default function Page() {
scrollToMessage(msgs[targetIndex], "auto") 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) { function upsert(next: Project) {
const list = globalSync.data.project const list = globalSync.data.project
sync.set("project", next.id) sync.set("project", next.id)
@ -1156,7 +1149,6 @@ export default function Page() {
const label = (option: ChangeMode) => { const label = (option: ChangeMode) => {
if (option === "git") return language.t("ui.sessionReview.title.git") if (option === "git") return language.t("ui.sessionReview.title.git")
if (option === "branch") return language.t("ui.sessionReview.title.branch") 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") return language.t("ui.sessionReview.title.lastTurn")
} }
@ -1179,11 +1171,26 @@ export default function Page() {
</div> </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(() => { const reviewEmptyText = createMemo(() => {
if (store.changes === "git") return language.t("session.review.noUncommittedChanges") if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
if (store.changes === "branch") return language.t("session.review.noBranchChanges") if (store.changes === "branch") return language.t("session.review.noBranchChanges")
if (store.changes === "turn") return language.t("session.review.noChanges") return language.t("session.review.noChanges")
return language.t(sessionEmptyKey())
}) })
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => { const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
@ -1193,31 +1200,10 @@ export default function Page() {
} }
if (store.changes === "turn") { if (store.changes === "turn") {
if (nogit()) return createGit(input)
return empty(reviewEmptyText()) 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 ( return (
<div class={input.emptyClass}> <div class={input.emptyClass}>
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div> <div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>

View File

@ -1,6 +1,6 @@
import { createEffect, createSignal, onCleanup, type JSX } from "solid-js" import { createEffect, createSignal, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener" 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 { SessionReview } from "@opencode-ai/ui/session-review"
import type { import type {
SessionReviewCommentActions, SessionReviewCommentActions,
@ -14,10 +14,12 @@ import type { LineComment } from "@/context/comments"
export type DiffStyle = "unified" | "split" export type DiffStyle = "unified" | "split"
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
export interface SessionReviewTabProps { export interface SessionReviewTabProps {
title?: JSX.Element title?: JSX.Element
empty?: JSX.Element empty?: JSX.Element
diffs: () => FileDiff[] diffs: () => ReviewDiff[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]> view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
diffStyle: DiffStyle diffStyle: DiffStyle
onDiffStyleChange?: (style: DiffStyle) => void onDiffStyleChange?: (style: DiffStyle) => void

View File

@ -8,7 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo" import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } 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 { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@ -27,7 +27,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
export function SessionSidePanel(props: { export function SessionSidePanel(props: {
canReview: () => boolean canReview: () => boolean
diffs: () => FileDiff[] diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
diffsReady: () => boolean diffsReady: () => boolean
empty: () => string empty: () => string
hasReview: () => boolean hasReview: () => boolean

View File

@ -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 { fn } from "@opencode-ai/util/fn"
import { iife } from "@opencode-ai/util/iife" import { iife } from "@opencode-ai/util/iife"
import z from "zod" import z from "zod"
@ -27,7 +27,7 @@ export namespace Share {
}), }),
z.object({ z.object({
type: z.literal("session_diff"), type: z.literal("session_diff"),
data: z.custom<FileDiff[]>(), data: z.custom<SnapshotFileDiff[]>(),
}), }),
z.object({ z.object({
type: z.literal("model"), type: z.literal("model"),

View File

@ -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 { SessionTurn } from "@opencode-ai/ui/session-turn"
import { SessionReview } from "@opencode-ai/ui/session-review" import { SessionReview } from "@opencode-ai/ui/session-review"
import { DataProvider } from "@opencode-ai/ui/context" import { DataProvider } from "@opencode-ai/ui/context"
@ -51,7 +51,7 @@ const getData = query(async (shareID) => {
shareID: string shareID: string
session: Session[] session: Session[]
session_diff: { session_diff: {
[sessionID: string]: FileDiff[] [sessionID: string]: SnapshotFileDiff[]
} }
session_status: { session_status: {
[sessionID: string]: SessionStatus [sessionID: string]: SessionStatus

View File

@ -2124,7 +2124,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
</text> </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} /> <Diagnostics diagnostics={props.metadata.diagnostics} filePath={file.movePath ?? file.filePath} />
</Show> </Show>
</BlockTool> </BlockTool>

View File

@ -1,4 +1,5 @@
import { Effect, Layer, ServiceMap, Stream } from "effect" import { Effect, Layer, ServiceMap, Stream } from "effect"
import { formatPatch, structuredPatch } from "diff"
import path from "path" import path from "path"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
@ -7,7 +8,6 @@ import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git" import { Git } from "@/git"
import { Snapshot } from "@/snapshot"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Instance } from "./instance" import { Instance } from "./instance"
import z from "zod" import z from "zod"
@ -49,6 +49,8 @@ export namespace Vcs {
map: Map<string, { additions: number; deletions: number }>, map: Map<string, { additions: number; deletions: number }>,
) { ) {
const base = ref ? yield* git.prefix(cwd) : "" 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( const next = yield* Effect.forEach(
list, list,
(item) => (item) =>
@ -58,12 +60,11 @@ export namespace Vcs {
const stat = map.get(item.file) const stat = map.get(item.file)
return { return {
file: item.file, file: item.file,
before, patch: patch(item.file, before, after),
after,
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0), additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0), deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
status: item.status, status: item.status,
} satisfies Snapshot.FileDiff } satisfies FileDiff
}), }),
{ concurrency: 8 }, { concurrency: 8 },
) )
@ -125,11 +126,24 @@ export namespace Vcs {
}) })
export type Info = z.infer<typeof Info> 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 { export interface Interface {
readonly init: () => Effect.Effect<void> readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined> readonly branch: () => Effect.Effect<string | undefined>
readonly defaultBranch: () => 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 { interface State {

View File

@ -154,7 +154,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
description: "VCS diff", description: "VCS diff",
content: { content: {
"application/json": { "application/json": {
schema: resolver(Snapshot.FileDiff.array()), schema: resolver(Vcs.FileDiff.array()),
}, },
}, },
}, },

View File

@ -59,7 +59,7 @@ export namespace ShareNext {
} }
| { | {
type: "session_diff" type: "session_diff"
data: SDK.FileDiff[] data: SDK.SnapshotFileDiff[]
} }
| { | {
type: "model" type: "model"

View File

@ -1,6 +1,6 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, Semaphore, ServiceMap, Stream } from "effect" import { Cause, Duration, Effect, Layer, Schedule, Semaphore, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { formatPatch, structuredPatch } from "diff"
import path from "path" import path from "path"
import z from "zod" import z from "zod"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
@ -22,14 +22,13 @@ export namespace Snapshot {
export const FileDiff = z export const FileDiff = z
.object({ .object({
file: z.string(), file: z.string(),
before: z.string(), patch: z.string(),
after: z.string(),
additions: z.number(), additions: z.number(),
deletions: z.number(), deletions: z.number(),
status: z.enum(["added", "deleted", "modified"]).optional(), status: z.enum(["added", "deleted", "modified"]).optional(),
}) })
.meta({ .meta({
ref: "FileDiff", ref: "SnapshotFileDiff",
}) })
export type FileDiff = z.infer<typeof FileDiff> export type FileDiff = z.infer<typeof FileDiff>
@ -521,8 +520,6 @@ export namespace Snapshot {
const map = new Map<string, { before: string; after: string }>() const map = new Map<string, { before: string; after: string }>()
const dec = new TextDecoder() const dec = new TextDecoder()
let i = 0 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) { for (const ref of refs) {
let end = i let end = i
while (end < out.length && out[end] !== 10) end += 1 while (end < out.length && out[end] !== 10) end += 1
@ -620,8 +617,9 @@ export namespace Snapshot {
] ]
}) })
const step = 100 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) { for (let i = 0; i < rows.length; i += step) {
const run = rows.slice(i, i + step) const run = rows.slice(i, i + step)
const text = yield* load(run) 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) const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row)
result.push({ result.push({
file: row.file, file: row.file,
before, patch: row.binary ? "" : patch(row.file, before, after),
after,
additions: row.additions, additions: row.additions,
deletions: row.deletions, deletions: row.deletions,
status: row.status, status: row.status,

View File

@ -164,9 +164,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
filePath: change.filePath, filePath: change.filePath,
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"), relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"),
type: change.type, type: change.type,
diff: change.diff, patch: change.diff,
before: change.oldContent,
after: change.newContent,
additions: change.additions, additions: change.additions,
deletions: change.deletions, deletions: change.deletions,
movePath: change.movePath, movePath: change.movePath,

View File

@ -123,8 +123,7 @@ export const EditTool = Tool.define("edit", {
const filediff: Snapshot.FileDiff = { const filediff: Snapshot.FileDiff = {
file: filePath, file: filePath,
before: contentOld, patch: diff,
after: contentNew,
additions: 0, additions: 0,
deletions: 0, deletions: 0,
} }

View File

@ -272,8 +272,8 @@ describe("ShareNext", () => {
diff: [ diff: [
{ {
file: "a.ts", file: "a.ts",
before: "one", patch:
after: "two", "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, additions: 1,
deletions: 1, deletions: 1,
status: "modified", status: "modified",
@ -285,8 +285,8 @@ describe("ShareNext", () => {
diff: [ diff: [
{ {
file: "b.ts", file: "b.ts",
before: "old", patch:
after: "new", "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, additions: 2,
deletions: 0, deletions: 0,
status: "modified", status: "modified",
@ -304,8 +304,7 @@ describe("ShareNext", () => {
type: string type: string
data: Array<{ data: Array<{
file: string file: string
before: string patch: string
after: string
additions: number additions: number
deletions: number deletions: number
status?: string status?: string
@ -318,8 +317,8 @@ describe("ShareNext", () => {
expect(body.data[0].data).toEqual([ expect(body.data[0].data).toEqual([
{ {
file: "b.ts", file: "b.ts",
before: "old", patch:
after: "new", "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, additions: 2,
deletions: 0, deletions: 0,
status: "modified", status: "modified",

View File

@ -974,8 +974,7 @@ test("diffFull with new file additions", async () => {
const newFileDiff = diffs[0] const newFileDiff = diffs[0]
expect(newFileDiff.file).toBe("new.txt") expect(newFileDiff.file).toBe("new.txt")
expect(newFileDiff.before).toBe("") expect(newFileDiff.patch).toContain("+new content")
expect(newFileDiff.after).toBe("new content")
expect(newFileDiff.additions).toBe(1) expect(newFileDiff.additions).toBe(1)
expect(newFileDiff.deletions).toBe(0) 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++) { for (let i = 0; i < ids.length; i++) {
const m = map.get(fwd("mix", `${ids[i]}-mod.txt`)) const m = map.get(fwd("mix", `${ids[i]}-mod.txt`))
expect(m).toBeDefined() expect(m).toBeDefined()
expect(m!.before).toBe(`before-${ids[i]}\n🙂\nline`) expect(m!.patch).toContain(`-before-${ids[i]}`)
expect(m!.after).toBe(`after-${ids[i]}\n🚀\nline`) expect(m!.patch).toContain(`+after-${ids[i]}`)
expect(m!.status).toBe("modified") expect(m!.status).toBe("modified")
const d = map.get(fwd("mix", `${ids[i]}-del.txt`)) const d = map.get(fwd("mix", `${ids[i]}-del.txt`))
expect(d).toBeDefined() expect(d).toBeDefined()
expect(d!.before).toBe(`gone-${ids[i]}\n你好`) expect(d!.patch).toContain(`-gone-${ids[i]}`)
expect(d!.after).toBe("")
expect(d!.status).toBe("deleted") expect(d!.status).toBe("deleted")
const a = map.get(fwd("mix", `${ids[i]}-add.txt`)) const a = map.get(fwd("mix", `${ids[i]}-add.txt`))
expect(a).toBeDefined() expect(a).toBeDefined()
expect(a!.before).toBe("") expect(a!.patch).toContain(`+new-${ids[i]}`)
expect(a!.after).toBe(`new-${ids[i]}\nこんにちは`)
expect(a!.status).toBe("added") expect(a!.status).toBe("added")
const b = map.get(fwd("mix", `${ids[i]}-bin.bin`)) const b = map.get(fwd("mix", `${ids[i]}-bin.bin`))
expect(b).toBeDefined() expect(b).toBeDefined()
expect(b!.before).toBe("") expect(b!.patch).toBe("")
expect(b!.after).toBe("")
expect(b!.additions).toBe(0) expect(b!.additions).toBe(0)
expect(b!.deletions).toBe(0) expect(b!.deletions).toBe(0)
expect(b!.status).toBe("modified") expect(b!.status).toBe("modified")
@ -1092,8 +1088,8 @@ test("diffFull with file modifications", async () => {
const modifiedFileDiff = diffs[0] const modifiedFileDiff = diffs[0]
expect(modifiedFileDiff.file).toBe("b.txt") expect(modifiedFileDiff.file).toBe("b.txt")
expect(modifiedFileDiff.before).toBe(tmp.extra.bContent) expect(modifiedFileDiff.patch).toContain(`-${tmp.extra.bContent}`)
expect(modifiedFileDiff.after).toBe("modified content") expect(modifiedFileDiff.patch).toContain("+modified content")
expect(modifiedFileDiff.additions).toBeGreaterThan(0) expect(modifiedFileDiff.additions).toBeGreaterThan(0)
expect(modifiedFileDiff.deletions).toBeGreaterThan(0) expect(modifiedFileDiff.deletions).toBeGreaterThan(0)
}, },
@ -1118,8 +1114,7 @@ test("diffFull with file deletions", async () => {
const removedFileDiff = diffs[0] const removedFileDiff = diffs[0]
expect(removedFileDiff.file).toBe("a.txt") expect(removedFileDiff.file).toBe("a.txt")
expect(removedFileDiff.before).toBe(tmp.extra.aContent) expect(removedFileDiff.patch).toContain(`-${tmp.extra.aContent}`)
expect(removedFileDiff.after).toBe("")
expect(removedFileDiff.additions).toBe(0) expect(removedFileDiff.additions).toBe(0)
expect(removedFileDiff.deletions).toBe(1) expect(removedFileDiff.deletions).toBe(1)
}, },
@ -1144,8 +1139,8 @@ test("diffFull with multiple line additions", async () => {
const multiDiff = diffs[0] const multiDiff = diffs[0]
expect(multiDiff.file).toBe("multi.txt") expect(multiDiff.file).toBe("multi.txt")
expect(multiDiff.before).toBe("") expect(multiDiff.patch).toContain("+line1")
expect(multiDiff.after).toBe("line1\nline2\nline3") expect(multiDiff.patch).toContain("+line3")
expect(multiDiff.additions).toBe(3) expect(multiDiff.additions).toBe(3)
expect(multiDiff.deletions).toBe(0) 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") const addedFileDiff = diffs.find((d) => d.file === "added.txt")
expect(addedFileDiff).toBeDefined() expect(addedFileDiff).toBeDefined()
expect(addedFileDiff!.before).toBe("") expect(addedFileDiff!.patch).toContain("+added content")
expect(addedFileDiff!.after).toBe("added content")
expect(addedFileDiff!.additions).toBe(1) expect(addedFileDiff!.additions).toBe(1)
expect(addedFileDiff!.deletions).toBe(0) expect(addedFileDiff!.deletions).toBe(0)
const removedFileDiff = diffs.find((d) => d.file === "a.txt") const removedFileDiff = diffs.find((d) => d.file === "a.txt")
expect(removedFileDiff).toBeDefined() expect(removedFileDiff).toBeDefined()
expect(removedFileDiff!.before).toBe(tmp.extra.aContent) expect(removedFileDiff!.patch).toContain(`-${tmp.extra.aContent}`)
expect(removedFileDiff!.after).toBe("")
expect(removedFileDiff!.additions).toBe(0) expect(removedFileDiff!.additions).toBe(0)
expect(removedFileDiff!.deletions).toBe(1) expect(removedFileDiff!.deletions).toBe(1)
}, },
@ -1263,7 +1256,7 @@ test("diffFull with binary file changes", async () => {
const binaryDiff = diffs[0] const binaryDiff = diffs[0]
expect(binaryDiff.file).toBe("binary.bin") expect(binaryDiff.file).toBe("binary.bin")
expect(binaryDiff.before).toBe("") expect(binaryDiff.patch).toBe("")
}, },
}) })
}) })

View File

@ -27,9 +27,7 @@ type AskInput = {
filePath: string filePath: string
relativePath: string relativePath: string
type: "add" | "update" | "delete" | "move" type: "add" | "update" | "delete" | "move"
diff: string patch: string
before: string
after: string
additions: number additions: number
deletions: number deletions: number
movePath?: string movePath?: string
@ -112,12 +110,12 @@ describe("tool.apply_patch freeform", () => {
const addFile = permissionCall.metadata.files.find((f) => f.type === "add") const addFile = permissionCall.metadata.files.find((f) => f.type === "add")
expect(addFile).toBeDefined() expect(addFile).toBeDefined()
expect(addFile!.relativePath).toBe("nested/new.txt") 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") const updateFile = permissionCall.metadata.files.find((f) => f.type === "update")
expect(updateFile).toBeDefined() expect(updateFile).toBeDefined()
expect(updateFile!.before).toContain("line2") expect(updateFile!.patch).toContain("-line2")
expect(updateFile!.after).toContain("changed") expect(updateFile!.patch).toContain("+changed")
const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8") const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8")
expect(added).toBe("created\n") expect(added).toBe("created\n")
@ -151,8 +149,8 @@ describe("tool.apply_patch freeform", () => {
expect(moveFile.type).toBe("move") expect(moveFile.type).toBe("move")
expect(moveFile.relativePath).toBe("renamed/dir/name.txt") expect(moveFile.relativePath).toBe("renamed/dir/name.txt")
expect(moveFile.movePath).toBe(path.join(fixture.path, "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.patch).toContain("-old content")
expect(moveFile.after).toBe("new content\n") expect(moveFile.patch).toContain("+new content")
}, },
}) })
}) })

View File

@ -347,10 +347,9 @@ export type EventCommandExecuted = {
} }
} }
export type FileDiff = { export type SnapshotFileDiff = {
file: string file: string
before: string patch: string
after: string
additions: number additions: number
deletions: number deletions: number
status?: "added" | "deleted" | "modified" status?: "added" | "deleted" | "modified"
@ -360,7 +359,7 @@ export type EventSessionDiff = {
type: "session.diff" type: "session.diff"
properties: { properties: {
sessionID: string sessionID: string
diff: Array<FileDiff> diff: Array<SnapshotFileDiff>
} }
} }
@ -542,7 +541,7 @@ export type UserMessage = {
summary?: { summary?: {
title?: string title?: string
body?: string body?: string
diffs: Array<FileDiff> diffs: Array<SnapshotFileDiff>
} }
agent: string agent: string
model: { model: {
@ -917,7 +916,7 @@ export type Session = {
additions: number additions: number
deletions: number deletions: number
files: number files: number
diffs?: Array<FileDiff> diffs?: Array<SnapshotFileDiff>
} }
share?: { share?: {
url: string url: string
@ -1078,7 +1077,7 @@ export type SyncEventSessionUpdated = {
additions: number additions: number
deletions: number deletions: number
files: number files: number
diffs?: Array<FileDiff> diffs?: Array<SnapshotFileDiff>
} | null } | null
share?: { share?: {
url: string | null url: string | null
@ -1803,7 +1802,7 @@ export type GlobalSession = {
additions: number additions: number
deletions: number deletions: number
files: number files: number
diffs?: Array<FileDiff> diffs?: Array<SnapshotFileDiff>
} }
share?: { share?: {
url: string url: string
@ -2009,6 +2008,14 @@ export type VcsInfo = {
default_branch?: string default_branch?: string
} }
export type VcsFileDiff = {
file: string
patch: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
}
export type Command = { export type Command = {
name: string name: string
description?: string description?: string
@ -3503,7 +3510,7 @@ export type SessionDiffResponses = {
/** /**
* Successfully retrieved diff * Successfully retrieved diff
*/ */
200: Array<FileDiff> 200: Array<SnapshotFileDiff>
} }
export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses] export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses]
@ -5159,7 +5166,7 @@ export type VcsDiffResponses = {
/** /**
* VCS diff * VCS diff
*/ */
200: Array<FileDiff> 200: Array<VcsFileDiff>
} }
export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses]

View File

@ -53,6 +53,7 @@
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
"diff": "catalog:",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"katex": "0.16.27", "katex": "0.16.27",

View File

@ -16,6 +16,7 @@ export type FileMediaOptions = {
current?: unknown current?: unknown
before?: unknown before?: unknown
after?: unknown after?: unknown
deleted?: boolean
readFile?: (path: string) => Promise<FileContent | undefined> readFile?: (path: string) => Promise<FileContent | undefined>
onLoad?: () => void onLoad?: () => void
onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void
@ -49,6 +50,7 @@ export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX
const media = cfg() const media = cfg()
const k = kind() const k = kind()
if (!media || !k) return false if (!media || !k) return false
if (media.deleted) return true
if (k === "svg") return false if (k === "svg") return false
if (media.current !== undefined) return false if (media.current !== undefined) return false
return !hasMediaValue(media.after as any) && hasMediaValue(media.before as any) return !hasMediaValue(media.after as any) && hasMediaValue(media.before as any)

View File

@ -1,5 +1,5 @@
import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs" 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 { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Dynamic, isServer } from "solid-js/web" import { Dynamic, isServer } from "solid-js/web"
import { useWorkerPool } from "../context/worker-pool" import { useWorkerPool } from "../context/worker-pool"
@ -16,8 +16,10 @@ import {
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer" import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { File, type DiffFileProps, type FileProps } from "./file" import { File, type DiffFileProps, type FileProps } from "./file"
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
type SSRDiffFileProps<T> = DiffFileProps<T> & { type SSRDiffFileProps<T> = DiffFileProps<T> & {
preloadedDiff: PreloadMultiFileDiffResult<T> preloadedDiff: DiffPreload<T>
} }
function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) { function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
@ -32,6 +34,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
const [local, others] = splitProps(props, [ const [local, others] = splitProps(props, [
"mode", "mode",
"media", "media",
"fileDiff",
"before", "before",
"after", "after",
"class", "class",
@ -90,12 +93,13 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
onCleanup(observeViewerScheme(() => fileDiffRef)) onCleanup(observeViewerScheme(() => fileDiffRef))
const virtualizer = getVirtualizer() const virtualizer = getVirtualizer()
const annotations = local.annotations ?? local.preloadedDiff.annotations ?? []
fileDiffInstance = virtualizer fileDiffInstance = virtualizer
? new VirtualizedFileDiff<T>( ? new VirtualizedFileDiff<T>(
{ {
...createDefaultOptions(props.diffStyle), ...createDefaultOptions(props.diffStyle),
...others, ...others,
...local.preloadedDiff, ...(local.preloadedDiff.options ?? {}),
}, },
virtualizer, virtualizer,
virtualMetrics, virtualMetrics,
@ -105,7 +109,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
{ {
...createDefaultOptions(props.diffStyle), ...createDefaultOptions(props.diffStyle),
...others, ...others,
...local.preloadedDiff, ...(local.preloadedDiff.options ?? {}),
}, },
workerPool, workerPool,
) )
@ -114,13 +118,24 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
// @ts-expect-error private field required for hydration // @ts-expect-error private field required for hydration
fileDiffInstance.fileContainer = fileDiffRef fileDiffInstance.fileContainer = fileDiffRef
fileDiffInstance.hydrate({ fileDiffInstance.hydrate(
oldFile: local.before, local.fileDiff
newFile: local.after, ? {
lineAnnotations: local.annotations ?? [], fileDiff: local.fileDiff,
fileContainer: fileDiffRef, lineAnnotations: annotations,
containerWrapper: container, 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() notifyRendered()
}) })

View File

@ -3,6 +3,7 @@ import {
DEFAULT_VIRTUAL_FILE_METRICS, DEFAULT_VIRTUAL_FILE_METRICS,
type DiffLineAnnotation, type DiffLineAnnotation,
type FileContents, type FileContents,
type FileDiffMetadata,
File as PierreFile, File as PierreFile,
type FileDiffOptions, type FileDiffOptions,
FileDiff, FileDiff,
@ -14,7 +15,7 @@ import {
VirtualizedFileDiff, VirtualizedFileDiff,
Virtualizer, Virtualizer,
} from "@pierre/diffs" } 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 { createMediaQuery } from "@solid-primitives/media"
import { makeEventListener } from "@solid-primitives/event-listener" import { makeEventListener } from "@solid-primitives/event-listener"
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js" 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> preloadedDiff?: PreloadMultiFileDiffResult<T>
} }
export type DiffFileProps<T = {}> = FileDiffOptions<T> & type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
type DiffBaseProps<T> = FileDiffOptions<T> &
SharedProps<T> & { SharedProps<T> & {
mode: "diff" mode: "diff"
before: FileContents
after: FileContents
annotations?: DiffLineAnnotation<T>[] 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> export type FileProps<T = {}> = TextFileProps<T> | DiffFileProps<T>
const sharedKeys = [ const sharedKeys = [
@ -108,7 +123,7 @@ const sharedKeys = [
] as const ] as const
const textKeys = ["file", ...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 // Shared viewer hook
@ -976,6 +991,12 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
const virtuals = createSharedVirtualStrategy(() => viewer.container) const virtuals = createSharedVirtualStrategy(() => viewer.container)
const large = createMemo(() => { 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 before = typeof local.before?.contents === "string" ? local.before.contents : ""
const after = typeof local.after?.contents === "string" ? local.after.contents : "" const after = typeof local.after?.contents === "string" ? local.after.contents : ""
return Math.max(before.length, after.length) > 500_000 return Math.max(before.length, after.length) > 500_000
@ -1054,6 +1075,17 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
instance = value instance = value
}, },
draw: (value) => { draw: (value) => {
if (local.fileDiff) {
value.render({
fileDiff: local.fileDiff,
lineAnnotations: [],
containerWrapper: viewer.container,
})
return
}
if (!local.before || !local.after) return
value.render({ value.render({
oldFile: { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) }, oldFile: { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) },
newFile: { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) }, newFile: { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) },

View File

@ -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")
})
})

View File

@ -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("")
}

View File

@ -15,7 +15,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode" import { checksum } from "@opencode-ai/util/encode"
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js" import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
import { createStore } from "solid-js/store" 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 { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { type SelectedLineRange } from "@pierre/diffs" import { type SelectedLineRange } from "@pierre/diffs"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
@ -23,6 +23,7 @@ import { mediaKindFromPath } from "../pierre/media"
import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge" import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge"
import { createLineCommentController } from "./line-comment-annotations" import { createLineCommentController } from "./line-comment-annotations"
import type { LineCommentEditorProps } from "./line-comment" import type { LineCommentEditorProps } from "./line-comment"
import { normalize, text, type ViewDiff } from "./session-diff"
const MAX_DIFF_CHANGED_LINES = 500 const MAX_DIFF_CHANGED_LINES = 500
const REVIEW_MOUNT_MARGIN = 300 const REVIEW_MOUNT_MARGIN = 300
@ -61,7 +62,8 @@ export type SessionReviewCommentActions = {
export type SessionReviewFocus = { file: string; id: string } 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 { export interface SessionReviewProps {
title?: JSX.Element title?: JSX.Element
@ -155,8 +157,8 @@ export const SessionReview = (props: SessionReviewProps) => {
const opened = () => store.opened const opened = () => store.opened
const open = () => props.open ?? store.open const open = () => props.open ?? store.open
const files = createMemo(() => props.diffs.map((diff) => diff.file)) const items = createMemo<Item[]>(() => props.diffs.map((diff) => ({ ...normalize(diff), preloaded: diff.preloaded })))
const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const))) const files = createMemo(() => items().map((diff) => diff.file))
const grouped = createMemo(() => { const grouped = createMemo(() => {
const next = new Map<string, SessionReviewComment[]>() const next = new Map<string, SessionReviewComment[]>()
for (const comment of props.comments ?? []) { 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 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 side = selectionSide(range)
const contents = side === "deletions" ? diff.before : diff.after const contents = text(diff, side)
if (typeof contents !== "string" || contents.length === 0) return undefined if (contents.length === 0) return undefined
return previewSelectedLines(contents, range) return previewSelectedLines(contents, range)
} }
@ -359,7 +361,7 @@ export const SessionReview = (props: SessionReviewProps) => {
<Show when={hasDiffs()} fallback={props.empty}> <Show when={hasDiffs()} fallback={props.empty}>
<div class="pb-6"> <div class="pb-6">
<Accordion multiple value={open()} onChange={handleChange}> <Accordion multiple value={open()} onChange={handleChange}>
<For each={props.diffs}> <For each={items()}>
{(diff) => { {(diff) => {
let wrapper: HTMLDivElement | undefined let wrapper: HTMLDivElement | undefined
const file = diff.file const file = diff.file
@ -371,8 +373,8 @@ export const SessionReview = (props: SessionReviewProps) => {
const comments = createMemo(() => grouped().get(file) ?? []) const comments = createMemo(() => grouped().get(file) ?? [])
const commentedLines = createMemo(() => comments().map((c) => c.selection)) const commentedLines = createMemo(() => comments().map((c) => c.selection))
const beforeText = () => (typeof diff.before === "string" ? diff.before : "") const beforeText = () => text(diff, "deletions")
const afterText = () => (typeof diff.after === "string" ? diff.after : "") const afterText = () => text(diff, "additions")
const changedLines = () => diff.additions + diff.deletions const changedLines = () => diff.additions + diff.deletions
const mediaKind = createMemo(() => mediaKindFromPath(file)) const mediaKind = createMemo(() => mediaKindFromPath(file))
@ -581,6 +583,7 @@ export const SessionReview = (props: SessionReviewProps) => {
<Dynamic <Dynamic
component={fileComponent} component={fileComponent}
mode="diff" mode="diff"
fileDiff={diff.fileDiff}
preloadedDiff={diff.preloaded} preloadedDiff={diff.preloaded}
diffStyle={diffStyle()} diffStyle={diffStyle()}
onRendered={() => { onRendered={() => {
@ -596,20 +599,11 @@ export const SessionReview = (props: SessionReviewProps) => {
renderHoverUtility={props.onLineComment ? commentsUi.renderHoverUtility : undefined} renderHoverUtility={props.onLineComment ? commentsUi.renderHoverUtility : undefined}
selectedLines={selectedLines()} selectedLines={selectedLines()}
commentedLines={commentedLines()} commentedLines={commentedLines()}
before={{
name: file,
contents: typeof diff.before === "string" ? diff.before : "",
}}
after={{
name: file,
contents: typeof diff.after === "string" ? diff.after : "",
}}
media={{ media={{
mode: "auto", mode: "auto",
path: file, path: file,
before: diff.before, deleted: diff.status === "deleted",
after: diff.after, readFile: diff.status === "deleted" ? undefined : props.readFile,
readFile: props.readFile,
}} }}
/> />
</Match> </Match>

View File

@ -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 type { SessionStatus } from "@opencode-ai/sdk/v2"
import { useData } from "../context" import { useData } from "../context"
import { useFileComponent } from "../context/file" import { useFileComponent } from "../context/file"
@ -19,6 +24,7 @@ import { SessionRetry } from "./session-retry"
import { TextReveal } from "./text-reveal" import { TextReveal } from "./text-reveal"
import { createAutoScroll } from "../hooks" import { createAutoScroll } from "../hooks"
import { useI18n } from "../context/i18n" import { useI18n } from "../context/i18n"
import { normalize } from "./session-diff"
function record(value: unknown): value is Record<string, unknown> { function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value) return !!value && typeof value === "object" && !Array.isArray(value)
@ -163,7 +169,7 @@ export function SessionTurn(
const emptyMessages: MessageType[] = [] const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = [] const emptyParts: PartType[] = []
const emptyAssistant: AssistantMessage[] = [] const emptyAssistant: AssistantMessage[] = []
const emptyDiffs: FileDiff[] = [] const emptyDiffs: SnapshotFileDiff[] = []
const idle = { type: "idle" as const } const idle = { type: "idle" as const }
const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages)) const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages))
@ -232,7 +238,7 @@ export function SessionTurn(
const seen = new Set<string>() const seen = new Set<string>()
return files return files
.reduceRight<FileDiff[]>((result, diff) => { .reduceRight<SnapshotFileDiff[]>((result, diff) => {
if (seen.has(diff.file)) return result if (seen.has(diff.file)) return result
seen.add(diff.file) seen.add(diff.file)
result.push(diff) result.push(diff)
@ -447,6 +453,7 @@ export function SessionTurn(
> >
<For each={visible()}> <For each={visible()}>
{(diff) => { {(diff) => {
const view = normalize(diff)
const active = createMemo(() => expanded().includes(diff.file)) const active = createMemo(() => expanded().includes(diff.file))
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
@ -495,12 +502,7 @@ export function SessionTurn(
<Accordion.Content> <Accordion.Content>
<Show when={shown()}> <Show when={shown()}>
<div data-slot="session-turn-diff-view" data-scrollable> <div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic <Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} />
component={fileComponent}
mode="diff"
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
/>
</div> </div>
</Show> </Show>
</Accordion.Content> </Accordion.Content>

View File

@ -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 { createSimpleContext } from "./helper"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
@ -13,7 +13,7 @@ type Data = {
[sessionID: string]: SessionStatus [sessionID: string]: SessionStatus
} }
session_diff: { session_diff: {
[sessionID: string]: FileDiff[] [sessionID: string]: SnapshotFileDiff[]
} }
session_diff_preload?: { session_diff_preload?: {
[sessionID: string]: PreloadMultiFileDiffResult<any>[] [sessionID: string]: PreloadMultiFileDiffResult<any>[]