fix(app): stabilize virtual session timeline interactions (#28422)
This commit is contained in:
@@ -36,7 +36,6 @@ describe("apply patch file", () => {
|
||||
])[0]
|
||||
|
||||
expect(file).toBeDefined()
|
||||
expect(file?.view.patch).toContain("@@ -1,1 +1,1 @@")
|
||||
expect(text(file!.view, "deletions")).toBe("one\n")
|
||||
expect(text(file!.view, "additions")).toBe("two\n")
|
||||
})
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface BasicToolProps {
|
||||
status?: string
|
||||
hideDetails?: boolean
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
forceOpen?: boolean
|
||||
defer?: boolean
|
||||
locked?: boolean
|
||||
@@ -83,7 +85,7 @@ export function BasicTool(props: BasicToolProps) {
|
||||
open: props.defaultOpen ?? false,
|
||||
ready: !props.defer && (props.defaultOpen ?? false),
|
||||
})
|
||||
const open = () => state.open
|
||||
const open = () => props.open ?? state.open
|
||||
const ready = () => state.ready
|
||||
const pending = () => props.status === "pending" || props.status === "running"
|
||||
const hasChildren = () => (props.defer ? "children" in props : props.children)
|
||||
@@ -110,8 +112,15 @@ export function BasicTool(props: BasicToolProps) {
|
||||
if (props.defer && open()) scheduleReady(true)
|
||||
})
|
||||
|
||||
const setOpen = (value: boolean) => {
|
||||
if (props.open === undefined) setState("open", value)
|
||||
props.onOpenChange?.(value)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.forceOpen) setState("open", true)
|
||||
if (!props.forceOpen) return
|
||||
if (open()) return
|
||||
setOpen(true)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
@@ -166,7 +175,7 @@ export function BasicTool(props: BasicToolProps) {
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (pending()) return
|
||||
if (props.locked && !value) return
|
||||
setState("open", value)
|
||||
setOpen(value)
|
||||
}
|
||||
|
||||
const trigger = () => (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { sampledChecksum } from "@opencode-ai/core/util/encode"
|
||||
import {
|
||||
areFilesEqual,
|
||||
areOptionsEqual,
|
||||
DEFAULT_VIRTUAL_FILE_METRICS,
|
||||
type DiffLineAnnotation,
|
||||
type FileContents,
|
||||
@@ -88,6 +90,7 @@ type DiffBaseProps<T> = FileDiffOptions<T> &
|
||||
mode: "diff"
|
||||
annotations?: DiffLineAnnotation<T>[]
|
||||
preloadedDiff?: DiffPreload<T>
|
||||
virtualize?: boolean
|
||||
}
|
||||
|
||||
type DiffPairProps<T> = DiffBaseProps<T> & {
|
||||
@@ -123,7 +126,7 @@ const sharedKeys = [
|
||||
] as const
|
||||
|
||||
const textKeys = ["file", ...sharedKeys] as const
|
||||
const diffKeys = ["fileDiff", "before", "after", ...sharedKeys] as const
|
||||
const diffKeys = ["fileDiff", "before", "after", "virtualize", ...sharedKeys] as const
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared viewer hook
|
||||
@@ -482,17 +485,24 @@ function notifyRendered(opts: {
|
||||
function renderViewer<I extends RenderTarget>(opts: {
|
||||
viewer: Viewer
|
||||
current: I | undefined
|
||||
reset?: boolean
|
||||
create: () => I
|
||||
update?: (value: I) => void
|
||||
assign: (value: I) => void
|
||||
draw: (value: I) => void
|
||||
onReady: () => void
|
||||
}) {
|
||||
clearReadyWatcher(opts.viewer.ready)
|
||||
opts.current?.cleanUp()
|
||||
const next = opts.create()
|
||||
opts.assign(next)
|
||||
const reset = opts.reset === true && opts.current !== undefined
|
||||
if (reset) opts.current?.cleanUp()
|
||||
const next = reset || !opts.current ? opts.create() : opts.current
|
||||
if (reset || !opts.current) {
|
||||
opts.viewer.container.innerHTML = ""
|
||||
opts.assign(next)
|
||||
} else {
|
||||
opts.update?.(next)
|
||||
}
|
||||
|
||||
opts.viewer.container.innerHTML = ""
|
||||
opts.draw(next)
|
||||
|
||||
applyViewerScheme(opts.viewer.getHost())
|
||||
@@ -566,7 +576,7 @@ function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enab
|
||||
}
|
||||
}
|
||||
|
||||
function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): VirtualStrategy {
|
||||
function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
|
||||
let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
||||
|
||||
const release = () => {
|
||||
@@ -576,6 +586,10 @@ function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): Vi
|
||||
|
||||
return {
|
||||
get: () => {
|
||||
if (!enabled()) {
|
||||
release()
|
||||
return
|
||||
}
|
||||
if (shared) return shared.virtualizer
|
||||
|
||||
const container = host()
|
||||
@@ -858,15 +872,14 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||
createEffect(() => {
|
||||
const opts = options()
|
||||
const workerPool = getWorkerPool("unified")
|
||||
const isVirtual = virtual()
|
||||
|
||||
const virtualizer = virtuals.get()
|
||||
|
||||
renderViewer({
|
||||
viewer,
|
||||
current: instance,
|
||||
reset: instance !== undefined,
|
||||
create: () =>
|
||||
isVirtual && virtualizer
|
||||
virtualizer
|
||||
? new VirtualizedFile<T>(opts, virtualizer, codeMetrics, workerPool)
|
||||
: new PierreFile<T>(opts, workerPool),
|
||||
assign: (value) => {
|
||||
@@ -907,6 +920,12 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||
|
||||
function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
let instance: FileDiff<T> | undefined
|
||||
let instanceVirtualizer: Virtualizer | undefined
|
||||
let instanceWorkerPool: ReturnType<typeof getWorkerPool>
|
||||
let instanceVirtualHunkSeparators: FileDiffOptions<T>["hunkSeparators"] | undefined
|
||||
let instanceFileDiff: FileDiffMetadata | undefined
|
||||
let instanceBefore: FileContents | undefined
|
||||
let instanceAfter: FileContents | undefined
|
||||
let dragSide: DiffSelectionSide | undefined
|
||||
let dragEndSide: DiffSelectionSide | undefined
|
||||
let viewer!: Viewer
|
||||
@@ -991,7 +1010,10 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
adapter,
|
||||
)
|
||||
|
||||
const virtuals = createSharedVirtualStrategy(() => viewer.container)
|
||||
const virtuals = createSharedVirtualStrategy(
|
||||
() => viewer.container,
|
||||
() => local.virtualize !== false,
|
||||
)
|
||||
|
||||
const large = createMemo(() => {
|
||||
if (local.fileDiff) {
|
||||
@@ -1067,31 +1089,62 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
return sampledChecksum(contents)
|
||||
}
|
||||
|
||||
const before = local.before ? { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) } : undefined
|
||||
const after = local.after ? { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) } : undefined
|
||||
const targetChanged =
|
||||
local.fileDiff !== undefined
|
||||
? instanceFileDiff !== local.fileDiff
|
||||
: instanceFileDiff !== undefined ||
|
||||
before === undefined ||
|
||||
after === undefined ||
|
||||
instanceBefore === undefined ||
|
||||
instanceAfter === undefined ||
|
||||
!areFilesEqual(instanceBefore, before) ||
|
||||
!areFilesEqual(instanceAfter, after)
|
||||
// Pierre beta virtualized instances retain their first diff target and resolve separator metrics at construction.
|
||||
// Plain timeline diffs can retain the instance as content streams; virtualized viewers reset only when that is unsafe.
|
||||
const reset =
|
||||
instance !== undefined &&
|
||||
(instanceVirtualizer !== virtualizer ||
|
||||
instanceWorkerPool !== workerPool ||
|
||||
(virtualizer !== undefined && (instanceVirtualHunkSeparators !== opts.hunkSeparators || targetChanged)))
|
||||
const forceRender = !reset && instance !== undefined && !areOptionsEqual(instance.options, opts)
|
||||
|
||||
renderViewer({
|
||||
viewer,
|
||||
current: instance,
|
||||
reset,
|
||||
create: () =>
|
||||
virtualizer
|
||||
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
|
||||
: new FileDiff<T>(opts, workerPool),
|
||||
update: (value) => value.setOptions(opts),
|
||||
assign: (value) => {
|
||||
instance = value
|
||||
instanceVirtualizer = virtualizer
|
||||
instanceWorkerPool = workerPool
|
||||
instanceVirtualHunkSeparators = virtualizer ? opts.hunkSeparators : undefined
|
||||
instanceFileDiff = local.fileDiff
|
||||
instanceBefore = before
|
||||
instanceAfter = after
|
||||
},
|
||||
draw: (value) => {
|
||||
if (local.fileDiff) {
|
||||
value.render({
|
||||
fileDiff: local.fileDiff,
|
||||
forceRender,
|
||||
lineAnnotations: [],
|
||||
containerWrapper: viewer.container,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!local.before || !local.after) return
|
||||
if (!before || !after) return
|
||||
|
||||
value.render({
|
||||
oldFile: { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) },
|
||||
newFile: { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) },
|
||||
oldFile: before,
|
||||
newFile: after,
|
||||
forceRender,
|
||||
lineAnnotations: [],
|
||||
containerWrapper: viewer.container,
|
||||
})
|
||||
@@ -1111,6 +1164,12 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
onCleanup(() => {
|
||||
instance?.cleanUp()
|
||||
instance = undefined
|
||||
instanceVirtualizer = undefined
|
||||
instanceWorkerPool = undefined
|
||||
instanceVirtualHunkSeparators = undefined
|
||||
instanceFileDiff = undefined
|
||||
instanceBefore = undefined
|
||||
instanceAfter = undefined
|
||||
virtuals.cleanup()
|
||||
dragSide = undefined
|
||||
dragEndSide = undefined
|
||||
|
||||
@@ -175,7 +175,10 @@ export interface MessagePartProps {
|
||||
message: MessageType
|
||||
hideDetails?: boolean
|
||||
defaultOpen?: boolean
|
||||
toolOpen?: boolean
|
||||
onToolOpenChange?: (open: boolean) => void
|
||||
deferToolContent?: boolean
|
||||
virtualizeDiff?: boolean
|
||||
showAssistantCopyPartID?: string | null
|
||||
turnDurationMs?: number
|
||||
}
|
||||
@@ -290,7 +293,7 @@ function getDirectory(path: string | undefined) {
|
||||
}
|
||||
|
||||
import type { IconProps } from "./icon"
|
||||
import { normalize } from "./session-diff"
|
||||
import { normalize, resolveFileDiff } from "./session-diff"
|
||||
|
||||
export type ToolInfo = {
|
||||
icon: IconProps["name"]
|
||||
@@ -930,7 +933,7 @@ export function AssistantMessageDisplay(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
|
||||
export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSizeChange?: () => void }) {
|
||||
const i18n = useI18n()
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const pending = createMemo(
|
||||
@@ -938,11 +941,15 @@ export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
|
||||
!!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
|
||||
)
|
||||
const summary = createMemo(() => contextToolSummary(props.parts))
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
setOpen(value)
|
||||
props.onSizeChange?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open()}
|
||||
onOpenChange={setOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
variant="ghost"
|
||||
class="tool-collapsible"
|
||||
data-timeline-part-ids={props.parts.map((part) => part.id).join(",")}
|
||||
@@ -1261,7 +1268,10 @@ export function Part(props: MessagePartProps) {
|
||||
message={props.message}
|
||||
hideDetails={props.hideDetails}
|
||||
defaultOpen={props.defaultOpen}
|
||||
toolOpen={props.toolOpen}
|
||||
onToolOpenChange={props.onToolOpenChange}
|
||||
deferToolContent={props.deferToolContent}
|
||||
virtualizeDiff={props.virtualizeDiff}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
turnDurationMs={props.turnDurationMs}
|
||||
/>
|
||||
@@ -1278,7 +1288,10 @@ export interface ToolProps {
|
||||
status?: string
|
||||
hideDetails?: boolean
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
deferContent?: boolean
|
||||
virtualizeDiff?: boolean
|
||||
forceOpen?: boolean
|
||||
locked?: boolean
|
||||
}
|
||||
@@ -1376,6 +1389,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
})
|
||||
|
||||
const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool)
|
||||
const controlledOpen = () => (props.onToolOpenChange ? (props.toolOpen ?? props.defaultOpen) : undefined)
|
||||
const handleToolOpenChange = (open: boolean) => props.onToolOpenChange?.(open)
|
||||
|
||||
return (
|
||||
<Show when={!hideQuestion()}>
|
||||
@@ -1399,6 +1414,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
error={error()}
|
||||
title={part().tool === "websearch" ? webSearchProviderLabel(partMetadata().provider) : undefined}
|
||||
defaultOpen={props.defaultOpen}
|
||||
open={controlledOpen()}
|
||||
onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
|
||||
subtitle={taskSubtitle()}
|
||||
href={taskHref()}
|
||||
/>
|
||||
@@ -1417,7 +1434,10 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
status={part().state.status}
|
||||
hideDetails={props.hideDetails}
|
||||
defaultOpen={props.defaultOpen}
|
||||
open={controlledOpen()}
|
||||
onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
|
||||
deferContent={props.deferToolContent}
|
||||
virtualizeDiff={props.virtualizeDiff}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -1921,15 +1941,29 @@ ToolRegistry.register({
|
||||
const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "")
|
||||
const filename = () => getFilename(props.input.filePath ?? "")
|
||||
const pending = () => props.status === "pending" || props.status === "running"
|
||||
const diffSource = createMemo(
|
||||
() => {
|
||||
const filediff = props.metadata?.filediff
|
||||
if (!filediff) return
|
||||
return {
|
||||
file: filediff.file || props.input.filePath || "",
|
||||
patch: typeof filediff.patch === "string" ? filediff.patch : undefined,
|
||||
before: typeof filediff.before === "string" ? filediff.before : undefined,
|
||||
after: typeof filediff.after === "string" ? filediff.after : undefined,
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
equals: (a, b) =>
|
||||
a?.file === b?.file && a?.patch === b?.patch && a?.before === b?.before && a?.after === b?.after,
|
||||
},
|
||||
)
|
||||
|
||||
const fileCompProps = createMemo(() => {
|
||||
try {
|
||||
if (props.metadata?.filediff) {
|
||||
const diff = normalize({
|
||||
...props.metadata?.filediff,
|
||||
status: "modified",
|
||||
})
|
||||
const fileDiff = diff.fileDiff
|
||||
const source = diffSource()
|
||||
if (source) {
|
||||
const fileDiff = resolveFileDiff(source)
|
||||
if (fileDiff) return { fileDiff, hunkSeparators: fileDiff.isPartial ? "simple" : "line-info-basic" }
|
||||
}
|
||||
} catch {}
|
||||
@@ -1987,7 +2021,7 @@ ToolRegistry.register({
|
||||
}
|
||||
>
|
||||
<div data-component="edit-content">
|
||||
<Dynamic component={fileComponent} mode="diff" {...fileCompProps()} />
|
||||
<Dynamic component={fileComponent} mode="diff" virtualize={props.virtualizeDiff} {...fileCompProps()} />
|
||||
</div>
|
||||
</ToolFileAccordion>
|
||||
</Show>
|
||||
@@ -2171,6 +2205,7 @@ ToolRegistry.register({
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
virtualize={props.virtualizeDiff}
|
||||
fileDiff={file.view.fileDiff}
|
||||
hunkSeparators={file.view.fileDiff.isPartial ? "simple" : "line-info-basic"}
|
||||
/>
|
||||
@@ -2243,7 +2278,7 @@ ToolRegistry.register({
|
||||
}
|
||||
>
|
||||
<div data-component="apply-patch-file-diff">
|
||||
<Dynamic component={fileComponent} mode="diff" fileDiff={single()!.view.fileDiff} />
|
||||
<Dynamic component={fileComponent} mode="diff" virtualize={props.virtualizeDiff} fileDiff={single()!.view.fileDiff} />
|
||||
</div>
|
||||
</ToolFileAccordion>
|
||||
</BasicTool>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { normalize, text } from "./session-diff"
|
||||
import { normalize, resolveFileDiff, text } from "./session-diff"
|
||||
|
||||
describe("session diff", () => {
|
||||
test("keeps unified patch content", () => {
|
||||
@@ -13,8 +13,8 @@ describe("session diff", () => {
|
||||
}
|
||||
const view = normalize(diff)
|
||||
|
||||
expect(view.patch).toBe(diff.patch)
|
||||
expect(view.fileDiff.name).toBe("a.ts")
|
||||
expect(view.fileDiff.isPartial).toBe(true)
|
||||
expect(text(view, "deletions")).toBe("one\ntwo\n")
|
||||
expect(text(view, "additions")).toBe("one\nthree\n")
|
||||
})
|
||||
@@ -34,7 +34,52 @@ describe("session diff", () => {
|
||||
expect(text(view, "additions")).toBe("one\nthree")
|
||||
})
|
||||
|
||||
test("converts legacy content into a patch", () => {
|
||||
test("keeps separated patch hunks partial without complete file contents", () => {
|
||||
const fileDiff = resolveFileDiff({
|
||||
file: "project.ts",
|
||||
patch:
|
||||
'Index: project.ts\n===================================================================\n--- project.ts\t\n+++ project.ts\t\n@@ -1,3 +1,2 @@\n import { and } from "drizzle-orm"\n-import { sql } from "drizzle-orm"\n import { ProjectTable } from "./project.sql"\n@@ -346,3 +345,3 @@\n import { Database } from "@/storage/db"\n-import { ProjectTable } from "./project.sql"\n+import { ProjectTable } from "../project/project.sql"\n import { SessionTable } from "../session/session.sql"\n',
|
||||
})
|
||||
|
||||
expect(fileDiff.isPartial).toBe(true)
|
||||
expect(fileDiff.hunks).toHaveLength(2)
|
||||
expect(fileDiff.hunks[1]?.collapsedBefore).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("renders headerless persisted patches", () => {
|
||||
const view = normalize({
|
||||
file: "a.ts",
|
||||
patch: "@@ -1 +1 @@\n-old\n+new\n",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified" as const,
|
||||
})
|
||||
|
||||
expect(view.fileDiff.name).toBe("a.ts")
|
||||
expect(view.fileDiff.isPartial).toBe(true)
|
||||
expect(text(view, "deletions")).toBe("old\n")
|
||||
expect(text(view, "additions")).toBe("new\n")
|
||||
})
|
||||
|
||||
test("does not share headerless patch metadata between files", () => {
|
||||
const patch = "@@ -1 +1 @@\n-old\n+new\n"
|
||||
|
||||
expect(resolveFileDiff({ file: "a.ts", patch }).name).toBe("a.ts")
|
||||
expect(resolveFileDiff({ file: "b.ts", patch }).name).toBe("b.ts")
|
||||
})
|
||||
|
||||
test("keeps capped header-only patches partial", () => {
|
||||
const fileDiff = resolveFileDiff({
|
||||
file: "a.ts",
|
||||
patch: "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n",
|
||||
})
|
||||
|
||||
expect(fileDiff.name).toBe("a.ts")
|
||||
expect(fileDiff.isPartial).toBe(true)
|
||||
expect(fileDiff.hunks).toEqual([])
|
||||
})
|
||||
|
||||
test("keeps full legacy content as a complete diff", () => {
|
||||
const diff = {
|
||||
file: "a.ts",
|
||||
before: "one\n",
|
||||
@@ -45,7 +90,7 @@ describe("session diff", () => {
|
||||
}
|
||||
const view = normalize(diff)
|
||||
|
||||
expect(view.patch).toContain("@@ -1,1 +1,1 @@")
|
||||
expect(view.fileDiff.isPartial).toBe(false)
|
||||
expect(text(view, "deletions")).toBe("one\n")
|
||||
expect(text(view, "additions")).toBe("two\n")
|
||||
})
|
||||
@@ -61,7 +106,6 @@ describe("session diff", () => {
|
||||
}
|
||||
const view = normalize(diff)
|
||||
|
||||
expect(view.patch).toBe(diff.patch)
|
||||
expect(text(view, "deletions")).toBe("")
|
||||
expect(text(view, "additions")).toBe("")
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
|
||||
import { formatPatch, parsePatch, structuredPatch } from "diff"
|
||||
import { parsePatch } from "diff"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
|
||||
type LegacyDiff = {
|
||||
@@ -14,107 +14,35 @@ type LegacyDiff = {
|
||||
|
||||
type SnapshotDiff = SnapshotFileDiff & { file: string }
|
||||
type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff
|
||||
export type DiffSource = Pick<LegacyDiff, "file" | "patch" | "before" | "after">
|
||||
|
||||
export type ViewDiff = {
|
||||
file: string
|
||||
patch: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
fileDiff: FileDiffMetadata
|
||||
}
|
||||
|
||||
const cache = new Map<string, FileDiffMetadata>()
|
||||
const diffCacheLimit = 16
|
||||
const patchFileDiffCache = new Map<string, FileDiffMetadata>()
|
||||
|
||||
function patch(diff: ReviewDiff) {
|
||||
if (typeof diff.patch === "string") {
|
||||
try {
|
||||
const [patch] = parsePatch(diff.patch)
|
||||
const beforeLines: Array<{ text: string; newline: boolean }> = []
|
||||
const afterLines: Array<{ text: string; newline: boolean }> = []
|
||||
let previous: "-" | "+" | " " | undefined
|
||||
|
||||
const patchIsPartial = patch.hunks.every((h) => h.oldStart > 1)
|
||||
|
||||
for (const hunk of patch.hunks) {
|
||||
for (const line of hunk.lines) {
|
||||
if (line.startsWith("\\")) {
|
||||
if (previous === "-" || previous === " ") {
|
||||
const before = beforeLines.at(-1)
|
||||
if (before) before.newline = false
|
||||
}
|
||||
if (previous === "+" || previous === " ") {
|
||||
const after = afterLines.at(-1)
|
||||
if (after) after.newline = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith("-")) {
|
||||
beforeLines.push({ text: line.slice(1), newline: true })
|
||||
previous = "-"
|
||||
} else if (line.startsWith("+")) {
|
||||
afterLines.push({ text: line.slice(1), newline: true })
|
||||
previous = "+"
|
||||
} else {
|
||||
// context line (starts with ' ')
|
||||
beforeLines.push({ text: line.slice(1), newline: true })
|
||||
afterLines.push({ text: line.slice(1), newline: true })
|
||||
previous = " "
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
before: beforeLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""),
|
||||
after: afterLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""),
|
||||
patch: diff.patch,
|
||||
patchIsPartial,
|
||||
}
|
||||
} catch {
|
||||
return { before: "", after: "", patch: diff.patch, patchIsPartial: false }
|
||||
}
|
||||
}
|
||||
return {
|
||||
before: "before" in diff && typeof diff.before === "string" ? diff.before : "",
|
||||
after: "after" in diff && typeof diff.after === "string" ? diff.after : "",
|
||||
patch: 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 },
|
||||
),
|
||||
),
|
||||
patchIsPartial: false,
|
||||
}
|
||||
}
|
||||
|
||||
function file(file: string, patch: string, before: string, after: string, partial = false) {
|
||||
const hit = cache.get(patch)
|
||||
if (hit) return hit
|
||||
|
||||
let value: FileDiffMetadata | undefined
|
||||
if (partial) value = parsePatchFiles(patch)[0]?.files[0]
|
||||
if (value === undefined) value = parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after })
|
||||
|
||||
cache.set(patch, value)
|
||||
return value
|
||||
export function resolveFileDiff(diff: DiffSource) {
|
||||
if (typeof diff.patch === "string") return fileDiffFromPatch(diff.file, diff.patch)
|
||||
return fileDiffFromContent(
|
||||
diff.file,
|
||||
typeof diff.before === "string" ? diff.before : "",
|
||||
typeof diff.after === "string" ? diff.after : "",
|
||||
)
|
||||
}
|
||||
|
||||
export function normalize(diff: ReviewDiff): ViewDiff {
|
||||
const next = patch(diff)
|
||||
const fileDiff = file(diff.file, next.patch, next.before, next.after, next.patchIsPartial)
|
||||
return {
|
||||
file: diff.file,
|
||||
patch: next.patch,
|
||||
additions: diff.additions,
|
||||
deletions: diff.deletions,
|
||||
status: diff.status,
|
||||
fileDiff,
|
||||
fileDiff: resolveFileDiff(diff),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,3 +50,40 @@ export function text(diff: ViewDiff, side: "deletions" | "additions") {
|
||||
if (side === "deletions") return diff.fileDiff.deletionLines.join("")
|
||||
return diff.fileDiff.additionLines.join("")
|
||||
}
|
||||
|
||||
function fileDiffFromPatch(file: string, patch: string) {
|
||||
const key = `${file}\0${patch}`
|
||||
const hit = patchFileDiffCache.get(key)
|
||||
if (hit) {
|
||||
patchFileDiffCache.delete(key)
|
||||
patchFileDiffCache.set(key, hit)
|
||||
return hit
|
||||
}
|
||||
|
||||
const input = patchInput(file, patch)
|
||||
const value = (input ? parsePatchFiles(input)[0]?.files[0] : undefined) ?? emptyFileDiff(file)
|
||||
patchFileDiffCache.set(key, value)
|
||||
while (patchFileDiffCache.size > diffCacheLimit) patchFileDiffCache.delete(patchFileDiffCache.keys().next().value!)
|
||||
return value
|
||||
}
|
||||
|
||||
function patchInput(file: string, patch: string) {
|
||||
try {
|
||||
const parsed = parsePatch(patch)[0]
|
||||
if (!parsed) return
|
||||
if (parsed.index || parsed.oldFileName || parsed.newFileName) return patch
|
||||
if (!parsed.hunks.length) return
|
||||
return `Index: ${file}\n===================================================================\n--- ${file}\t\n+++ ${file}\t\n${patch}`
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function fileDiffFromContent(file: string, before: string, after: string) {
|
||||
if (!before && !after) return emptyFileDiff(file)
|
||||
return parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after })
|
||||
}
|
||||
|
||||
function emptyFileDiff(file: string) {
|
||||
return parseDiffFromFile({ name: file, contents: "" }, { name: file, contents: "" })
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface ToolErrorCardProps extends Omit<ComponentProps<typeof Card>, "c
|
||||
error: string
|
||||
title?: string
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
subtitle?: string
|
||||
href?: string
|
||||
}
|
||||
@@ -22,9 +24,22 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
|
||||
open: props.defaultOpen ?? false,
|
||||
copied: false,
|
||||
})
|
||||
const open = () => state.open
|
||||
const open = () => props.open ?? state.open
|
||||
const copied = () => state.copied
|
||||
const [split, rest] = splitProps(props, ["tool", "error", "title", "defaultOpen", "subtitle", "href"])
|
||||
const [split, rest] = splitProps(props, [
|
||||
"tool",
|
||||
"error",
|
||||
"title",
|
||||
"defaultOpen",
|
||||
"open",
|
||||
"onOpenChange",
|
||||
"subtitle",
|
||||
"href",
|
||||
])
|
||||
const setOpen = (value: boolean) => {
|
||||
if (props.open === undefined) setState("open", value)
|
||||
props.onOpenChange?.(value)
|
||||
}
|
||||
const name = createMemo(() => {
|
||||
if (split.title) return split.title
|
||||
const map: Record<string, string> = {
|
||||
@@ -81,7 +96,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
|
||||
class="tool-collapsible"
|
||||
data-open={open() ? "true" : "false"}
|
||||
open={open()}
|
||||
onOpenChange={(value) => setState("open", value)}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<Collapsible.Trigger>
|
||||
<div data-component="tool-trigger">
|
||||
|
||||
Reference in New Issue
Block a user