refactor(snapshot): store unified patches in file diffs (#21244)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>message-v3
parent
463318486f
commit
b7fab49b64
1
bun.lock
1
bun.lock
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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[]
|
||||||
|
|
|
||||||
|
|
@ -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,29 +1171,7 @@ export default function Page() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const reviewEmptyText = createMemo(() => {
|
const createGit = (input: { emptyClass: string }) => (
|
||||||
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())
|
|
||||||
})
|
|
||||||
|
|
||||||
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
|
||||||
if (store.changes === "git" || store.changes === "branch") {
|
|
||||||
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
|
||||||
return empty(reviewEmptyText())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (store.changes === "turn") {
|
|
||||||
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={input.emptyClass}>
|
||||||
<div class="flex flex-col gap-3">
|
<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-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
|
||||||
|
|
@ -1216,6 +1186,22 @@ export default function Page() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const reviewEmptyText = createMemo(() => {
|
||||||
|
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
|
||||||
|
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
|
||||||
|
return language.t("session.review.noChanges")
|
||||||
|
})
|
||||||
|
|
||||||
|
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
||||||
|
if (store.changes === "git" || store.changes === "branch") {
|
||||||
|
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||||
|
return empty(reviewEmptyText())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (store.changes === "turn") {
|
||||||
|
if (nogit()) return createGit(input)
|
||||||
|
return empty(reviewEmptyText())
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export namespace ShareNext {
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "session_diff"
|
type: "session_diff"
|
||||||
data: SDK.FileDiff[]
|
data: SDK.SnapshotFileDiff[]
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "model"
|
type: "model"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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("")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
lineAnnotations: annotations,
|
||||||
fileContainer: fileDiffRef,
|
fileContainer: fileDiffRef,
|
||||||
containerWrapper: container,
|
containerWrapper: container,
|
||||||
})
|
prerenderedHTML: local.preloadedDiff.prerenderedHTML,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
oldFile: local.before,
|
||||||
|
newFile: local.after,
|
||||||
|
lineAnnotations: annotations,
|
||||||
|
fileContainer: fileDiffRef,
|
||||||
|
containerWrapper: container,
|
||||||
|
prerenderedHTML: local.preloadedDiff.prerenderedHTML,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,14 +81,28 @@ 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"
|
||||||
|
annotations?: DiffLineAnnotation<T>[]
|
||||||
|
preloadedDiff?: DiffPreload<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiffPairProps<T> = DiffBaseProps<T> & {
|
||||||
before: FileContents
|
before: FileContents
|
||||||
after: FileContents
|
after: FileContents
|
||||||
annotations?: DiffLineAnnotation<T>[]
|
fileDiff?: undefined
|
||||||
preloadedDiff?: PreloadMultiFileDiffResult<T>
|
}
|
||||||
}
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
|
@ -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) },
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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("")
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>[]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue