summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-26 18:23:04 -0600
committerGitHub <[email protected]>2026-02-26 18:23:04 -0600
commitfc52e4b2d3a41efde772e6de8fb2e01f27821701 (patch)
treecf23af294a00a10e55f230232585344c111f0bb9 /packages
parent9a6bfeb782766099d4ce3a98bb9e7b4e79f8bfe6 (diff)
downloadopencode-fc52e4b2d3a41efde772e6de8fb2e01f27821701.tar.gz
opencode-fc52e4b2d3a41efde772e6de8fb2e01f27821701.zip
feat(app): better diff/code comments (#14621)
Co-authored-by: adamelmore <[email protected]> Co-authored-by: David Hill <[email protected]>
Diffstat (limited to 'packages')
-rw-r--r--packages/app/e2e/files/file-tree.spec.ts6
-rw-r--r--packages/app/e2e/files/file-viewer.spec.ts60
-rw-r--r--packages/app/src/app.tsx10
-rw-r--r--packages/app/src/components/prompt-input.tsx100
-rw-r--r--packages/app/src/components/prompt-input/build-request-parts.test.ts9
-rw-r--r--packages/app/src/components/prompt-input/build-request-parts.ts22
-rw-r--r--packages/app/src/components/prompt-input/history.test.ts52
-rw-r--r--packages/app/src/components/prompt-input/history.ts125
-rw-r--r--packages/app/src/components/session/session-context-tab.tsx5
-rw-r--r--packages/app/src/context/comments.test.ts33
-rw-r--r--packages/app/src/context/comments.tsx55
-rw-r--r--packages/app/src/context/file/view-cache.ts2
-rw-r--r--packages/app/src/context/layout-scroll.test.ts20
-rw-r--r--packages/app/src/context/layout-scroll.ts12
-rw-r--r--packages/app/src/context/prompt.tsx28
-rw-r--r--packages/app/src/pages/session.tsx60
-rw-r--r--packages/app/src/pages/session/file-tabs.tsx461
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx120
-rw-r--r--packages/app/src/pages/session/review-tab.tsx99
-rw-r--r--packages/app/src/utils/comment-note.ts88
-rw-r--r--packages/enterprise/src/routes/share/[shareID].tsx433
-rw-r--r--packages/ui/src/components/code.css4
-rw-r--r--packages/ui/src/components/code.tsx1097
-rw-r--r--packages/ui/src/components/diff-ssr.tsx317
-rw-r--r--packages/ui/src/components/diff.tsx652
-rw-r--r--packages/ui/src/components/file-media.tsx265
-rw-r--r--packages/ui/src/components/file-search.tsx69
-rw-r--r--packages/ui/src/components/file-ssr.tsx178
-rw-r--r--packages/ui/src/components/file.css (renamed from packages/ui/src/components/diff.css)9
-rw-r--r--packages/ui/src/components/file.tsx1176
-rw-r--r--packages/ui/src/components/line-comment-annotations.tsx586
-rw-r--r--packages/ui/src/components/line-comment-styles.ts (renamed from packages/ui/src/components/line-comment.css)114
-rw-r--r--packages/ui/src/components/line-comment.tsx192
-rw-r--r--packages/ui/src/components/message-part.tsx21
-rw-r--r--packages/ui/src/components/session-review-search.test.ts39
-rw-r--r--packages/ui/src/components/session-review-search.ts59
-rw-r--r--packages/ui/src/components/session-review.css44
-rw-r--r--packages/ui/src/components/session-review.tsx847
-rw-r--r--packages/ui/src/components/session-turn.tsx7
-rw-r--r--packages/ui/src/context/diff.tsx10
-rw-r--r--packages/ui/src/context/file.tsx (renamed from packages/ui/src/context/code.tsx)6
-rw-r--r--packages/ui/src/context/index.ts2
-rw-r--r--packages/ui/src/i18n/ar.ts9
-rw-r--r--packages/ui/src/i18n/br.ts9
-rw-r--r--packages/ui/src/i18n/bs.ts9
-rw-r--r--packages/ui/src/i18n/da.ts9
-rw-r--r--packages/ui/src/i18n/de.ts11
-rw-r--r--packages/ui/src/i18n/en.ts10
-rw-r--r--packages/ui/src/i18n/es.ts9
-rw-r--r--packages/ui/src/i18n/fr.ts9
-rw-r--r--packages/ui/src/i18n/ja.ts9
-rw-r--r--packages/ui/src/i18n/ko.ts9
-rw-r--r--packages/ui/src/i18n/no.ts9
-rw-r--r--packages/ui/src/i18n/pl.ts9
-rw-r--r--packages/ui/src/i18n/ru.ts9
-rw-r--r--packages/ui/src/i18n/th.ts9
-rw-r--r--packages/ui/src/i18n/zh.ts9
-rw-r--r--packages/ui/src/i18n/zht.ts9
-rw-r--r--packages/ui/src/pierre/comment-hover.ts74
-rw-r--r--packages/ui/src/pierre/commented-lines.ts91
-rw-r--r--packages/ui/src/pierre/diff-selection.ts71
-rw-r--r--packages/ui/src/pierre/file-find.ts576
-rw-r--r--packages/ui/src/pierre/file-runtime.ts114
-rw-r--r--packages/ui/src/pierre/file-selection.ts85
-rw-r--r--packages/ui/src/pierre/index.ts42
-rw-r--r--packages/ui/src/pierre/media.ts110
-rw-r--r--packages/ui/src/pierre/selection-bridge.ts129
-rw-r--r--packages/ui/src/styles/index.css4
68 files changed, 5790 insertions, 3147 deletions
diff --git a/packages/app/e2e/files/file-tree.spec.ts b/packages/app/e2e/files/file-tree.spec.ts
index 321d96af5..44efb7f00 100644
--- a/packages/app/e2e/files/file-tree.spec.ts
+++ b/packages/app/e2e/files/file-tree.spec.ts
@@ -43,7 +43,7 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession
await tab.click()
await expect(tab).toHaveAttribute("aria-selected", "true")
- const code = page.locator('[data-component="code"]').first()
- await expect(code).toBeVisible()
- await expect(code).toContainText("export default function FileTree")
+ const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
+ await expect(viewer).toBeVisible()
+ await expect(viewer).toContainText("export default function FileTree")
})
diff --git a/packages/app/e2e/files/file-viewer.spec.ts b/packages/app/e2e/files/file-viewer.spec.ts
index b968acc13..bee67c7d1 100644
--- a/packages/app/e2e/files/file-viewer.spec.ts
+++ b/packages/app/e2e/files/file-viewer.spec.ts
@@ -1,5 +1,6 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
+import { modKey } from "../utils"
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
await gotoSession()
@@ -43,7 +44,60 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
await expect(tab).toBeVisible()
await tab.click()
- const code = page.locator('[data-component="code"]').first()
- await expect(code).toBeVisible()
- await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
+ const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
+ await expect(viewer).toBeVisible()
+ await expect(viewer.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
+})
+
+test("cmd+f opens text viewer search while prompt is focused", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ await page.locator(promptSelector).click()
+ await page.keyboard.type("/open")
+
+ const command = page.locator('[data-slash-id="file.open"]').first()
+ await expect(command).toBeVisible()
+ await page.keyboard.press("Enter")
+
+ const dialog = page
+ .getByRole("dialog")
+ .filter({ has: page.getByPlaceholder(/search files/i) })
+ .first()
+ await expect(dialog).toBeVisible()
+
+ const input = dialog.getByRole("textbox").first()
+ await input.fill("package.json")
+
+ const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
+ let index = -1
+ await expect
+ .poll(
+ async () => {
+ const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
+ index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
+ return index >= 0
+ },
+ { timeout: 30_000 },
+ )
+ .toBe(true)
+
+ const item = items.nth(index)
+ await expect(item).toBeVisible()
+ await item.click()
+
+ await expect(dialog).toHaveCount(0)
+
+ const tab = page.getByRole("tab", { name: "package.json" })
+ await expect(tab).toBeVisible()
+ await tab.click()
+
+ const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
+ await expect(viewer).toBeVisible()
+
+ await page.locator(promptSelector).click()
+ await page.keyboard.press(`${modKey}+f`)
+
+ const findInput = page.getByPlaceholder("Find")
+ await expect(findInput).toBeVisible()
+ await expect(findInput).toBeFocused()
})
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 1be9f38d7..4a25e8d94 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -1,11 +1,9 @@
import "@/index.css"
-import { Code } from "@opencode-ai/ui/code"
+import { File } from "@opencode-ai/ui/file"
import { I18nProvider } from "@opencode-ai/ui/context"
-import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
-import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
+import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
-import { Diff } from "@opencode-ai/ui/diff"
import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta"
@@ -122,9 +120,7 @@ export function AppBaseProviders(props: ParentProps) {
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProviderWithNativeParser>
- <DiffComponentProvider component={Diff}>
- <CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
- </DiffComponentProvider>
+ <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProviderWithNativeParser>
</DialogProvider>
</ErrorBoundary>
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 9174133ac..85aa16384 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -3,7 +3,7 @@ import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo
import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
-import { useFile } from "@/context/file"
+import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
import {
ContentPart,
DEFAULT_PROMPT,
@@ -43,6 +43,9 @@ import {
canNavigateHistoryAtCursor,
navigatePromptHistory,
prependHistoryEntry,
+ type PromptHistoryComment,
+ type PromptHistoryEntry,
+ type PromptHistoryStoredEntry,
promptLength,
} from "./prompt-input/history"
import { createPromptSubmit } from "./prompt-input/submit"
@@ -170,12 +173,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const focus = { file: item.path, id: item.commentID }
comments.setActive(focus)
+ const queueCommentFocus = (attempts = 6) => {
+ const schedule = (left: number) => {
+ requestAnimationFrame(() => {
+ comments.setFocus({ ...focus })
+ if (left <= 0) return
+ requestAnimationFrame(() => {
+ const current = comments.focus()
+ if (!current) return
+ if (current.file !== focus.file || current.id !== focus.id) return
+ schedule(left - 1)
+ })
+ })
+ }
+
+ schedule(attempts)
+ }
+
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
if (wantsReview) {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("changes")
tabs().setActive("review")
- requestAnimationFrame(() => comments.setFocus(focus))
+ queueCommentFocus()
return
}
@@ -183,8 +203,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
layout.fileTree.setTab("all")
const tab = files.tab(item.path)
tabs().open(tab)
- files.load(item.path)
- requestAnimationFrame(() => comments.setFocus(focus))
+ tabs().setActive(tab)
+ Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus())
}
const recent = createMemo(() => {
@@ -219,7 +239,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [store, setStore] = createStore<{
popover: "at" | "slash" | null
historyIndex: number
- savedPrompt: Prompt | null
+ savedPrompt: PromptHistoryEntry | null
placeholder: number
draggingType: "image" | "@mention" | null
mode: "normal" | "shell"
@@ -227,7 +247,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}>({
popover: null,
historyIndex: -1,
- savedPrompt: null,
+ savedPrompt: null as PromptHistoryEntry | null,
placeholder: Math.floor(Math.random() * EXAMPLES.length),
draggingType: null,
mode: "normal",
@@ -256,7 +276,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [history, setHistory] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<{
- entries: Prompt[]
+ entries: PromptHistoryStoredEntry[]
}>({
entries: [],
}),
@@ -264,7 +284,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [shellHistory, setShellHistory] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<{
- entries: Prompt[]
+ entries: PromptHistoryStoredEntry[]
}>({
entries: [],
}),
@@ -282,9 +302,66 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}),
)
- const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
+ const historyComments = () => {
+ const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
+ return prompt.context.items().flatMap((item) => {
+ if (item.type !== "file") return []
+ const comment = item.comment?.trim()
+ if (!comment) return []
+
+ const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
+ const nextSelection =
+ selection ??
+ (item.selection
+ ? ({
+ start: item.selection.startLine,
+ end: item.selection.endLine,
+ } satisfies SelectedLineRange)
+ : undefined)
+ if (!nextSelection) return []
+
+ return [
+ {
+ id: item.commentID ?? item.key,
+ path: item.path,
+ selection: { ...nextSelection },
+ comment,
+ time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
+ origin: item.commentOrigin,
+ preview: item.preview,
+ } satisfies PromptHistoryComment,
+ ]
+ })
+ }
+
+ const applyHistoryComments = (items: PromptHistoryComment[]) => {
+ comments.replace(
+ items.map((item) => ({
+ id: item.id,
+ file: item.path,
+ selection: { ...item.selection },
+ comment: item.comment,
+ time: item.time,
+ })),
+ )
+ prompt.context.replaceComments(
+ items.map((item) => ({
+ type: "file" as const,
+ path: item.path,
+ selection: selectionFromLines(item.selection),
+ comment: item.comment,
+ commentID: item.id,
+ commentOrigin: item.origin,
+ preview: item.preview,
+ })),
+ )
+ }
+
+ const applyHistoryPrompt = (entry: PromptHistoryEntry, position: "start" | "end") => {
+ const p = entry.prompt
const length = position === "start" ? 0 : promptLength(p)
setStore("applyingHistory", true)
+ applyHistoryComments(entry.comments)
prompt.set(p, length)
requestAnimationFrame(() => {
editorRef.focus()
@@ -846,7 +923,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
const currentHistory = mode === "shell" ? shellHistory : history
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
- const next = prependHistoryEntry(currentHistory.entries, prompt)
+ const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments())
if (next === currentHistory.entries) return
setCurrentHistory("entries", next)
}
@@ -857,12 +934,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
entries: store.mode === "shell" ? shellHistory.entries : history.entries,
historyIndex: store.historyIndex,
currentPrompt: prompt.current(),
+ currentComments: historyComments(),
savedPrompt: store.savedPrompt,
})
if (!result.handled) return false
setStore("historyIndex", result.historyIndex)
setStore("savedPrompt", result.savedPrompt)
- applyHistoryPrompt(result.prompt, result.cursor)
+ applyHistoryPrompt(result.entry, result.cursor)
return true
}
diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts
index 72bdecc01..4c2e2d8be 100644
--- a/packages/app/src/components/prompt-input/build-request-parts.test.ts
+++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts
@@ -35,6 +35,15 @@ describe("buildRequestParts", () => {
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
).toBe(true)
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
+ expect(
+ result.requestParts.some(
+ (part) =>
+ part.type === "text" &&
+ part.synthetic &&
+ part.metadata?.opencodeComment &&
+ (part.metadata.opencodeComment as { comment?: string }).comment === "check this",
+ ),
+ ).toBe(true)
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts
index 0cc54dc2b..4146fb484 100644
--- a/packages/app/src/components/prompt-input/build-request-parts.ts
+++ b/packages/app/src/components/prompt-input/build-request-parts.ts
@@ -4,6 +4,7 @@ import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id"
+import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
@@ -41,18 +42,6 @@ const fileQuery = (selection: FileSelection | undefined) =>
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
-const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
- const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
- const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
- const range =
- start === undefined || end === undefined
- ? "this file"
- : start === end
- ? `line ${start}`
- : `lines ${start} through ${end}`
- return `The user made the following comment regarding ${range} of ${path}: ${comment}`
-}
-
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
if (part.type === "text") {
return {
@@ -153,8 +142,15 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
{
id: Identifier.ascending("part"),
type: "text",
- text: commentNote(item.path, item.selection, comment),
+ text: formatCommentNote({ path: item.path, selection: item.selection, comment }),
synthetic: true,
+ metadata: createCommentMetadata({
+ path: item.path,
+ selection: item.selection,
+ comment,
+ preview: item.preview,
+ origin: item.commentOrigin,
+ }),
} satisfies PromptRequestPart,
filePart,
]
diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts
index b7a4f896b..37b5ce196 100644
--- a/packages/app/src/components/prompt-input/history.test.ts
+++ b/packages/app/src/components/prompt-input/history.test.ts
@@ -3,25 +3,42 @@ import type { Prompt } from "@/context/prompt"
import {
canNavigateHistoryAtCursor,
clonePromptParts,
+ normalizePromptHistoryEntry,
navigatePromptHistory,
prependHistoryEntry,
promptLength,
+ type PromptHistoryComment,
} from "./history"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
+const comment = (id: string, value = "note"): PromptHistoryComment => ({
+ id,
+ path: "src/a.ts",
+ selection: { start: 2, end: 4 },
+ comment: value,
+ time: 1,
+ origin: "review",
+ preview: "const a = 1",
+})
describe("prompt-input history", () => {
test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
const first = prependHistoryEntry([], DEFAULT_PROMPT)
expect(first).toEqual([])
+ const commentsOnly = prependHistoryEntry([], DEFAULT_PROMPT, [comment("c1")])
+ expect(commentsOnly).toHaveLength(1)
+
const withOne = prependHistoryEntry([], text("hello"))
expect(withOne).toHaveLength(1)
const deduped = prependHistoryEntry(withOne, text("hello"))
expect(deduped).toBe(withOne)
+
+ const dedupedComments = prependHistoryEntry(commentsOnly, DEFAULT_PROMPT, [comment("c1")])
+ expect(dedupedComments).toBe(commentsOnly)
})
test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
@@ -31,24 +48,57 @@ describe("prompt-input history", () => {
entries,
historyIndex: -1,
currentPrompt: text("draft"),
+ currentComments: [comment("draft")],
savedPrompt: null,
})
expect(up.handled).toBe(true)
if (!up.handled) throw new Error("expected handled")
expect(up.historyIndex).toBe(0)
expect(up.cursor).toBe("start")
+ expect(up.entry.comments).toEqual([])
const down = navigatePromptHistory({
direction: "down",
entries,
historyIndex: up.historyIndex,
currentPrompt: text("ignored"),
+ currentComments: [],
savedPrompt: up.savedPrompt,
})
expect(down.handled).toBe(true)
if (!down.handled) throw new Error("expected handled")
expect(down.historyIndex).toBe(-1)
- expect(down.prompt[0]?.type === "text" ? down.prompt[0].content : "").toBe("draft")
+ expect(down.entry.prompt[0]?.type === "text" ? down.entry.prompt[0].content : "").toBe("draft")
+ expect(down.entry.comments).toEqual([comment("draft")])
+ })
+
+ test("navigatePromptHistory keeps entry comments when moving through history", () => {
+ const entries = [
+ {
+ prompt: text("with comment"),
+ comments: [comment("c1")],
+ },
+ ]
+
+ const up = navigatePromptHistory({
+ direction: "up",
+ entries,
+ historyIndex: -1,
+ currentPrompt: text("draft"),
+ currentComments: [],
+ savedPrompt: null,
+ })
+
+ expect(up.handled).toBe(true)
+ if (!up.handled) throw new Error("expected handled")
+ expect(up.entry.prompt[0]?.type === "text" ? up.entry.prompt[0].content : "").toBe("with comment")
+ expect(up.entry.comments).toEqual([comment("c1")])
+ })
+
+ test("normalizePromptHistoryEntry supports legacy prompt arrays", () => {
+ const entry = normalizePromptHistoryEntry(text("legacy"))
+ expect(entry.prompt[0]?.type === "text" ? entry.prompt[0].content : "").toBe("legacy")
+ expect(entry.comments).toEqual([])
})
test("helpers clone prompt and count text content length", () => {
diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts
index c279a3ed5..de6265321 100644
--- a/packages/app/src/components/prompt-input/history.ts
+++ b/packages/app/src/components/prompt-input/history.ts
@@ -1,9 +1,27 @@
import type { Prompt } from "@/context/prompt"
+import type { SelectedLineRange } from "@/context/file"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export const MAX_HISTORY = 100
+export type PromptHistoryComment = {
+ id: string
+ path: string
+ selection: SelectedLineRange
+ comment: string
+ time: number
+ origin?: "review" | "file"
+ preview?: string
+}
+
+export type PromptHistoryEntry = {
+ prompt: Prompt
+ comments: PromptHistoryComment[]
+}
+
+export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry
+
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
const position = Math.max(0, Math.min(cursor, text.length))
const atStart = position === 0
@@ -25,29 +43,82 @@ export function clonePromptParts(prompt: Prompt): Prompt {
})
}
+function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
+ return {
+ start: selection.start,
+ end: selection.end,
+ ...(selection.side ? { side: selection.side } : {}),
+ ...(selection.endSide ? { endSide: selection.endSide } : {}),
+ }
+}
+
+export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
+ return comments.map((comment) => ({
+ ...comment,
+ selection: cloneSelection(comment.selection),
+ }))
+}
+
+export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
+ if (Array.isArray(entry)) {
+ return {
+ prompt: clonePromptParts(entry),
+ comments: [],
+ }
+ }
+ return {
+ prompt: clonePromptParts(entry.prompt),
+ comments: clonePromptHistoryComments(entry.comments),
+ }
+}
+
export function promptLength(prompt: Prompt) {
return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
}
-export function prependHistoryEntry(entries: Prompt[], prompt: Prompt, max = MAX_HISTORY) {
+export function prependHistoryEntry(
+ entries: PromptHistoryStoredEntry[],
+ prompt: Prompt,
+ comments: PromptHistoryComment[] = [],
+ max = MAX_HISTORY,
+) {
const text = prompt
.map((part) => ("content" in part ? part.content : ""))
.join("")
.trim()
const hasImages = prompt.some((part) => part.type === "image")
- if (!text && !hasImages) return entries
+ const hasComments = comments.some((comment) => !!comment.comment.trim())
+ if (!text && !hasImages && !hasComments) return entries
- const entry = clonePromptParts(prompt)
+ const entry = {
+ prompt: clonePromptParts(prompt),
+ comments: clonePromptHistoryComments(comments),
+ } satisfies PromptHistoryEntry
const last = entries[0]
if (last && isPromptEqual(last, entry)) return entries
return [entry, ...entries].slice(0, max)
}
-function isPromptEqual(promptA: Prompt, promptB: Prompt) {
- if (promptA.length !== promptB.length) return false
- for (let i = 0; i < promptA.length; i++) {
- const partA = promptA[i]
- const partB = promptB[i]
+function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) {
+ return (
+ commentA.path === commentB.path &&
+ commentA.comment === commentB.comment &&
+ commentA.origin === commentB.origin &&
+ commentA.preview === commentB.preview &&
+ commentA.selection.start === commentB.selection.start &&
+ commentA.selection.end === commentB.selection.end &&
+ commentA.selection.side === commentB.selection.side &&
+ commentA.selection.endSide === commentB.selection.endSide
+ )
+}
+
+function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) {
+ const entryA = normalizePromptHistoryEntry(promptA)
+ const entryB = normalizePromptHistoryEntry(promptB)
+ if (entryA.prompt.length !== entryB.prompt.length) return false
+ for (let i = 0; i < entryA.prompt.length; i++) {
+ const partA = entryA.prompt[i]
+ const partB = entryB.prompt[i]
if (partA.type !== partB.type) return false
if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false
if (partA.type === "file") {
@@ -67,28 +138,35 @@ function isPromptEqual(promptA: Prompt, promptB: Prompt) {
if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
}
+ if (entryA.comments.length !== entryB.comments.length) return false
+ for (let i = 0; i < entryA.comments.length; i++) {
+ const commentA = entryA.comments[i]
+ const commentB = entryB.comments[i]
+ if (!commentA || !commentB || !isCommentEqual(commentA, commentB)) return false
+ }
return true
}
type HistoryNavInput = {
direction: "up" | "down"
- entries: Prompt[]
+ entries: PromptHistoryStoredEntry[]
historyIndex: number
currentPrompt: Prompt
- savedPrompt: Prompt | null
+ currentComments: PromptHistoryComment[]
+ savedPrompt: PromptHistoryEntry | null
}
type HistoryNavResult =
| {
handled: false
historyIndex: number
- savedPrompt: Prompt | null
+ savedPrompt: PromptHistoryEntry | null
}
| {
handled: true
historyIndex: number
- savedPrompt: Prompt | null
- prompt: Prompt
+ savedPrompt: PromptHistoryEntry | null
+ entry: PromptHistoryEntry
cursor: "start" | "end"
}
@@ -103,22 +181,27 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
}
if (input.historyIndex === -1) {
+ const entry = normalizePromptHistoryEntry(input.entries[0])
return {
handled: true,
historyIndex: 0,
- savedPrompt: clonePromptParts(input.currentPrompt),
- prompt: input.entries[0],
+ savedPrompt: {
+ prompt: clonePromptParts(input.currentPrompt),
+ comments: clonePromptHistoryComments(input.currentComments),
+ },
+ entry,
cursor: "start",
}
}
if (input.historyIndex < input.entries.length - 1) {
const next = input.historyIndex + 1
+ const entry = normalizePromptHistoryEntry(input.entries[next])
return {
handled: true,
historyIndex: next,
savedPrompt: input.savedPrompt,
- prompt: input.entries[next],
+ entry,
cursor: "start",
}
}
@@ -132,11 +215,12 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
if (input.historyIndex > 0) {
const next = input.historyIndex - 1
+ const entry = normalizePromptHistoryEntry(input.entries[next])
return {
handled: true,
historyIndex: next,
savedPrompt: input.savedPrompt,
- prompt: input.entries[next],
+ entry,
cursor: "end",
}
}
@@ -147,7 +231,7 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
handled: true,
historyIndex: -1,
savedPrompt: null,
- prompt: input.savedPrompt,
+ entry: input.savedPrompt,
cursor: "end",
}
}
@@ -156,7 +240,10 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
handled: true,
historyIndex: -1,
savedPrompt: null,
- prompt: DEFAULT_PROMPT,
+ entry: {
+ prompt: DEFAULT_PROMPT,
+ comments: [],
+ },
cursor: "end",
}
}
diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx
index 1ea97c395..582aa3391 100644
--- a/packages/app/src/components/session/session-context-tab.tsx
+++ b/packages/app/src/components/session/session-context-tab.tsx
@@ -9,7 +9,7 @@ import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
-import { Code } from "@opencode-ai/ui/code"
+import { File } from "@opencode-ai/ui/file"
import { Markdown } from "@opencode-ai/ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
@@ -47,7 +47,8 @@ function RawMessageContent(props: { message: Message; getParts: (id: string) =>
})
return (
- <Code
+ <File
+ mode="text"
file={file()}
overflow="wrap"
class="select-text"
diff --git a/packages/app/src/context/comments.test.ts b/packages/app/src/context/comments.test.ts
index bee5c7871..82fa170f2 100644
--- a/packages/app/src/context/comments.test.ts
+++ b/packages/app/src/context/comments.test.ts
@@ -150,4 +150,37 @@ describe("comments session indexing", () => {
dispose()
})
})
+
+ test("update changes only the targeted comment body", () => {
+ createRoot((dispose) => {
+ const comments = createCommentSessionForTest({
+ "a.ts": [line("a.ts", "a1", 10), line("a.ts", "a2", 20)],
+ })
+
+ comments.update("a.ts", "a2", "edited")
+
+ expect(comments.list("a.ts").map((item) => item.comment)).toEqual(["a1", "edited"])
+
+ dispose()
+ })
+ })
+
+ test("replace swaps comment state and clears focus state", () => {
+ createRoot((dispose) => {
+ const comments = createCommentSessionForTest({
+ "a.ts": [line("a.ts", "a1", 10)],
+ })
+
+ comments.setFocus({ file: "a.ts", id: "a1" })
+ comments.setActive({ file: "a.ts", id: "a1" })
+ comments.replace([line("b.ts", "b1", 30)])
+
+ expect(comments.list("a.ts")).toEqual([])
+ expect(comments.list("b.ts").map((item) => item.id)).toEqual(["b1"])
+ expect(comments.focus()).toBeNull()
+ expect(comments.active()).toBeNull()
+
+ dispose()
+ })
+ })
})
diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx
index ecf63e45b..a97010c0a 100644
--- a/packages/app/src/context/comments.tsx
+++ b/packages/app/src/context/comments.tsx
@@ -44,6 +44,37 @@ function aggregate(comments: Record<string, LineComment[]>) {
.sort((a, b) => a.time - b.time)
}
+function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
+ const next: SelectedLineRange = {
+ start: selection.start,
+ end: selection.end,
+ }
+
+ if (selection.side) next.side = selection.side
+ if (selection.endSide) next.endSide = selection.endSide
+ return next
+}
+
+function cloneComment(comment: LineComment): LineComment {
+ return {
+ ...comment,
+ selection: cloneSelection(comment.selection),
+ }
+}
+
+function group(comments: LineComment[]) {
+ return comments.reduce<Record<string, LineComment[]>>((acc, comment) => {
+ const list = acc[comment.file]
+ const next = cloneComment(comment)
+ if (list) {
+ list.push(next)
+ return acc
+ }
+ acc[comment.file] = [next]
+ return acc
+ }, {})
+}
+
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
const [state, setState] = createStore({
focus: null as CommentFocus | null,
@@ -70,6 +101,7 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
id: uuid(),
time: Date.now(),
...input,
+ selection: cloneSelection(input.selection),
}
batch(() => {
@@ -87,6 +119,23 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
})
}
+ const update = (file: string, id: string, comment: string) => {
+ setStore("comments", file, (items) =>
+ (items ?? []).map((item) => {
+ if (item.id !== id) return item
+ return { ...item, comment }
+ }),
+ )
+ }
+
+ const replace = (comments: LineComment[]) => {
+ batch(() => {
+ setStore("comments", reconcile(group(comments)))
+ setFocus(null)
+ setActive(null)
+ })
+ }
+
const clear = () => {
batch(() => {
setStore("comments", reconcile({}))
@@ -100,6 +149,8 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
all,
add,
remove,
+ update,
+ replace,
clear,
focus: () => state.focus,
setFocus,
@@ -132,6 +183,8 @@ function createCommentSession(dir: string, id: string | undefined) {
all: session.all,
add: session.add,
remove: session.remove,
+ update: session.update,
+ replace: session.replace,
clear: session.clear,
focus: session.focus,
setFocus: session.setFocus,
@@ -176,6 +229,8 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
all: () => session().all(),
add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
remove: (file: string, id: string) => session().remove(file, id),
+ update: (file: string, id: string, comment: string) => session().update(file, id, comment),
+ replace: (comments: LineComment[]) => session().replace(comments),
clear: () => session().clear(),
focus: () => session().focus(),
setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
diff --git a/packages/app/src/context/file/view-cache.ts b/packages/app/src/context/file/view-cache.ts
index 6e8ddf62d..4c060174a 100644
--- a/packages/app/src/context/file/view-cache.ts
+++ b/packages/app/src/context/file/view-cache.ts
@@ -9,7 +9,7 @@ const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
- if (range.start <= range.end) return range
+ if (range.start <= range.end) return { ...range }
const startSide = range.side
const endSide = range.endSide ?? startSide
diff --git a/packages/app/src/context/layout-scroll.test.ts b/packages/app/src/context/layout-scroll.test.ts
index 2a13e4020..483be150f 100644
--- a/packages/app/src/context/layout-scroll.test.ts
+++ b/packages/app/src/context/layout-scroll.test.ts
@@ -41,4 +41,24 @@ describe("createScrollPersistence", () => {
vi.useRealTimers()
}
})
+
+ test("reseeds empty cache after persisted snapshot loads", () => {
+ const snapshot = {
+ session: {},
+ } as Record<string, Record<string, { x: number; y: number }>>
+
+ const scroll = createScrollPersistence({
+ getSnapshot: (sessionKey) => snapshot[sessionKey],
+ onFlush: () => {},
+ })
+
+ expect(scroll.scroll("session", "review")).toBeUndefined()
+
+ snapshot.session = {
+ review: { x: 12, y: 34 },
+ }
+
+ expect(scroll.scroll("session", "review")).toEqual({ x: 12, y: 34 })
+ scroll.dispose()
+ })
})
diff --git a/packages/app/src/context/layout-scroll.ts b/packages/app/src/context/layout-scroll.ts
index 30b0f6904..ef66eccd9 100644
--- a/packages/app/src/context/layout-scroll.ts
+++ b/packages/app/src/context/layout-scroll.ts
@@ -33,8 +33,16 @@ export function createScrollPersistence(opts: Options) {
}
function seed(sessionKey: string) {
- if (cache[sessionKey]) return
- setCache(sessionKey, clone(opts.getSnapshot(sessionKey)))
+ const next = clone(opts.getSnapshot(sessionKey))
+ const current = cache[sessionKey]
+ if (!current) {
+ setCache(sessionKey, next)
+ return
+ }
+
+ if (Object.keys(current).length > 0) return
+ if (Object.keys(next).length === 0) return
+ setCache(sessionKey, next)
}
function scroll(sessionKey: string, tab: string) {
diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx
index 064892105..fb8226559 100644
--- a/packages/app/src/context/prompt.tsx
+++ b/packages/app/src/context/prompt.tsx
@@ -116,6 +116,10 @@ function contextItemKey(item: ContextItem) {
return `${key}:c=${digest.slice(0, 8)}`
}
+function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
+ return item.type === "file" && !!item.comment?.trim()
+}
+
function createPromptActions(
setStore: SetStoreFunction<{
prompt: Prompt
@@ -189,6 +193,26 @@ function createPromptSession(dir: string, id: string | undefined) {
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
+ removeComment(path: string, commentID: string) {
+ setStore("context", "items", (items) =>
+ items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
+ )
+ },
+ updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
+ setStore("context", "items", (items) =>
+ items.map((item) => {
+ if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
+ const value = { ...item, ...next }
+ return { ...value, key: contextItemKey(value) }
+ }),
+ )
+ },
+ replaceComments(items: FileContextItem[]) {
+ setStore("context", "items", (current) => [
+ ...current.filter((item) => !isCommentItem(item)),
+ ...items.map((item) => ({ ...item, key: contextItemKey(item) })),
+ ])
+ },
},
set: actions.set,
reset: actions.reset,
@@ -251,6 +275,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
items: () => session().context.items(),
add: (item: ContextItem) => session().context.add(item),
remove: (key: string) => session().context.remove(key),
+ removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID),
+ updateComment: (path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) =>
+ session().context.updateComment(path, commentID, next),
+ replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
},
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
reset: () => session().reset(),
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 75bd988f8..0d2718efb 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -379,11 +379,58 @@ export default function Page() {
})
}
+ const updateCommentInContext = (input: {
+ id: string
+ file: string
+ selection: SelectedLineRange
+ comment: string
+ preview?: string
+ }) => {
+ comments.update(input.file, input.id, input.comment)
+ prompt.context.updateComment(input.file, input.id, {
+ comment: input.comment,
+ ...(input.preview ? { preview: input.preview } : {}),
+ })
+ }
+
+ const removeCommentFromContext = (input: { id: string; file: string }) => {
+ comments.remove(input.file, input.id)
+ prompt.context.removeComment(input.file, input.id)
+ }
+
+ const reviewCommentActions = createMemo(() => ({
+ moreLabel: language.t("common.moreOptions"),
+ editLabel: language.t("common.edit"),
+ deleteLabel: language.t("common.delete"),
+ saveLabel: language.t("common.save"),
+ }))
+
+ const isEditableTarget = (target: EventTarget | null | undefined) => {
+ if (!(target instanceof HTMLElement)) return false
+ return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
+ }
+
+ const deepActiveElement = () => {
+ let current: Element | null = document.activeElement
+ while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
+ current = current.shadowRoot.activeElement
+ }
+ return current instanceof HTMLElement ? current : undefined
+ }
+
const handleKeyDown = (event: KeyboardEvent) => {
- const activeElement = document.activeElement as HTMLElement | undefined
+ const path = event.composedPath()
+ const target = path.find((item): item is HTMLElement => item instanceof HTMLElement)
+ const activeElement = deepActiveElement()
+
+ const protectedTarget = path.some(
+ (item) => item instanceof HTMLElement && item.closest("[data-prevent-autofocus]") !== null,
+ )
+ if (protectedTarget || isEditableTarget(target)) return
+
if (activeElement) {
const isProtected = activeElement.closest("[data-prevent-autofocus]")
- const isInput = /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(activeElement.tagName) || activeElement.isContentEditable
+ const isInput = isEditableTarget(activeElement)
if (isProtected || isInput) return
}
if (dialog.active) return
@@ -500,6 +547,9 @@ export default function Page() {
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+ onLineCommentUpdate={updateCommentInContext}
+ onLineCommentDelete={removeCommentFromContext}
+ lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
@@ -521,6 +571,9 @@ export default function Page() {
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+ onLineCommentUpdate={updateCommentInContext}
+ onLineCommentDelete={removeCommentFromContext}
+ lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
@@ -549,6 +602,9 @@ export default function Page() {
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+ onLineCommentUpdate={updateCommentInContext}
+ onLineCommentDelete={removeCommentFromContext}
+ lineCommentActions={reviewCommentActions()}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx
index 4b30915d8..e92eee670 100644
--- a/packages/app/src/pages/session/file-tabs.tsx
+++ b/packages/app/src/pages/session/file-tabs.tsx
@@ -1,15 +1,17 @@
-import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
-import { createStore, produce } from "solid-js/store"
+import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js"
+import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { useParams } from "@solidjs/router"
-import { useCodeComponent } from "@opencode-ai/ui/context/code"
+import type { FileSearchHandle } from "@opencode-ai/ui/file"
+import { useFileComponent } from "@opencode-ai/ui/context/file"
+import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
+import { createLineCommentController } from "@opencode-ai/ui/line-comment-annotations"
import { sampledChecksum } from "@opencode-ai/util/encode"
-import { decode64 } from "@/utils/base64"
-import { showToast } from "@opencode-ai/ui/toast"
-import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
-import { Mark } from "@opencode-ai/ui/logo"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
+import { showToast } from "@opencode-ai/ui/toast"
import { useLayout } from "@/context/layout"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useComments } from "@/context/comments"
@@ -17,11 +19,37 @@ import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { getSessionHandoff } from "@/pages/session/handoff"
-const formatCommentLabel = (range: SelectedLineRange) => {
- const start = Math.min(range.start, range.end)
- const end = Math.max(range.start, range.end)
- if (start === end) return `line ${start}`
- return `lines ${start}-${end}`
+function FileCommentMenu(props: {
+ moreLabel: string
+ editLabel: string
+ deleteLabel: string
+ onEdit: VoidFunction
+ onDelete: VoidFunction
+}) {
+ return (
+ <div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
+ <DropdownMenu gutter={4} placement="bottom-end">
+ <DropdownMenu.Trigger
+ as={IconButton}
+ icon="dot-grid"
+ variant="ghost"
+ size="small"
+ class="size-6 rounded-md"
+ aria-label={props.moreLabel}
+ />
+ <DropdownMenu.Portal>
+ <DropdownMenu.Content>
+ <DropdownMenu.Item onSelect={props.onEdit}>
+ <DropdownMenu.ItemLabel>{props.editLabel}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Item onSelect={props.onDelete}>
+ <DropdownMenu.ItemLabel>{props.deleteLabel}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ </DropdownMenu.Content>
+ </DropdownMenu.Portal>
+ </DropdownMenu>
+ </div>
+ )
}
export function FileTabContent(props: { tab: string }) {
@@ -31,7 +59,7 @@ export function FileTabContent(props: { tab: string }) {
const comments = useComments()
const language = useLanguage()
const prompt = usePrompt()
- const codeComponent = useCodeComponent()
+ const fileComponent = useFileComponent()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
@@ -41,6 +69,13 @@ export function FileTabContent(props: { tab: string }) {
let scrollFrame: number | undefined
let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = []
+ let find: FileSearchHandle | null = null
+
+ const search = {
+ register: (handle: FileSearchHandle | null) => {
+ find = handle
+ },
+ }
const path = createMemo(() => file.pathFromTab(props.tab))
const state = createMemo(() => {
@@ -50,66 +85,18 @@ export function FileTabContent(props: { tab: string }) {
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => sampledChecksum(contents()))
- const isImage = createMemo(() => {
- const c = state()?.content
- return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
- })
- const isSvg = createMemo(() => {
- const c = state()?.content
- return c?.mimeType === "image/svg+xml"
- })
- const isBinary = createMemo(() => state()?.content?.type === "binary")
- const svgContent = createMemo(() => {
- if (!isSvg()) return
- const c = state()?.content
- if (!c) return
- if (c.encoding !== "base64") return c.content
- return decode64(c.content)
- })
-
- const svgDecodeFailed = createMemo(() => {
- if (!isSvg()) return false
- const c = state()?.content
- if (!c) return false
- if (c.encoding !== "base64") return false
- return svgContent() === undefined
- })
-
- const svgToast = { shown: false }
- createEffect(() => {
- if (!svgDecodeFailed()) return
- if (svgToast.shown) return
- svgToast.shown = true
- showToast({
- variant: "error",
- title: language.t("toast.file.loadFailed.title"),
- })
- })
- const svgPreviewUrl = createMemo(() => {
- if (!isSvg()) return
- const c = state()?.content
- if (!c) return
- if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
- return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
- })
- const imageDataUrl = createMemo(() => {
- if (!isImage()) return
- const c = state()?.content
- return `data:${c?.mimeType};base64,${c?.content}`
- })
- const selectedLines = createMemo(() => {
+ const selectedLines = createMemo<SelectedLineRange | null>(() => {
const p = path()
if (!p) return null
- if (file.ready()) return file.selectedLines(p) ?? null
- return getSessionHandoff(sessionKey())?.files[p] ?? null
+ if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
+ return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
})
const selectionPreview = (source: string, selection: FileSelection) => {
- const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
- const end = Math.max(selection.startLine, selection.endLine)
- const lines = source.split("\n").slice(start - 1, end)
- if (lines.length === 0) return undefined
- return lines.slice(0, 2).join("\n")
+ return previewSelectedLines(source, {
+ start: selection.startLine,
+ end: selection.endLine,
+ })
}
const addCommentToContext = (input: {
@@ -145,7 +132,25 @@ export function FileTabContent(props: { tab: string }) {
})
}
- let wrap: HTMLDivElement | undefined
+ const updateCommentInContext = (input: {
+ id: string
+ file: string
+ selection: SelectedLineRange
+ comment: string
+ }) => {
+ comments.update(input.file, input.id, input.comment)
+ const preview =
+ input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined
+ prompt.context.updateComment(input.file, input.id, {
+ comment: input.comment,
+ ...(preview ? { preview } : {}),
+ })
+ }
+
+ const removeCommentFromContext = (input: { id: string; file: string }) => {
+ comments.remove(input.file, input.id)
+ prompt.context.removeComment(input.file, input.id)
+ }
const fileComments = createMemo(() => {
const p = path()
@@ -153,121 +158,105 @@ export function FileTabContent(props: { tab: string }) {
return comments.list(p)
})
- const commentLayout = createMemo(() => {
- return fileComments()
- .map((comment) => `${comment.id}:${comment.selection.start}:${comment.selection.end}`)
- .join("|")
- })
-
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
const [note, setNote] = createStore({
openedComment: null as string | null,
commenting: null as SelectedLineRange | null,
- draft: "",
- positions: {} as Record<string, number>,
- draftTop: undefined as number | undefined,
+ selected: null as SelectedLineRange | null,
})
- const setCommenting = (range: SelectedLineRange | null) => {
- setNote("commenting", range)
- scheduleComments()
- if (!range) return
- setNote("draft", "")
- }
-
- const getRoot = () => {
- const el = wrap
- if (!el) return
-
- const host = el.querySelector("diffs-container")
- if (!(host instanceof HTMLElement)) return
-
- const root = host.shadowRoot
- if (!root) return
-
- return root
- }
-
- const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
- const line = Math.max(range.start, range.end)
- const node = root.querySelector(`[data-line="${line}"]`)
- if (!(node instanceof HTMLElement)) return
- return node
- }
-
- const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
- const wrapperRect = wrapper.getBoundingClientRect()
- const rect = marker.getBoundingClientRect()
- return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
+ const syncSelected = (range: SelectedLineRange | null) => {
+ const p = path()
+ if (!p) return
+ file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null)
}
- const updateComments = () => {
- const el = wrap
- const root = getRoot()
- if (!el || !root) {
- setNote("positions", {})
- setNote("draftTop", undefined)
- return
- }
-
- const estimateTop = (range: SelectedLineRange) => {
- const line = Math.max(range.start, range.end)
- const height = 24
- const offset = 2
- return Math.max(0, (line - 1) * height + offset)
- }
-
- const large = contents().length > 500_000
+ const activeSelection = () => note.selected ?? selectedLines()
+
+ const commentsUi = createLineCommentController({
+ comments: fileComments,
+ label: language.t("ui.lineComment.submit"),
+ draftKey: () => path() ?? props.tab,
+ state: {
+ opened: () => note.openedComment,
+ setOpened: (id) => setNote("openedComment", id),
+ selected: () => note.selected,
+ setSelected: (range) => setNote("selected", range),
+ commenting: () => note.commenting,
+ setCommenting: (range) => setNote("commenting", range),
+ syncSelected,
+ hoverSelected: syncSelected,
+ },
+ getHoverSelectedRange: activeSelection,
+ cancelDraftOnCommentToggle: true,
+ clearSelectionOnSelectionEndNull: true,
+ onSubmit: ({ comment, selection }) => {
+ const p = path()
+ if (!p) return
+ addCommentToContext({ file: p, selection, comment, origin: "file" })
+ },
+ onUpdate: ({ id, comment, selection }) => {
+ const p = path()
+ if (!p) return
+ updateCommentInContext({ id, file: p, selection, comment })
+ },
+ onDelete: (comment) => {
+ const p = path()
+ if (!p) return
+ removeCommentFromContext({ id: comment.id, file: p })
+ },
+ editSubmitLabel: language.t("common.save"),
+ renderCommentActions: (_, controls) => (
+ <FileCommentMenu
+ moreLabel={language.t("common.moreOptions")}
+ editLabel={language.t("common.edit")}
+ deleteLabel={language.t("common.delete")}
+ onEdit={controls.edit}
+ onDelete={controls.remove}
+ />
+ ),
+ onDraftPopoverFocusOut: (e: FocusEvent) => {
+ const current = e.currentTarget as HTMLDivElement
+ const target = e.relatedTarget
+ if (target instanceof Node && current.contains(target)) return
+
+ setTimeout(() => {
+ if (!document.activeElement || !current.contains(document.activeElement)) {
+ setNote("commenting", null)
+ }
+ }, 0)
+ },
+ })
- const next: Record<string, number> = {}
- for (const comment of fileComments()) {
- const marker = findMarker(root, comment.selection)
- if (marker) next[comment.id] = markerTop(el, marker)
- else if (large) next[comment.id] = estimateTop(comment.selection)
- }
+ createEffect(() => {
+ if (typeof window === "undefined") return
- const removed = Object.keys(note.positions).filter((id) => next[id] === undefined)
- const changed = Object.entries(next).filter(([id, top]) => note.positions[id] !== top)
- if (removed.length > 0 || changed.length > 0) {
- setNote(
- "positions",
- produce((draft) => {
- for (const id of removed) {
- delete draft[id]
- }
-
- for (const [id, top] of changed) {
- draft[id] = top
- }
- }),
- )
- }
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.defaultPrevented) return
+ if (tabs().active() !== props.tab) return
+ if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return
+ if (event.key.toLowerCase() !== "f") return
- const range = note.commenting
- if (!range) {
- setNote("draftTop", undefined)
- return
+ event.preventDefault()
+ event.stopPropagation()
+ find?.focus()
}
- const marker = findMarker(root, range)
- if (marker) {
- setNote("draftTop", markerTop(el, marker))
- return
- }
-
- setNote("draftTop", large ? estimateTop(range) : undefined)
- }
-
- const scheduleComments = () => {
- requestAnimationFrame(updateComments)
- }
-
- createEffect(() => {
- commentLayout()
- scheduleComments()
+ window.addEventListener("keydown", onKeyDown, { capture: true })
+ onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true }))
})
+ createEffect(
+ on(
+ path,
+ () => {
+ commentsUi.note.reset()
+ },
+ { defer: true },
+ ),
+ )
+
createEffect(() => {
const focus = comments.focus()
const p = path()
@@ -278,9 +267,7 @@ export function FileTabContent(props: { tab: string }) {
const target = fileComments().find((comment) => comment.id === focus.id)
if (!target) return
- setNote("openedComment", target.id)
- setCommenting(null)
- file.setSelectedLines(p, target.selection)
+ commentsUi.note.openComment(target.id, target.selection, { cancelDraft: true })
requestAnimationFrame(() => comments.clearFocus())
})
@@ -419,99 +406,50 @@ export function FileTabContent(props: { tab: string }) {
cancelAnimationFrame(scrollFrame)
})
- const renderCode = (source: string, wrapperClass: string) => (
- <div
- ref={(el) => {
- wrap = el
- scheduleComments()
- }}
- class={`relative overflow-hidden ${wrapperClass}`}
- >
+ const renderFile = (source: string) => (
+ <div class="relative overflow-hidden pb-40">
<Dynamic
- component={codeComponent}
+ component={fileComponent}
+ mode="text"
file={{
name: path() ?? "",
contents: source,
cacheKey: cacheKey(),
}}
enableLineSelection
- selectedLines={selectedLines()}
+ enableHoverUtility
+ selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
requestAnimationFrame(restoreScroll)
- requestAnimationFrame(scheduleComments)
}}
+ annotations={commentsUi.annotations()}
+ renderAnnotation={commentsUi.renderAnnotation}
+ renderHoverUtility={commentsUi.renderHoverUtility}
onLineSelected={(range: SelectedLineRange | null) => {
- const p = path()
- if (!p) return
- file.setSelectedLines(p, range)
- if (!range) setCommenting(null)
+ commentsUi.onLineSelected(range)
}}
+ onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
- if (!range) {
- setCommenting(null)
- return
- }
-
- setNote("openedComment", null)
- setCommenting(range)
+ commentsUi.onLineSelectionEnd(range)
}}
+ search={search}
overflow="scroll"
class="select-text"
+ media={{
+ mode: "auto",
+ path: path(),
+ current: state()?.content,
+ onLoad: () => requestAnimationFrame(restoreScroll),
+ onError: (args: { kind: "image" | "audio" | "svg" }) => {
+ if (args.kind !== "svg") return
+ showToast({
+ variant: "error",
+ title: language.t("toast.file.loadFailed.title"),
+ })
+ },
+ }}
/>
- <For each={fileComments()}>
- {(comment) => (
- <LineCommentView
- id={comment.id}
- top={note.positions[comment.id]}
- open={note.openedComment === comment.id}
- comment={comment.comment}
- selection={formatCommentLabel(comment.selection)}
- onMouseEnter={() => {
- const p = path()
- if (!p) return
- file.setSelectedLines(p, comment.selection)
- }}
- onClick={() => {
- const p = path()
- if (!p) return
- setCommenting(null)
- setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
- file.setSelectedLines(p, comment.selection)
- }}
- />
- )}
- </For>
- <Show when={note.commenting}>
- {(range) => (
- <Show when={note.draftTop !== undefined}>
- <LineCommentEditor
- top={note.draftTop}
- value={note.draft}
- selection={formatCommentLabel(range())}
- onInput={(value) => setNote("draft", value)}
- onCancel={cancelCommenting}
- onSubmit={(value) => {
- const p = path()
- if (!p) return
- addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" })
- setCommenting(null)
- }}
- onPopoverFocusOut={(e: FocusEvent) => {
- const current = e.currentTarget as HTMLDivElement
- const target = e.relatedTarget
- if (target instanceof Node && current.contains(target)) return
-
- setTimeout(() => {
- if (!document.activeElement || !current.contains(document.activeElement)) {
- cancelCommenting()
- }
- }, 0)
- }}
- />
- </Show>
- )}
- </Show>
</div>
)
@@ -526,36 +464,7 @@ export function FileTabContent(props: { tab: string }) {
onScroll={handleScroll as any}
>
<Switch>
- <Match when={state()?.loaded && isImage()}>
- <div class="px-6 py-4 pb-40">
- <img
- src={imageDataUrl()}
- alt={path()}
- class="max-w-full"
- onLoad={() => requestAnimationFrame(restoreScroll)}
- />
- </div>
- </Match>
- <Match when={state()?.loaded && isSvg()}>
- <div class="flex flex-col gap-4 px-6 py-4">
- {renderCode(svgContent() ?? "", "")}
- <Show when={svgPreviewUrl()}>
- <div class="flex justify-center pb-40">
- <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
- </div>
- </Show>
- </div>
- </Match>
- <Match when={state()?.loaded && isBinary()}>
- <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
- <Mark class="w-14 opacity-10" />
- <div class="flex flex-col gap-2 max-w-md">
- <div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
- <div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
- </div>
- </div>
- </Match>
- <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
+ <Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
</Match>
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index b84109035..8215f31ba 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -2,6 +2,7 @@ import { For, createEffect, createMemo, on, onCleanup, Show, type JSX } from "so
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
@@ -9,8 +10,9 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
-import type { UserMessage } from "@opencode-ai/sdk/v2"
+import type { Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
+import { getFilename } from "@opencode-ai/util/path"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -18,6 +20,35 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
+import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
+
+type MessageComment = {
+ path: string
+ comment: string
+ selection?: {
+ startLine: number
+ endLine: number
+ }
+}
+
+const messageComments = (parts: Part[]): MessageComment[] =>
+ parts.flatMap((part) => {
+ if (part.type !== "text" || !(part as TextPart).synthetic) return []
+ const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text)
+ if (!next) return []
+ return [
+ {
+ path: next.path,
+ comment: next.comment,
+ selection: next.selection
+ ? {
+ startLine: next.selection.startLine,
+ endLine: next.selection.endLine,
+ }
+ : undefined,
+ },
+ ]
+ })
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
const current = target instanceof Element ? target : undefined
@@ -522,34 +553,67 @@ export function MessageTimeline(props: {
</div>
</Show>
<For each={props.renderedUserMessages}>
- {(message) => (
- <div
- id={props.anchor(message.id)}
- data-message-id={message.id}
- ref={(el) => {
- props.onRegisterMessage(el, message.id)
- onCleanup(() => props.onUnregisterMessage(message.id))
- }}
- classList={{
- "min-w-0 w-full max-w-full": true,
- "md:max-w-200 2xl:max-w-[1000px]": props.centered,
- }}
- >
- <SessionTurn
- sessionID={sessionID() ?? ""}
- messageID={message.id}
- lastUserMessageID={props.lastUserMessageID}
- showReasoningSummaries={settings.general.showReasoningSummaries()}
- shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
- editToolDefaultOpen={settings.general.editToolPartsExpanded()}
- classes={{
- root: "min-w-0 w-full relative",
- content: "flex flex-col justify-between !overflow-visible",
- container: "w-full px-4 md:px-5",
+ {(message) => {
+ const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? []))
+ return (
+ <div
+ id={props.anchor(message.id)}
+ data-message-id={message.id}
+ ref={(el) => {
+ props.onRegisterMessage(el, message.id)
+ onCleanup(() => props.onUnregisterMessage(message.id))
}}
- />
- </div>
- )}
+ classList={{
+ "min-w-0 w-full max-w-full": true,
+ "md:max-w-200 2xl:max-w-[1000px]": props.centered,
+ }}
+ >
+ <Show when={comments().length > 0}>
+ <div class="w-full px-4 md:px-5 pb-2">
+ <div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
+ <div class="flex w-max min-w-full justify-end gap-2">
+ <For each={comments()}>
+ {(comment) => (
+ <div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
+ <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
+ <FileIcon node={{ path: comment.path, type: "file" }} class="size-3.5 shrink-0" />
+ <span class="truncate">{getFilename(comment.path)}</span>
+ <Show when={comment.selection}>
+ {(selection) => (
+ <span class="shrink-0 text-text-weak">
+ {selection().startLine === selection().endLine
+ ? `:${selection().startLine}`
+ : `:${selection().startLine}-${selection().endLine}`}
+ </span>
+ )}
+ </Show>
+ </div>
+ <div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
+ {comment.comment}
+ </div>
+ </div>
+ )}
+ </For>
+ </div>
+ </div>
+ </div>
+ </Show>
+ <SessionTurn
+ sessionID={sessionID() ?? ""}
+ messageID={message.id}
+ lastUserMessageID={props.lastUserMessageID}
+ showReasoningSummaries={settings.general.showReasoningSummaries()}
+ shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
+ editToolDefaultOpen={settings.general.editToolPartsExpanded()}
+ classes={{
+ root: "min-w-0 w-full relative",
+ content: "flex flex-col justify-between !overflow-visible",
+ container: "w-full px-4 md:px-5",
+ }}
+ />
+ </div>
+ )
+ }}
</For>
</div>
</ScrollView>
diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx
index fd2f3b2bd..7f90ff5ac 100644
--- a/packages/app/src/pages/session/review-tab.tsx
+++ b/packages/app/src/pages/session/review-tab.tsx
@@ -1,6 +1,11 @@
import { createEffect, on, onCleanup, type JSX } from "solid-js"
import type { FileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/ui/session-review"
+import type {
+ SessionReviewCommentActions,
+ SessionReviewCommentDelete,
+ SessionReviewCommentUpdate,
+} from "@opencode-ai/ui/session-review"
import type { SelectedLineRange } from "@/context/file"
import { useSDK } from "@/context/sdk"
import { useLayout } from "@/context/layout"
@@ -17,6 +22,9 @@ export interface SessionReviewTabProps {
onDiffStyleChange?: (style: DiffStyle) => void
onViewFile?: (file: string) => void
onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
+ onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
+ onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
+ lineCommentActions?: SessionReviewCommentActions
comments?: LineComment[]
focusedComment?: { file: string; id: string } | null
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
@@ -39,10 +47,11 @@ export function StickyAddButton(props: { children: JSX.Element }) {
export function SessionReviewTab(props: SessionReviewTabProps) {
let scroll: HTMLDivElement | undefined
- let frame: number | undefined
- let pending: { x: number; y: number } | undefined
+ let restoreFrame: number | undefined
+ let userInteracted = false
const sdk = useSDK()
+ const layout = useLayout()
const readFile = async (path: string) => {
return sdk.client.file
@@ -54,48 +63,81 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
})
}
- const restoreScroll = () => {
+ const handleInteraction = () => {
+ userInteracted = true
+ }
+
+ const doRestore = () => {
+ restoreFrame = undefined
const el = scroll
- if (!el) return
+ if (!el || !layout.ready() || userInteracted) return
+ if (el.clientHeight === 0 || el.clientWidth === 0) return
const s = props.view().scroll("review")
- if (!s) return
+ if (!s || (s.x === 0 && s.y === 0)) return
+
+ const maxY = Math.max(0, el.scrollHeight - el.clientHeight)
+ const maxX = Math.max(0, el.scrollWidth - el.clientWidth)
- if (el.scrollTop !== s.y) el.scrollTop = s.y
- if (el.scrollLeft !== s.x) el.scrollLeft = s.x
+ const targetY = Math.min(s.y, maxY)
+ const targetX = Math.min(s.x, maxX)
+
+ if (el.scrollTop !== targetY) el.scrollTop = targetY
+ if (el.scrollLeft !== targetX) el.scrollLeft = targetX
}
- const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
- pending = {
- x: event.currentTarget.scrollLeft,
- y: event.currentTarget.scrollTop,
- }
- if (frame !== undefined) return
+ const queueRestore = () => {
+ if (userInteracted || restoreFrame !== undefined) return
+ restoreFrame = requestAnimationFrame(doRestore)
+ }
- frame = requestAnimationFrame(() => {
- frame = undefined
+ const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
+ if (!layout.ready() || !userInteracted) return
- const next = pending
- pending = undefined
- if (!next) return
+ const el = event.currentTarget
+ if (el.clientHeight === 0 || el.clientWidth === 0) return
- props.view().setScroll("review", next)
+ props.view().setScroll("review", {
+ x: el.scrollLeft,
+ y: el.scrollTop,
})
}
createEffect(
on(
() => props.diffs().length,
- () => {
- requestAnimationFrame(restoreScroll)
+ () => queueRestore(),
+ { defer: true },
+ ),
+ )
+
+ createEffect(
+ on(
+ () => props.diffStyle,
+ () => queueRestore(),
+ { defer: true },
+ ),
+ )
+
+ createEffect(
+ on(
+ () => layout.ready(),
+ (ready) => {
+ if (!ready) return
+ queueRestore()
},
{ defer: true },
),
)
onCleanup(() => {
- if (frame === undefined) return
- cancelAnimationFrame(frame)
+ if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
+ if (scroll) {
+ scroll.removeEventListener("wheel", handleInteraction)
+ scroll.removeEventListener("pointerdown", handleInteraction)
+ scroll.removeEventListener("touchstart", handleInteraction)
+ scroll.removeEventListener("keydown", handleInteraction)
+ }
})
return (
@@ -104,11 +146,15 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
empty={props.empty}
scrollRef={(el) => {
scroll = el
+ el.addEventListener("wheel", handleInteraction, { passive: true, capture: true })
+ el.addEventListener("pointerdown", handleInteraction, { passive: true, capture: true })
+ el.addEventListener("touchstart", handleInteraction, { passive: true, capture: true })
+ el.addEventListener("keydown", handleInteraction, { passive: true, capture: true })
props.onScrollRef?.(el)
- restoreScroll()
+ queueRestore()
}}
onScroll={handleScroll}
- onDiffRendered={() => requestAnimationFrame(restoreScroll)}
+ onDiffRendered={queueRestore}
open={props.view().review.open()}
onOpenChange={props.view().review.setOpen}
classes={{
@@ -123,6 +169,9 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
focusedFile={props.focusedFile}
readFile={readFile}
onLineComment={props.onLineComment}
+ onLineCommentUpdate={props.onLineCommentUpdate}
+ onLineCommentDelete={props.onLineCommentDelete}
+ lineCommentActions={props.lineCommentActions}
comments={props.comments}
focusedComment={props.focusedComment}
onFocusedCommentChange={props.onFocusedCommentChange}
diff --git a/packages/app/src/utils/comment-note.ts b/packages/app/src/utils/comment-note.ts
new file mode 100644
index 000000000..99e87fc81
--- /dev/null
+++ b/packages/app/src/utils/comment-note.ts
@@ -0,0 +1,88 @@
+import type { FileSelection } from "@/context/file"
+
+export type PromptComment = {
+ path: string
+ selection?: FileSelection
+ comment: string
+ preview?: string
+ origin?: "review" | "file"
+}
+
+function selection(selection: unknown) {
+ if (!selection || typeof selection !== "object") return undefined
+ const startLine = Number((selection as FileSelection).startLine)
+ const startChar = Number((selection as FileSelection).startChar)
+ const endLine = Number((selection as FileSelection).endLine)
+ const endChar = Number((selection as FileSelection).endChar)
+ if (![startLine, startChar, endLine, endChar].every(Number.isFinite)) return undefined
+ return {
+ startLine,
+ startChar,
+ endLine,
+ endChar,
+ } satisfies FileSelection
+}
+
+export function createCommentMetadata(input: PromptComment) {
+ return {
+ opencodeComment: {
+ path: input.path,
+ selection: input.selection,
+ comment: input.comment,
+ preview: input.preview,
+ origin: input.origin,
+ },
+ }
+}
+
+export function readCommentMetadata(value: unknown) {
+ if (!value || typeof value !== "object") return
+ const meta = (value as { opencodeComment?: unknown }).opencodeComment
+ if (!meta || typeof meta !== "object") return
+ const path = (meta as { path?: unknown }).path
+ const comment = (meta as { comment?: unknown }).comment
+ if (typeof path !== "string" || typeof comment !== "string") return
+ const preview = (meta as { preview?: unknown }).preview
+ const origin = (meta as { origin?: unknown }).origin
+ return {
+ path,
+ selection: selection((meta as { selection?: unknown }).selection),
+ comment,
+ preview: typeof preview === "string" ? preview : undefined,
+ origin: origin === "review" || origin === "file" ? origin : undefined,
+ } satisfies PromptComment
+}
+
+export function formatCommentNote(input: { path: string; selection?: FileSelection; comment: string }) {
+ const start = input.selection ? Math.min(input.selection.startLine, input.selection.endLine) : undefined
+ const end = input.selection ? Math.max(input.selection.startLine, input.selection.endLine) : undefined
+ const range =
+ start === undefined || end === undefined
+ ? "this file"
+ : start === end
+ ? `line ${start}`
+ : `lines ${start} through ${end}`
+ return `The user made the following comment regarding ${range} of ${input.path}: ${input.comment}`
+}
+
+export function parseCommentNote(text: string) {
+ const match = text.match(
+ /^The user made the following comment regarding (this file|line (\d+)|lines (\d+) through (\d+)) of (.+?): ([\s\S]+)$/,
+ )
+ if (!match) return
+ const start = match[2] ? Number(match[2]) : match[3] ? Number(match[3]) : undefined
+ const end = match[2] ? Number(match[2]) : match[4] ? Number(match[4]) : undefined
+ return {
+ path: match[5],
+ selection:
+ start !== undefined && end !== undefined
+ ? {
+ startLine: start,
+ startChar: 0,
+ endLine: end,
+ endChar: 0,
+ }
+ : undefined,
+ comment: match[6],
+ } satisfies PromptComment
+}
diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx
index eb830e4a6..ada543b7d 100644
--- a/packages/enterprise/src/routes/share/[shareID].tsx
+++ b/packages/enterprise/src/routes/share/[shareID].tsx
@@ -2,8 +2,7 @@ import { FileDiff, Message, Model, Part, Session, SessionStatus, UserMessage } f
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { DataProvider } from "@opencode-ai/ui/context"
-import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
-import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
+import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
import { createAsync, query, useParams } from "@solidjs/router"
import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
@@ -22,14 +21,12 @@ import NotFound from "../[...404]"
import { Tabs } from "@opencode-ai/ui/tabs"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
-import { Diff as SSRDiff } from "@opencode-ai/ui/diff-ssr"
+import { FileSSR } from "@opencode-ai/ui/file-ssr"
import { clientOnly } from "@solidjs/start"
import { type IconName } from "@opencode-ai/ui/icons/provider"
import { Meta, Title } from "@solidjs/meta"
import { Base64 } from "js-base64"
-const ClientOnlyDiff = clientOnly(() => import("@opencode-ai/ui/diff").then((m) => ({ default: m.Diff })))
-const ClientOnlyCode = clientOnly(() => import("@opencode-ai/ui/code").then((m) => ({ default: m.Code })))
const ClientOnlyWorkerPoolProvider = clientOnly(() =>
import("@opencode-ai/ui/pierre/worker").then((m) => ({
default: (props: { children: any }) => (
@@ -218,252 +215,244 @@ export default function () {
<Meta property="og:image" content={ogImage()} />
<Meta name="twitter:image" content={ogImage()} />
<ClientOnlyWorkerPoolProvider>
- <DiffComponentProvider component={ClientOnlyDiff}>
- <CodeComponentProvider component={ClientOnlyCode}>
- <DataProvider data={data()} directory={info().directory}>
- {iife(() => {
- const [store, setStore] = createStore({
- messageId: undefined as string | undefined,
- })
- const messages = createMemo(() =>
- data().sessionID
- ? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
- (a, b) => a.time.created - b.time.created,
- )
- : [],
- )
- const firstUserMessage = createMemo(() => messages().at(0))
- const activeMessage = createMemo(
- () => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
- )
- function setActiveMessage(message: UserMessage | undefined) {
- if (message) {
- setStore("messageId", message.id)
- } else {
- setStore("messageId", undefined)
- }
+ <FileComponentProvider component={FileSSR}>
+ <DataProvider data={data()} directory={info().directory}>
+ {iife(() => {
+ const [store, setStore] = createStore({
+ messageId: undefined as string | undefined,
+ })
+ const messages = createMemo(() =>
+ data().sessionID
+ ? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
+ (a, b) => a.time.created - b.time.created,
+ )
+ : [],
+ )
+ const firstUserMessage = createMemo(() => messages().at(0))
+ const activeMessage = createMemo(
+ () => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
+ )
+ function setActiveMessage(message: UserMessage | undefined) {
+ if (message) {
+ setStore("messageId", message.id)
+ } else {
+ setStore("messageId", undefined)
}
- const provider = createMemo(() => activeMessage()?.model?.providerID)
- const modelID = createMemo(() => activeMessage()?.model?.modelID)
- const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
- const diffs = createMemo(() => {
- const diffs = data().session_diff[data().sessionID] ?? []
- const preloaded = data().session_diff_preload[data().sessionID] ?? []
- return diffs.map((diff) => ({
- ...diff,
- preloaded: preloaded.find((d) => d.newFile.name === diff.file),
- }))
- })
- const splitDiffs = createMemo(() => {
- const diffs = data().session_diff[data().sessionID] ?? []
- const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
- return diffs.map((diff) => ({
- ...diff,
- preloaded: preloaded.find((d) => d.newFile.name === diff.file),
- }))
- })
+ }
+ const provider = createMemo(() => activeMessage()?.model?.providerID)
+ const modelID = createMemo(() => activeMessage()?.model?.modelID)
+ const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
+ const diffs = createMemo(() => {
+ const diffs = data().session_diff[data().sessionID] ?? []
+ const preloaded = data().session_diff_preload[data().sessionID] ?? []
+ return diffs.map((diff) => ({
+ ...diff,
+ preloaded: preloaded.find((d) => d.newFile.name === diff.file),
+ }))
+ })
+ const splitDiffs = createMemo(() => {
+ const diffs = data().session_diff[data().sessionID] ?? []
+ const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
+ return diffs.map((diff) => ({
+ ...diff,
+ preloaded: preloaded.find((d) => d.newFile.name === diff.file),
+ }))
+ })
- const title = () => (
- <div class="flex flex-col gap-4">
- <div class="flex flex-col gap-2 sm:flex-row sm:gap-4 sm:items-center sm:h-8 justify-start self-stretch">
- <div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base w-fit">
- <Mark class="shrink-0 w-3 my-0.5" />
- <div class="text-12-mono text-text-base">v{info().version}</div>
+ const title = () => (
+ <div class="flex flex-col gap-4">
+ <div class="flex flex-col gap-2 sm:flex-row sm:gap-4 sm:items-center sm:h-8 justify-start self-stretch">
+ <div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base w-fit">
+ <Mark class="shrink-0 w-3 my-0.5" />
+ <div class="text-12-mono text-text-base">v{info().version}</div>
+ </div>
+ <div class="flex gap-4 items-center">
+ <div class="flex gap-2 items-center">
+ <ProviderIcon
+ id={provider() as IconName}
+ class="size-3.5 shrink-0 text-icon-strong-base"
+ />
+ <div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
</div>
- <div class="flex gap-4 items-center">
- <div class="flex gap-2 items-center">
- <ProviderIcon
- id={provider() as IconName}
- class="size-3.5 shrink-0 text-icon-strong-base"
- />
- <div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
- </div>
- <div class="text-12-regular text-text-weaker">
- {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
- </div>
+ <div class="text-12-regular text-text-weaker">
+ {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
</div>
</div>
- <div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
- )
+ <div class="text-left text-16-medium text-text-strong">{info().title}</div>
+ </div>
+ )
- const turns = () => (
- <div class="relative mt-2 pb-8 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
- <div class="px-4 py-6">{title()}</div>
- <div class="flex flex-col gap-15 items-start justify-start mt-4">
- <For each={messages()}>
- {(message) => (
- <SessionTurn
- sessionID={data().sessionID}
- messageID={message.id}
- classes={{
- root: "min-w-0 w-full relative",
- content: "flex flex-col justify-between !overflow-visible",
- container: "px-4",
- }}
- />
- )}
- </For>
- </div>
- <div class="px-4 flex items-center justify-center pt-20 pb-8 shrink-0">
- <Logo class="w-58.5 opacity-12" />
- </div>
+ const turns = () => (
+ <div class="relative mt-2 pb-8 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
+ <div class="px-4 py-6">{title()}</div>
+ <div class="flex flex-col gap-15 items-start justify-start mt-4">
+ <For each={messages()}>
+ {(message) => (
+ <SessionTurn
+ sessionID={data().sessionID}
+ messageID={message.id}
+ classes={{
+ root: "min-w-0 w-full relative",
+ content: "flex flex-col justify-between !overflow-visible",
+ container: "px-4",
+ }}
+ />
+ )}
+ </For>
</div>
- )
+ <div class="px-4 flex items-center justify-center pt-20 pb-8 shrink-0">
+ <Logo class="w-58.5 opacity-12" />
+ </div>
+ </div>
+ )
- const wide = createMemo(() => diffs().length === 0)
+ const wide = createMemo(() => diffs().length === 0)
- return (
- <div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
- <header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
- <div class="">
- <a href="https://opencode.ai">
- <Mark />
- </a>
- </div>
- <div class="flex gap-3 items-center">
- <IconButton
- as={"a"}
- href="https://github.com/anomalyco/opencode"
- target="_blank"
- icon="github"
- variant="ghost"
- />
- <IconButton
- as={"a"}
- href="https://opencode.ai/discord"
- target="_blank"
- icon="discord"
- variant="ghost"
- />
- </div>
- </header>
- <div class="select-text flex flex-col flex-1 min-h-0">
+ return (
+ <div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
+ <header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
+ <div class="">
+ <a href="https://opencode.ai">
+ <Mark />
+ </a>
+ </div>
+ <div class="flex gap-3 items-center">
+ <IconButton
+ as={"a"}
+ href="https://github.com/anomalyco/opencode"
+ target="_blank"
+ icon="github"
+ variant="ghost"
+ />
+ <IconButton
+ as={"a"}
+ href="https://opencode.ai/discord"
+ target="_blank"
+ icon="discord"
+ variant="ghost"
+ />
+ </div>
+ </header>
+ <div class="select-text flex flex-col flex-1 min-h-0">
+ <div
+ classList={{
+ "hidden w-full flex-1 min-h-0": true,
+ "md:flex": wide(),
+ "lg:flex": !wide(),
+ }}
+ >
<div
classList={{
- "hidden w-full flex-1 min-h-0": true,
- "md:flex": wide(),
- "lg:flex": !wide(),
+ "@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
}}
>
<div
classList={{
- "@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
+ "w-full flex justify-start items-start min-w-0 px-6": true,
}}
>
- <div
- classList={{
- "w-full flex justify-start items-start min-w-0 px-6": true,
+ {title()}
+ </div>
+ <div class="flex items-start justify-start h-full min-h-0">
+ <Show when={messages().length > 1}>
+ <MessageNav
+ class="sticky top-0 shrink-0 py-2 pl-4"
+ messages={messages()}
+ current={activeMessage()}
+ size="compact"
+ onMessageSelect={setActiveMessage}
+ />
+ </Show>
+ <SessionTurn
+ sessionID={data().sessionID}
+ messageID={store.messageId ?? firstUserMessage()!.id!}
+ classes={{
+ root: "grow",
+ content: "flex flex-col justify-between",
+ container: "w-full pb-20 px-6",
}}
>
- {title()}
- </div>
- <div class="flex items-start justify-start h-full min-h-0">
- <Show when={messages().length > 1}>
- <MessageNav
- class="sticky top-0 shrink-0 py-2 pl-4"
- messages={messages()}
- current={activeMessage()}
- size="compact"
- onMessageSelect={setActiveMessage}
- />
- </Show>
- <SessionTurn
- sessionID={data().sessionID}
- messageID={store.messageId ?? firstUserMessage()!.id!}
- classes={{
- root: "grow",
- content: "flex flex-col justify-between",
- container: "w-full pb-20 px-6",
- }}
- >
- <div
- classList={{ "w-full flex items-center justify-center pb-8 shrink-0": true }}
- >
- <Logo class="w-58.5 opacity-12" />
- </div>
- </SessionTurn>
- </div>
+ <div classList={{ "w-full flex items-center justify-center pb-8 shrink-0": true }}>
+ <Logo class="w-58.5 opacity-12" />
+ </div>
+ </SessionTurn>
</div>
- <Show when={diffs().length > 0}>
- <DiffComponentProvider component={SSRDiff}>
- <div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
+ </div>
+ <Show when={diffs().length > 0}>
+ <div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
+ <SessionReview
+ class="@4xl:hidden"
+ diffs={diffs()}
+ classes={{
+ root: "pb-20",
+ header: "px-6",
+ container: "px-6",
+ }}
+ />
+ <SessionReview
+ split
+ class="hidden @4xl:flex"
+ diffs={splitDiffs()}
+ classes={{
+ root: "pb-20",
+ header: "px-6",
+ container: "px-6",
+ }}
+ />
+ </div>
+ </Show>
+ </div>
+ <Switch>
+ <Match when={diffs().length > 0}>
+ <Tabs classList={{ "md:hidden": wide(), "lg:hidden": !wide() }}>
+ <Tabs.List>
+ <Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
+ Session
+ </Tabs.Trigger>
+ <Tabs.Trigger
+ value="review"
+ class="w-1/2 !border-r-0"
+ classes={{ button: "w-full" }}
+ >
+ {diffs().length} Files Changed
+ </Tabs.Trigger>
+ </Tabs.List>
+ <Tabs.Content value="session" class="!overflow-hidden">
+ {turns()}
+ </Tabs.Content>
+ <Tabs.Content
+ forceMount
+ value="review"
+ class="!overflow-hidden hidden data-[selected]:block"
+ >
+ <div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
<SessionReview
- class="@4xl:hidden"
diffs={diffs()}
classes={{
root: "pb-20",
- header: "px-6",
- container: "px-6",
- }}
- />
- <SessionReview
- split
- class="hidden @4xl:flex"
- diffs={splitDiffs()}
- classes={{
- root: "pb-20",
- header: "px-6",
- container: "px-6",
+ header: "px-4",
+ container: "px-4",
}}
/>
</div>
- </DiffComponentProvider>
- </Show>
- </div>
- <Switch>
- <Match when={diffs().length > 0}>
- <Tabs classList={{ "md:hidden": wide(), "lg:hidden": !wide() }}>
- <Tabs.List>
- <Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
- Session
- </Tabs.Trigger>
- <Tabs.Trigger
- value="review"
- class="w-1/2 !border-r-0"
- classes={{ button: "w-full" }}
- >
- {diffs().length} Files Changed
- </Tabs.Trigger>
- </Tabs.List>
- <Tabs.Content value="session" class="!overflow-hidden">
- {turns()}
- </Tabs.Content>
- <Tabs.Content
- forceMount
- value="review"
- class="!overflow-hidden hidden data-[selected]:block"
- >
- <div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
- <DiffComponentProvider component={SSRDiff}>
- <SessionReview
- diffs={diffs()}
- classes={{
- root: "pb-20",
- header: "px-4",
- container: "px-4",
- }}
- />
- </DiffComponentProvider>
- </div>
- </Tabs.Content>
- </Tabs>
- </Match>
- <Match when={true}>
- <div
- classList={{ "!overflow-hidden": true, "md:hidden": wide(), "lg:hidden": !wide() }}
- >
- {turns()}
- </div>
- </Match>
- </Switch>
- </div>
+ </Tabs.Content>
+ </Tabs>
+ </Match>
+ <Match when={true}>
+ <div
+ classList={{ "!overflow-hidden": true, "md:hidden": wide(), "lg:hidden": !wide() }}
+ >
+ {turns()}
+ </div>
+ </Match>
+ </Switch>
</div>
- )
- })}
- </DataProvider>
- </CodeComponentProvider>
- </DiffComponentProvider>
+ </div>
+ )
+ })}
+ </DataProvider>
+ </FileComponentProvider>
</ClientOnlyWorkerPoolProvider>
</>
)
diff --git a/packages/ui/src/components/code.css b/packages/ui/src/components/code.css
deleted file mode 100644
index 671b40512..000000000
--- a/packages/ui/src/components/code.css
+++ /dev/null
@@ -1,4 +0,0 @@
-[data-component="code"] {
- content-visibility: auto;
- overflow: hidden;
-}
diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx
deleted file mode 100644
index 837cc5337..000000000
--- a/packages/ui/src/components/code.tsx
+++ /dev/null
@@ -1,1097 +0,0 @@
-import {
- DEFAULT_VIRTUAL_FILE_METRICS,
- type FileContents,
- File,
- FileOptions,
- LineAnnotation,
- type SelectedLineRange,
- type VirtualFileMetrics,
- VirtualizedFile,
- Virtualizer,
-} from "@pierre/diffs"
-import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
-import { Portal } from "solid-js/web"
-import { createDefaultOptions, styleVariables } from "../pierre"
-import { getWorkerPool } from "../pierre/worker"
-import { Icon } from "./icon"
-
-const VIRTUALIZE_BYTES = 500_000
-const codeMetrics = {
- ...DEFAULT_VIRTUAL_FILE_METRICS,
- lineHeight: 24,
- fileGap: 0,
-} satisfies Partial<VirtualFileMetrics>
-
-type SelectionSide = "additions" | "deletions"
-
-export type CodeProps<T = {}> = FileOptions<T> & {
- file: FileContents
- annotations?: LineAnnotation<T>[]
- selectedLines?: SelectedLineRange | null
- commentedLines?: SelectedLineRange[]
- onRendered?: () => void
- onLineSelectionEnd?: (selection: SelectedLineRange | null) => void
- class?: string
- classList?: ComponentProps<"div">["classList"]
-}
-
-function findElement(node: Node | null): HTMLElement | undefined {
- if (!node) return
- if (node instanceof HTMLElement) return node
- return node.parentElement ?? undefined
-}
-
-function findLineNumber(node: Node | null): number | undefined {
- const element = findElement(node)
- if (!element) return
-
- const line = element.closest("[data-line]")
- if (!(line instanceof HTMLElement)) return
-
- const value = parseInt(line.dataset.line ?? "", 10)
- if (Number.isNaN(value)) return
-
- return value
-}
-
-function findSide(node: Node | null): SelectionSide | undefined {
- const element = findElement(node)
- if (!element) return
-
- const code = element.closest("[data-code]")
- if (!(code instanceof HTMLElement)) return
-
- if (code.hasAttribute("data-deletions")) return "deletions"
- return "additions"
-}
-
-type FindHost = {
- element: () => HTMLElement | undefined
- open: () => void
- close: () => void
- next: (dir: 1 | -1) => void
- isOpen: () => boolean
-}
-
-const findHosts = new Set<FindHost>()
-let findTarget: FindHost | undefined
-let findCurrent: FindHost | undefined
-let findInstalled = false
-
-function isEditable(node: unknown): boolean {
- if (!(node instanceof HTMLElement)) return false
- if (node.closest("[data-prevent-autofocus]")) return true
- if (node.isContentEditable) return true
- return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName)
-}
-
-function hostForNode(node: unknown): FindHost | undefined {
- if (!(node instanceof Node)) return
- for (const host of findHosts) {
- const el = host.element()
- if (el && el.isConnected && el.contains(node)) return host
- }
-}
-
-function installFindShortcuts() {
- if (findInstalled) return
- if (typeof window === "undefined") return
- findInstalled = true
-
- window.addEventListener(
- "keydown",
- (event) => {
- if (event.defaultPrevented) return
-
- const mod = event.metaKey || event.ctrlKey
- if (!mod) return
-
- const key = event.key.toLowerCase()
-
- if (key === "g") {
- const host = findCurrent
- if (!host || !host.isOpen()) return
- event.preventDefault()
- event.stopPropagation()
- host.next(event.shiftKey ? -1 : 1)
- return
- }
-
- if (key !== "f") return
-
- const current = findCurrent
- if (current && current.isOpen()) {
- event.preventDefault()
- event.stopPropagation()
- current.open()
- return
- }
-
- const host =
- hostForNode(document.activeElement) ?? hostForNode(event.target) ?? findTarget ?? Array.from(findHosts)[0]
- if (!host) return
-
- event.preventDefault()
- event.stopPropagation()
- host.open()
- },
- { capture: true },
- )
-}
-
-export function Code<T>(props: CodeProps<T>) {
- let wrapper!: HTMLDivElement
- let container!: HTMLDivElement
- let findInput: HTMLInputElement | undefined
- let findOverlay!: HTMLDivElement
- let findOverlayFrame: number | undefined
- let findOverlayScroll: HTMLElement[] = []
- let observer: MutationObserver | undefined
- let renderToken = 0
- let selectionFrame: number | undefined
- let dragFrame: number | undefined
- let dragStart: number | undefined
- let dragEnd: number | undefined
- let dragMoved = false
- let lastSelection: SelectedLineRange | null = null
- let pendingSelectionEnd = false
-
- const [local, others] = splitProps(props, [
- "file",
- "class",
- "classList",
- "annotations",
- "selectedLines",
- "commentedLines",
- "onRendered",
- ])
-
- const [rendered, setRendered] = createSignal(0)
-
- const [findOpen, setFindOpen] = createSignal(false)
- const [findQuery, setFindQuery] = createSignal("")
- const [findIndex, setFindIndex] = createSignal(0)
- const [findCount, setFindCount] = createSignal(0)
- let findMode: "highlights" | "overlay" = "overlay"
- let findHits: Range[] = []
-
- const [findPos, setFindPos] = createSignal<{ top: number; right: number }>({ top: 8, right: 8 })
-
- let instance: File<T> | VirtualizedFile<T> | undefined
- let virtualizer: Virtualizer | undefined
- let virtualRoot: Document | HTMLElement | undefined
-
- const bytes = createMemo(() => {
- const value = local.file.contents as unknown
- if (typeof value === "string") return value.length
- if (Array.isArray(value)) {
- return value.reduce(
- (acc, part) => acc + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
- 0,
- )
- }
- if (value == null) return 0
- return String(value).length
- })
- const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES)
-
- const options = createMemo(() => ({
- ...createDefaultOptions<T>("unified"),
- ...others,
- }))
-
- const getRoot = () => {
- const host = container.querySelector("diffs-container")
- if (!(host instanceof HTMLElement)) return
-
- const root = host.shadowRoot
- if (!root) return
-
- return root
- }
-
- const applyScheme = () => {
- const host = container.querySelector("diffs-container")
- if (!(host instanceof HTMLElement)) return
-
- const scheme = document.documentElement.dataset.colorScheme
- if (scheme === "dark" || scheme === "light") {
- host.dataset.colorScheme = scheme
- return
- }
-
- host.removeAttribute("data-color-scheme")
- }
-
- const supportsHighlights = () => {
- const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown }
- return typeof g.Highlight === "function" && g.CSS?.highlights != null
- }
-
- const clearHighlightFind = () => {
- const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights
- if (!api) return
- api.delete("opencode-find")
- api.delete("opencode-find-current")
- }
-
- const clearOverlayScroll = () => {
- for (const el of findOverlayScroll) el.removeEventListener("scroll", scheduleOverlay)
- findOverlayScroll = []
- }
-
- const clearOverlay = () => {
- if (findOverlayFrame !== undefined) {
- cancelAnimationFrame(findOverlayFrame)
- findOverlayFrame = undefined
- }
- findOverlay.innerHTML = ""
- }
-
- const renderOverlay = () => {
- if (findMode !== "overlay") {
- clearOverlay()
- return
- }
-
- clearOverlay()
- if (findHits.length === 0) return
-
- const base = wrapper.getBoundingClientRect()
- const current = findIndex()
-
- const frag = document.createDocumentFragment()
- for (let i = 0; i < findHits.length; i++) {
- const range = findHits[i]
- const active = i === current
-
- for (const rect of Array.from(range.getClientRects())) {
- if (!rect.width || !rect.height) continue
-
- const el = document.createElement("div")
- el.style.position = "absolute"
- el.style.left = `${Math.round(rect.left - base.left)}px`
- el.style.top = `${Math.round(rect.top - base.top)}px`
- el.style.width = `${Math.round(rect.width)}px`
- el.style.height = `${Math.round(rect.height)}px`
- el.style.borderRadius = "2px"
- el.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)"
- el.style.opacity = active ? "0.55" : "0.35"
- if (active) el.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)"
- frag.appendChild(el)
- }
- }
-
- findOverlay.appendChild(frag)
- }
-
- function scheduleOverlay() {
- if (findMode !== "overlay") return
- if (!findOpen()) return
- if (findOverlayFrame !== undefined) return
-
- findOverlayFrame = requestAnimationFrame(() => {
- findOverlayFrame = undefined
- renderOverlay()
- })
- }
-
- const syncOverlayScroll = () => {
- if (findMode !== "overlay") return
- const root = getRoot()
-
- const next = root
- ? Array.from(root.querySelectorAll("[data-code]")).filter(
- (node): node is HTMLElement => node instanceof HTMLElement,
- )
- : []
- if (next.length === findOverlayScroll.length && next.every((el, i) => el === findOverlayScroll[i])) return
-
- clearOverlayScroll()
- findOverlayScroll = next
- for (const el of findOverlayScroll) el.addEventListener("scroll", scheduleOverlay, { passive: true })
- }
-
- const clearFind = () => {
- clearHighlightFind()
- clearOverlay()
- clearOverlayScroll()
- findHits = []
- setFindCount(0)
- setFindIndex(0)
- }
-
- const getScrollParent = (el: HTMLElement): HTMLElement | undefined => {
- let parent = el.parentElement
- while (parent) {
- const style = getComputedStyle(parent)
- if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
- parent = parent.parentElement
- }
- }
-
- const positionFindBar = () => {
- if (typeof window === "undefined") return
-
- const root = getScrollParent(wrapper) ?? wrapper
- const rect = root.getBoundingClientRect()
- const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
- const header = Number.isNaN(title) ? 0 : title
- setFindPos({
- top: Math.round(rect.top) + header - 4,
- right: Math.round(window.innerWidth - rect.right) + 8,
- })
- }
-
- const scanFind = (root: ShadowRoot, query: string) => {
- const needle = query.toLowerCase()
- const out: Range[] = []
-
- const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter(
- (node): node is HTMLElement => node instanceof HTMLElement,
- )
-
- for (const col of cols) {
- const text = col.textContent
- if (!text) continue
-
- const hay = text.toLowerCase()
- let idx = hay.indexOf(needle)
- if (idx === -1) continue
-
- const nodes: Text[] = []
- const ends: number[] = []
- const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT)
- let node = walker.nextNode()
- let pos = 0
-
- while (node) {
- if (node instanceof Text) {
- pos += node.data.length
- nodes.push(node)
- ends.push(pos)
- }
- node = walker.nextNode()
- }
-
- if (nodes.length === 0) continue
-
- const locate = (at: number) => {
- let lo = 0
- let hi = ends.length - 1
- while (lo < hi) {
- const mid = (lo + hi) >> 1
- if (ends[mid] >= at) hi = mid
- else lo = mid + 1
- }
- const prev = lo === 0 ? 0 : ends[lo - 1]
- return { node: nodes[lo], offset: at - prev }
- }
-
- while (idx !== -1) {
- const start = locate(idx)
- const end = locate(idx + query.length)
- const range = document.createRange()
- range.setStart(start.node, start.offset)
- range.setEnd(end.node, end.offset)
- out.push(range)
- idx = hay.indexOf(needle, idx + query.length)
- }
- }
-
- return out
- }
-
- const scrollToRange = (range: Range) => {
- const start = range.startContainer
- const el = start instanceof Element ? start : start.parentElement
- el?.scrollIntoView({ block: "center", inline: "center" })
- }
-
- const setHighlights = (ranges: Range[], index: number) => {
- const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights
- const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight
- if (!api || typeof Highlight !== "function") return false
-
- api.delete("opencode-find")
- api.delete("opencode-find-current")
-
- const active = ranges[index]
- if (active) api.set("opencode-find-current", new Highlight(active))
-
- const rest = ranges.filter((_, i) => i !== index)
- if (rest.length > 0) api.set("opencode-find", new Highlight(...rest))
- return true
- }
-
- const applyFind = (opts?: { reset?: boolean; scroll?: boolean }) => {
- if (!findOpen()) return
-
- const query = findQuery().trim()
- if (!query) {
- clearFind()
- return
- }
-
- const root = getRoot()
- if (!root) return
-
- findMode = supportsHighlights() ? "highlights" : "overlay"
-
- const ranges = scanFind(root, query)
- const total = ranges.length
- const desired = opts?.reset ? 0 : findIndex()
- const index = total ? Math.min(desired, total - 1) : 0
-
- findHits = ranges
- setFindCount(total)
- setFindIndex(index)
-
- const active = ranges[index]
- if (findMode === "highlights") {
- clearOverlay()
- clearOverlayScroll()
- if (!setHighlights(ranges, index)) {
- findMode = "overlay"
- clearHighlightFind()
- syncOverlayScroll()
- scheduleOverlay()
- }
- if (opts?.scroll && active) {
- scrollToRange(active)
- }
- return
- }
-
- clearHighlightFind()
- syncOverlayScroll()
- if (opts?.scroll && active) {
- scrollToRange(active)
- }
- scheduleOverlay()
- }
-
- const closeFind = () => {
- setFindOpen(false)
- clearFind()
- if (findCurrent === host) findCurrent = undefined
- }
-
- const stepFind = (dir: 1 | -1) => {
- if (!findOpen()) return
- const total = findCount()
- if (total <= 0) return
-
- const index = (findIndex() + dir + total) % total
- setFindIndex(index)
-
- const active = findHits[index]
- if (!active) return
-
- if (findMode === "highlights") {
- if (!setHighlights(findHits, index)) {
- findMode = "overlay"
- applyFind({ reset: true, scroll: true })
- return
- }
- scrollToRange(active)
- return
- }
-
- clearHighlightFind()
- syncOverlayScroll()
- scrollToRange(active)
- scheduleOverlay()
- }
-
- const host: FindHost = {
- element: () => wrapper,
- isOpen: () => findOpen(),
- next: stepFind,
- open: () => {
- if (findCurrent && findCurrent !== host) findCurrent.close()
- findCurrent = host
- findTarget = host
-
- if (!findOpen()) setFindOpen(true)
- requestAnimationFrame(() => {
- applyFind({ scroll: true })
- findInput?.focus()
- findInput?.select()
- })
- },
- close: closeFind,
- }
-
- onMount(() => {
- findMode = supportsHighlights() ? "highlights" : "overlay"
- installFindShortcuts()
- findHosts.add(host)
- if (!findTarget) findTarget = host
-
- onCleanup(() => {
- findHosts.delete(host)
- if (findCurrent === host) {
- findCurrent = undefined
- clearHighlightFind()
- }
- if (findTarget === host) findTarget = undefined
- })
- })
-
- createEffect(() => {
- if (!findOpen()) return
-
- const update = () => positionFindBar()
- requestAnimationFrame(update)
- window.addEventListener("resize", update, { passive: true })
-
- const root = getScrollParent(wrapper) ?? wrapper
- const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(() => update())
- observer?.observe(root)
-
- onCleanup(() => {
- window.removeEventListener("resize", update)
- observer?.disconnect()
- })
- })
-
- const applyCommentedLines = (ranges: SelectedLineRange[]) => {
- const root = getRoot()
- if (!root) return
-
- const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
- for (const node of existing) {
- if (!(node instanceof HTMLElement)) continue
- node.removeAttribute("data-comment-selected")
- }
-
- const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter(
- (node): node is HTMLElement => node instanceof HTMLElement,
- )
-
- for (const range of ranges) {
- const start = Math.max(1, Math.min(range.start, range.end))
- const end = Math.max(range.start, range.end)
-
- for (let line = start; line <= end; line++) {
- const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`))
- for (const node of nodes) {
- if (!(node instanceof HTMLElement)) continue
- node.setAttribute("data-comment-selected", "")
- }
- }
-
- for (const annotation of annotations) {
- const line = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
- if (Number.isNaN(line)) continue
- if (line < start || line > end) continue
- annotation.setAttribute("data-comment-selected", "")
- }
- }
- }
-
- const text = () => {
- const value = local.file.contents as unknown
- if (typeof value === "string") return value
- if (Array.isArray(value)) return value.join("\n")
- if (value == null) return ""
- return String(value)
- }
-
- const lineCount = () => {
- const value = text()
- const total = value.split("\n").length - (value.endsWith("\n") ? 1 : 0)
- return Math.max(1, total)
- }
-
- const applySelection = (range: SelectedLineRange | null) => {
- const current = instance
- if (!current) return false
-
- if (virtual()) {
- current.setSelectedLines(range)
- return true
- }
-
- const root = getRoot()
- if (!root) return false
-
- const lines = lineCount()
- if (root.querySelectorAll("[data-line]").length < lines) return false
-
- if (!range) {
- current.setSelectedLines(null)
- return true
- }
-
- const start = Math.min(range.start, range.end)
- const end = Math.max(range.start, range.end)
-
- if (start < 1 || end > lines) {
- current.setSelectedLines(null)
- return true
- }
-
- if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) {
- current.setSelectedLines(null)
- return true
- }
-
- const normalized = (() => {
- if (range.endSide != null) return { start: range.start, end: range.end }
- if (range.side !== "deletions") return range
- if (root.querySelector("[data-deletions]") != null) return range
- return { start: range.start, end: range.end }
- })()
-
- current.setSelectedLines(normalized)
- return true
- }
-
- const notifyRendered = () => {
- observer?.disconnect()
- observer = undefined
- renderToken++
-
- const token = renderToken
-
- const lines = virtual() ? undefined : lineCount()
-
- const isReady = (root: ShadowRoot) =>
- virtual()
- ? root.querySelector("[data-line]") != null
- : root.querySelectorAll("[data-line]").length >= (lines ?? 0)
-
- const notify = () => {
- if (token !== renderToken) return
-
- observer?.disconnect()
- observer = undefined
- requestAnimationFrame(() => {
- if (token !== renderToken) return
- applySelection(lastSelection)
- applyFind({ reset: true })
- local.onRendered?.()
- })
- }
-
- const root = getRoot()
- if (root && isReady(root)) {
- notify()
- return
- }
-
- if (typeof MutationObserver === "undefined") return
-
- const observeRoot = (root: ShadowRoot) => {
- if (isReady(root)) {
- notify()
- return
- }
-
- observer?.disconnect()
- observer = new MutationObserver(() => {
- if (token !== renderToken) return
- if (!isReady(root)) return
-
- notify()
- })
-
- observer.observe(root, { childList: true, subtree: true })
- }
-
- if (root) {
- observeRoot(root)
- return
- }
-
- observer = new MutationObserver(() => {
- if (token !== renderToken) return
-
- const root = getRoot()
- if (!root) return
-
- observeRoot(root)
- })
-
- observer.observe(container, { childList: true, subtree: true })
- }
-
- const updateSelection = () => {
- const root = getRoot()
- if (!root) return
-
- const selection =
- (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
- if (!selection || selection.isCollapsed) return
-
- const domRange =
- (
- selection as unknown as {
- getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[]
- }
- ).getComposedRanges?.({ shadowRoots: [root] })?.[0] ??
- (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
-
- const startNode = domRange?.startContainer ?? selection.anchorNode
- const endNode = domRange?.endContainer ?? selection.focusNode
- if (!startNode || !endNode) return
-
- if (!root.contains(startNode) || !root.contains(endNode)) return
-
- const start = findLineNumber(startNode)
- const end = findLineNumber(endNode)
- if (start === undefined || end === undefined) return
-
- const startSide = findSide(startNode)
- const endSide = findSide(endNode)
- const side = startSide ?? endSide
-
- const selected: SelectedLineRange = {
- start,
- end,
- }
-
- if (side) selected.side = side
- if (endSide && side && endSide !== side) selected.endSide = endSide
-
- setSelectedLines(selected)
- }
-
- const setSelectedLines = (range: SelectedLineRange | null) => {
- lastSelection = range
- applySelection(range)
- }
-
- const scheduleSelectionUpdate = () => {
- if (selectionFrame !== undefined) return
-
- selectionFrame = requestAnimationFrame(() => {
- selectionFrame = undefined
- updateSelection()
-
- if (!pendingSelectionEnd) return
- pendingSelectionEnd = false
- props.onLineSelectionEnd?.(lastSelection)
- })
- }
-
- const updateDragSelection = () => {
- if (dragStart === undefined || dragEnd === undefined) return
-
- const start = Math.min(dragStart, dragEnd)
- const end = Math.max(dragStart, dragEnd)
-
- setSelectedLines({ start, end })
- }
-
- const scheduleDragUpdate = () => {
- if (dragFrame !== undefined) return
-
- dragFrame = requestAnimationFrame(() => {
- dragFrame = undefined
- updateDragSelection()
- })
- }
-
- const lineFromMouseEvent = (event: MouseEvent) => {
- const path = event.composedPath()
-
- let numberColumn = false
- let line: number | undefined
-
- for (const item of path) {
- if (!(item instanceof HTMLElement)) continue
-
- numberColumn = numberColumn || item.dataset.columnNumber != null
-
- if (line === undefined && item.dataset.line) {
- const parsed = parseInt(item.dataset.line, 10)
- if (!Number.isNaN(parsed)) line = parsed
- }
-
- if (numberColumn && line !== undefined) break
- }
-
- return { line, numberColumn }
- }
-
- const handleMouseDown = (event: MouseEvent) => {
- if (props.enableLineSelection !== true) return
- if (event.button !== 0) return
-
- const { line, numberColumn } = lineFromMouseEvent(event)
- if (numberColumn) return
- if (line === undefined) return
-
- dragStart = line
- dragEnd = line
- dragMoved = false
- }
-
- const handleMouseMove = (event: MouseEvent) => {
- if (props.enableLineSelection !== true) return
- if (dragStart === undefined) return
-
- if ((event.buttons & 1) === 0) {
- dragStart = undefined
- dragEnd = undefined
- dragMoved = false
- return
- }
-
- const { line } = lineFromMouseEvent(event)
- if (line === undefined) return
-
- dragEnd = line
- dragMoved = true
- scheduleDragUpdate()
- }
-
- const handleMouseUp = () => {
- if (props.enableLineSelection !== true) return
- if (dragStart === undefined) return
-
- if (!dragMoved) {
- pendingSelectionEnd = false
- const line = dragStart
- setSelectedLines({ start: line, end: line })
- props.onLineSelectionEnd?.(lastSelection)
- dragStart = undefined
- dragEnd = undefined
- dragMoved = false
- return
- }
-
- pendingSelectionEnd = true
- scheduleDragUpdate()
- scheduleSelectionUpdate()
-
- dragStart = undefined
- dragEnd = undefined
- dragMoved = false
- }
-
- const handleSelectionChange = () => {
- if (props.enableLineSelection !== true) return
- if (dragStart === undefined) return
-
- const selection = window.getSelection()
- if (!selection || selection.isCollapsed) return
-
- scheduleSelectionUpdate()
- }
-
- createEffect(() => {
- const opts = options()
- const workerPool = getWorkerPool("unified")
- const isVirtual = virtual()
-
- observer?.disconnect()
- observer = undefined
-
- instance?.cleanUp()
- instance = undefined
-
- if (!isVirtual && virtualizer) {
- virtualizer.cleanUp()
- virtualizer = undefined
- virtualRoot = undefined
- }
-
- const v = (() => {
- if (!isVirtual) return
- if (typeof document === "undefined") return
-
- const root = getScrollParent(wrapper) ?? document
- if (virtualizer && virtualRoot === root) return virtualizer
-
- virtualizer?.cleanUp()
- virtualizer = new Virtualizer()
- virtualRoot = root
- virtualizer.setup(root, root instanceof Document ? undefined : wrapper)
- return virtualizer
- })()
-
- instance = isVirtual && v ? new VirtualizedFile<T>(opts, v, codeMetrics, workerPool) : new File<T>(opts, workerPool)
-
- container.innerHTML = ""
- const value = text()
- instance.render({
- file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents: value },
- lineAnnotations: local.annotations,
- containerWrapper: container,
- })
-
- applyScheme()
-
- setRendered((value) => value + 1)
- notifyRendered()
- })
-
- createEffect(() => {
- if (typeof document === "undefined") return
- if (typeof MutationObserver === "undefined") return
-
- const root = document.documentElement
- const monitor = new MutationObserver(() => applyScheme())
- monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
- applyScheme()
-
- onCleanup(() => monitor.disconnect())
- })
-
- createEffect(() => {
- rendered()
- const ranges = local.commentedLines ?? []
- requestAnimationFrame(() => applyCommentedLines(ranges))
- })
-
- createEffect(() => {
- setSelectedLines(local.selectedLines ?? null)
- })
-
- createEffect(() => {
- if (props.enableLineSelection !== true) return
-
- container.addEventListener("mousedown", handleMouseDown)
- container.addEventListener("mousemove", handleMouseMove)
- window.addEventListener("mouseup", handleMouseUp)
- document.addEventListener("selectionchange", handleSelectionChange)
-
- onCleanup(() => {
- container.removeEventListener("mousedown", handleMouseDown)
- container.removeEventListener("mousemove", handleMouseMove)
- window.removeEventListener("mouseup", handleMouseUp)
- document.removeEventListener("selectionchange", handleSelectionChange)
- })
- })
-
- onCleanup(() => {
- observer?.disconnect()
-
- instance?.cleanUp()
- instance = undefined
-
- virtualizer?.cleanUp()
- virtualizer = undefined
- virtualRoot = undefined
-
- clearOverlayScroll()
- clearOverlay()
- if (findCurrent === host) {
- findCurrent = undefined
- clearHighlightFind()
- }
-
- if (selectionFrame !== undefined) {
- cancelAnimationFrame(selectionFrame)
- selectionFrame = undefined
- }
-
- if (dragFrame !== undefined) {
- cancelAnimationFrame(dragFrame)
- dragFrame = undefined
- }
-
- dragStart = undefined
- dragEnd = undefined
- dragMoved = false
- lastSelection = null
- pendingSelectionEnd = false
- })
-
- const FindBar = (barProps: { class: string; style?: ComponentProps<"div">["style"] }) => (
- <div class={barProps.class} style={barProps.style} onPointerDown={(e) => e.stopPropagation()}>
- <Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" />
- <input
- ref={findInput}
- placeholder="Find"
- value={findQuery()}
- class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak"
- onInput={(e) => {
- setFindQuery(e.currentTarget.value)
- setFindIndex(0)
- applyFind({ reset: true, scroll: true })
- }}
- onKeyDown={(e) => {
- if (e.key === "Escape") {
- e.preventDefault()
- closeFind()
- return
- }
- if (e.key !== "Enter") return
- e.preventDefault()
- stepFind(e.shiftKey ? -1 : 1)
- }}
- />
- <div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}>
- {findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"}
- </div>
- <div class="flex items-center">
- <button
- type="button"
- class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
- disabled={findCount() === 0}
- aria-label="Previous match"
- onClick={() => stepFind(-1)}
- >
- <Icon name="chevron-down" size="small" class="rotate-180" />
- </button>
- <button
- type="button"
- class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
- disabled={findCount() === 0}
- aria-label="Next match"
- onClick={() => stepFind(1)}
- >
- <Icon name="chevron-down" size="small" />
- </button>
- </div>
- <button
- type="button"
- class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
- aria-label="Close search"
- onClick={closeFind}
- >
- <Icon name="close-small" size="small" />
- </button>
- </div>
- )
-
- return (
- <div
- data-component="code"
- style={styleVariables}
- class="relative outline-none"
- classList={{
- ...(local.classList || {}),
- [local.class ?? ""]: !!local.class,
- }}
- ref={wrapper}
- tabIndex={0}
- onPointerDown={() => {
- findTarget = host
- wrapper.focus({ preventScroll: true })
- }}
- onFocus={() => {
- findTarget = host
- }}
- >
- <Show when={findOpen()}>
- <Portal>
- <FindBar
- class="fixed z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md"
- style={{
- top: `${findPos().top}px`,
- right: `${findPos().right}px`,
- }}
- />
- </Portal>
- </Show>
- <div ref={container} />
- <div ref={findOverlay} class="pointer-events-none absolute inset-0 z-0" />
- </div>
- )
-}
diff --git a/packages/ui/src/components/diff-ssr.tsx b/packages/ui/src/components/diff-ssr.tsx
deleted file mode 100644
index e739afc16..000000000
--- a/packages/ui/src/components/diff-ssr.tsx
+++ /dev/null
@@ -1,317 +0,0 @@
-import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
-import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
-import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
-import { Dynamic, isServer } from "solid-js/web"
-import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre"
-import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
-import { useWorkerPool } from "../context/worker-pool"
-
-export type SSRDiffProps<T = {}> = DiffProps<T> & {
- preloadedDiff: PreloadMultiFileDiffResult<T>
-}
-
-export function Diff<T>(props: SSRDiffProps<T>) {
- let container!: HTMLDivElement
- let fileDiffRef!: HTMLElement
- const [local, others] = splitProps(props, [
- "before",
- "after",
- "class",
- "classList",
- "annotations",
- "selectedLines",
- "commentedLines",
- ])
- const workerPool = useWorkerPool(props.diffStyle)
-
- let fileDiffInstance: FileDiff<T> | undefined
- let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
- const cleanupFunctions: Array<() => void> = []
-
- const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
-
- const getVirtualizer = () => {
- if (sharedVirtualizer) return sharedVirtualizer.virtualizer
-
- const result = acquireVirtualizer(container)
- if (!result) return
-
- sharedVirtualizer = result
- return result.virtualizer
- }
-
- const applyScheme = () => {
- const scheme = document.documentElement.dataset.colorScheme
- if (scheme === "dark" || scheme === "light") {
- fileDiffRef.dataset.colorScheme = scheme
- return
- }
-
- fileDiffRef.removeAttribute("data-color-scheme")
- }
-
- const lineIndex = (split: boolean, element: HTMLElement) => {
- const raw = element.dataset.lineIndex
- if (!raw) return
- const values = raw
- .split(",")
- .map((value) => parseInt(value, 10))
- .filter((value) => !Number.isNaN(value))
- if (values.length === 0) return
- if (!split) return values[0]
- if (values.length === 2) return values[1]
- return values[0]
- }
-
- const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: "additions" | "deletions" | undefined) => {
- const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
- (node): node is HTMLElement => node instanceof HTMLElement,
- )
- if (nodes.length === 0) return
-
- const targetSide = side ?? "additions"
-
- for (const node of nodes) {
- if (findSide(node) === targetSide) return lineIndex(split, node)
- if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
- }
- }
-
- const fixSelection = (range: SelectedLineRange | null) => {
- if (!range) return range
- const root = getRoot()
- if (!root) return
-
- const diffs = root.querySelector("[data-diff]")
- if (!(diffs instanceof HTMLElement)) return
-
- const split = diffs.dataset.diffType === "split"
-
- const start = rowIndex(root, split, range.start, range.side)
- const end = rowIndex(root, split, range.end, range.endSide ?? range.side)
-
- if (start === undefined || end === undefined) {
- if (root.querySelector("[data-line], [data-alt-line]") == null) return
- return null
- }
- if (start <= end) return range
-
- const side = range.endSide ?? range.side
- const swapped: SelectedLineRange = {
- start: range.end,
- end: range.start,
- }
- if (side) swapped.side = side
- if (range.endSide && range.side) swapped.endSide = range.side
-
- return swapped
- }
-
- const setSelectedLines = (range: SelectedLineRange | null, attempt = 0) => {
- const diff = fileDiffInstance
- if (!diff) return
-
- const fixed = fixSelection(range)
- if (fixed === undefined) {
- if (attempt >= 120) return
- requestAnimationFrame(() => setSelectedLines(range, attempt + 1))
- return
- }
-
- diff.setSelectedLines(fixed)
- }
-
- const findSide = (element: HTMLElement): "additions" | "deletions" => {
- const line = element.closest("[data-line], [data-alt-line]")
- if (line instanceof HTMLElement) {
- const type = line.dataset.lineType
- if (type === "change-deletion") return "deletions"
- if (type === "change-addition" || type === "change-additions") return "additions"
- }
-
- const code = element.closest("[data-code]")
- if (!(code instanceof HTMLElement)) return "additions"
- return code.hasAttribute("data-deletions") ? "deletions" : "additions"
- }
-
- const applyCommentedLines = (ranges: SelectedLineRange[]) => {
- const root = getRoot()
- if (!root) return
-
- const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
- for (const node of existing) {
- if (!(node instanceof HTMLElement)) continue
- node.removeAttribute("data-comment-selected")
- }
-
- const diffs = root.querySelector("[data-diff]")
- if (!(diffs instanceof HTMLElement)) return
-
- const split = diffs.dataset.diffType === "split"
-
- const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
- (node): node is HTMLElement => node instanceof HTMLElement,
- )
- if (rows.length === 0) return
-
- const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
- (node): node is HTMLElement => node instanceof HTMLElement,
- )
-
- const lineIndex = (element: HTMLElement) => {
- const raw = element.dataset.lineIndex
- if (!raw) return
- const values = raw
- .split(",")
- .map((value) => parseInt(value, 10))
- .filter((value) => !Number.isNaN(value))
- if (values.length === 0) return
- if (!split) return values[0]
- if (values.length === 2) return values[1]
- return values[0]
- }
-
- const rowIndex = (line: number, side: "additions" | "deletions" | undefined) => {
- const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
- (node): node is HTMLElement => node instanceof HTMLElement,
- )
- if (nodes.length === 0) return
-
- const targetSide = side ?? "additions"
-
- for (const node of nodes) {
- if (findSide(node) === targetSide) return lineIndex(node)
- if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(node)
- }
- }
-
- for (const range of ranges) {
- const start = rowIndex(range.start, range.side)
- if (start === undefined) continue
-
- const end = (() => {
- const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
- if (same) return start
- return rowIndex(range.end, range.endSide ?? range.side)
- })()
- if (end === undefined) continue
-
- const first = Math.min(start, end)
- const last = Math.max(start, end)
-
- for (const row of rows) {
- const idx = lineIndex(row)
- if (idx === undefined) continue
- if (idx < first || idx > last) continue
- row.setAttribute("data-comment-selected", "")
- }
-
- for (const annotation of annotations) {
- const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
- if (Number.isNaN(idx)) continue
- if (idx < first || idx > last) continue
- annotation.setAttribute("data-comment-selected", "")
- }
- }
- }
-
- onMount(() => {
- if (isServer || !props.preloadedDiff) return
-
- applyScheme()
-
- if (typeof MutationObserver !== "undefined") {
- const root = document.documentElement
- const monitor = new MutationObserver(() => applyScheme())
- monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
- onCleanup(() => monitor.disconnect())
- }
-
- const virtualizer = getVirtualizer()
-
- fileDiffInstance = virtualizer
- ? new VirtualizedFileDiff<T>(
- {
- ...createDefaultOptions(props.diffStyle),
- ...others,
- ...props.preloadedDiff,
- },
- virtualizer,
- virtualMetrics,
- workerPool,
- )
- : new FileDiff<T>(
- {
- ...createDefaultOptions(props.diffStyle),
- ...others,
- ...props.preloadedDiff,
- },
- workerPool,
- )
- // @ts-expect-error - fileContainer is private but needed for SSR hydration
- fileDiffInstance.fileContainer = fileDiffRef
- fileDiffInstance.hydrate({
- oldFile: local.before,
- newFile: local.after,
- lineAnnotations: local.annotations,
- fileContainer: fileDiffRef,
- containerWrapper: container,
- })
-
- setSelectedLines(local.selectedLines ?? null)
-
- createEffect(() => {
- fileDiffInstance?.setLineAnnotations(local.annotations ?? [])
- })
-
- createEffect(() => {
- setSelectedLines(local.selectedLines ?? null)
- })
-
- createEffect(() => {
- const ranges = local.commentedLines ?? []
- requestAnimationFrame(() => applyCommentedLines(ranges))
- })
-
- // Hydrate annotation slots with interactive SolidJS components
- // if (props.annotations.length > 0 && props.renderAnnotation != null) {
- // for (const annotation of props.annotations) {
- // const slotName = `annotation-${annotation.side}-${annotation.lineNumber}`;
- // const slotElement = fileDiffRef.querySelector(
- // `[slot="${slotName}"]`
- // ) as HTMLElement;
- //
- // if (slotElement != null) {
- // // Clear the static server-rendered content from the slot
- // slotElement.innerHTML = '';
- //
- // // Mount a fresh SolidJS component into this slot using render().
- // // This enables full SolidJS reactivity (signals, effects, etc.)
- // const dispose = render(
- // () => props.renderAnnotation!(annotation),
- // slotElement
- // );
- // cleanupFunctions.push(dispose);
- // }
- // }
- // }
- })
-
- onCleanup(() => {
- // Clean up FileDiff event handlers and dispose SolidJS components
- fileDiffInstance?.cleanUp()
- cleanupFunctions.forEach((dispose) => dispose())
- sharedVirtualizer?.release()
- sharedVirtualizer = undefined
- })
-
- return (
- <div data-component="diff" style={styleVariables} ref={container}>
- <Dynamic component={DIFFS_TAG_NAME} ref={fileDiffRef} id="ssr-diff">
- <Show when={isServer}>
- <template shadowrootmode="open" innerHTML={props.preloadedDiff.prerenderedHTML} />
- </Show>
- </Dynamic>
- </div>
- )
-}
diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx
deleted file mode 100644
index 0002232b0..000000000
--- a/packages/ui/src/components/diff.tsx
+++ /dev/null
@@ -1,652 +0,0 @@
-import { sampledChecksum } from "@opencode-ai/util/encode"
-import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
-import { createMediaQuery } from "@solid-primitives/media"
-import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
-import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
-import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
-import { getWorkerPool } from "../pierre/worker"
-
-type SelectionSide = "additions" | "deletions"
-
-function findElement(node: Node | null): HTMLElement | undefined {
- if (!node) return
- if (node instanceof HTMLElement) return node
- return node.parentElement ?? undefined
-}
-
-function findLineNumber(node: Node | null): number | undefined {
- const element = findElement(node)
- if (!element) return
-
- const line = element.closest("[data-line], [data-alt-line]")
- if (!(line instanceof HTMLElement)) return
-
- const value = (() => {
- const primary = parseInt(line.dataset.line ?? "", 10)
- if (!Number.isNaN(primary)) return primary
-
- const alt = parseInt(line.dataset.altLine ?? "", 10)
- if (!Number.isNaN(alt)) return alt
- })()
-
- return value
-}
-
-function findSide(node: Node | null): SelectionSide | undefined {
- const element = findElement(node)
- if (!element) return
-
- const line = element.closest("[data-line], [data-alt-line]")
- if (line instanceof HTMLElement) {
- const type = line.dataset.lineType
- if (type === "change-deletion") return "deletions"
- if (type === "change-addition" || type === "change-additions") return "additions"
- }
-
- const code = element.closest("[data-code]")
- if (!(code instanceof HTMLElement)) return
-
- if (code.hasAttribute("data-deletions")) return "deletions"
- return "additions"
-}
-
-export function Diff<T>(props: DiffProps<T>) {
- let container!: HTMLDivElement
- let observer: MutationObserver | undefined
- let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
- let renderToken = 0
- let selectionFrame: number | undefined
- let dragFrame: number | undefined
- let dragStart: number | undefined
- let dragEnd: number | undefined
- let dragSide: SelectionSide | undefined
- let dragEndSide: SelectionSide | undefined
- let dragMoved = false
- let lastSelection: SelectedLineRange | null = null
- let pendingSelectionEnd = false
-
- const [local, others] = splitProps(props, [
- "before",
- "after",
- "class",
- "classList",
- "annotations",
- "selectedLines",
- "commentedLines",
- "onRendered",
- ])
-
- const mobile = createMediaQuery("(max-width: 640px)")
-
- const large = createMemo(() => {
- const before = typeof local.before?.contents === "string" ? local.before.contents : ""
- const after = typeof local.after?.contents === "string" ? local.after.contents : ""
- return Math.max(before.length, after.length) > 500_000
- })
-
- const largeOptions = {
- lineDiffType: "none",
- maxLineDiffLength: 0,
- tokenizeMaxLineLength: 1,
- } satisfies Pick<FileDiffOptions<T>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
-
- const options = createMemo<FileDiffOptions<T>>(() => {
- const base = {
- ...createDefaultOptions(props.diffStyle),
- ...others,
- }
-
- const perf = large() ? { ...base, ...largeOptions } : base
- if (!mobile()) return perf
-
- return {
- ...perf,
- disableLineNumbers: true,
- }
- })
-
- let instance: FileDiff<T> | undefined
- const [current, setCurrent] = createSignal<FileDiff<T> | undefined>(undefined)
- const [rendered, setRendered] = createSignal(0)
-
- const getVirtualizer = () => {
- if (sharedVirtualizer) return sharedVirtualizer.virtualizer
-
- const result = acquireVirtualizer(container)
- if (!result) return
-
- sharedVirtualizer = result
- return result.virtualizer
- }
-
- const getRoot = () => {
- const host = container.querySelector("diffs-container")
- if (!(host instanceof HTMLElement)) return
-
- const root = host.shadowRoot
- if (!root) return
-
- return root
- }
-
- const applyScheme = () => {
- const host = container.querySelector("diffs-container")
- if (!(host instanceof HTMLElement)) return
-
- const scheme = document.documentElement.dataset.colorScheme
- if (scheme === "dark" || scheme === "light") {
- host.dataset.colorScheme = scheme
- return
- }
-
- host.removeAttribute("data-color-scheme")
- }
-
- const lineIndex = (split: boolean, element: HTMLElement) => {
- const raw = element.dataset.lineIndex
- if (!raw) return
- const values = raw
- .split(",")
- .map((value) => parseInt(value, 10))
- .filter((value) => !Number.isNaN(value))
- if (values.length === 0) return
- if (!split) return values[0]
- if (values.length === 2) return values[1]
- return values[0]
- }
-
- const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: SelectionSide | undefined) => {
- const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
- (node): node is HTMLElement => node instanceof HTMLElement,
- )
- if (nodes.length === 0) return
-
- const targetSide = side ?? "additions"
-
- for (const node of nodes) {
- if (findSide(node) === targetSide) return lineIndex(split, node)
- if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
- }
- }
-
- const fixSelection = (range: SelectedLineRange | null) => {
- if (!range) return range
- const root = getRoot()
- if (!root) return
-
- const diffs = root.querySelector("[data-diff]")
- if (!(diffs instanceof HTMLElement)) return
-
- const split = diffs.dataset.diffType === "split"
-
- const start = rowIndex(root, split, range.start, range.side)
- const end = rowIndex(root, split, range.end, range.endSide ?? range.side)
- if (start === undefined || end === undefined) {
- if (root.querySelector("[data-line], [data-alt-line]") == null) return
- return null
- }
- if (start <= end) return range
-
- const side = range.endSide ?? range.side
- const swapped: SelectedLineRange = {
- start: range.end,
- end: range.start,
- }
-
- if (side) swapped.side = side
- if (range.endSide && range.side) swapped.endSide = range.side
-
- return swapped
- }
-
- const notifyRendered = () => {
- observer?.disconnect()
- observer = undefined
- renderToken++
-
- const token = renderToken
- let settle = 0
-
- const isReady = (root: ShadowRoot) => root.querySelector("[data-line]") != null
-
- const notify = () => {
- if (token !== renderToken) return
-
- observer?.disconnect()
- observer = undefined
- requestAnimationFrame(() => {
- if (token !== renderToken) return
- setSelectedLines(lastSelection)
- local.onRendered?.()
- })
- }
-
- const schedule = () => {
- settle++
- const current = settle
-
- requestAnimationFrame(() => {
- if (token !== renderToken) return
- if (current !== settle) return
-
- requestAnimationFrame(() => {
- if (token !== renderToken) return
- if (current !== settle) return
-
- notify()
- })
- })
- }
-
- const observeRoot = (root: ShadowRoot) => {
- observer?.disconnect()
- observer = new MutationObserver(() => {
- if (token !== renderToken) return
- if (!isReady(root)) return
-
- schedule()
- })
-
- observer.observe(root, { childList: true, subtree: true })
-
- if (!isReady(root)) return
- schedule()
- }
-
- const root = getRoot()
- if (typeof MutationObserver === "undefined") {
- if (!root || !isReady(root)) return
- setSelectedLines(lastSelection)
- local.onRendered?.()
- return
- }
-
- if (root) {
- observeRoot(root)
- return
- }
-
- observer = new MutationObserver(() => {
- if (token !== renderToken) return
-
- const root = getRoot()
- if (!root) return
-
- observeRoot(root)
- })
-
- observer.observe(container, { childList: true, subtree: true })
- }
-
- const applyCommentedLines = (ranges: SelectedLineRange[]) => {
- const root = getRoot()
- if (!root) return
-
- const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
- for (const node of existing) {
- if (!(node instanceof HTMLElement)) continue
- node.removeAttribute("data-comment-selected")
- }
-
- const diffs = root.querySelector("[data-diff]")
- if (!(diffs instanceof HTMLElement)) return
-
- const split = diffs.dataset.diffType === "split"
-
- const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
- (node): node is HTMLElement => node instanceof HTMLElement,
- )
- if (rows.length === 0) return
-
- const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
- (node): node is HTMLElement => node instanceof HTMLElement,
- )
-
- for (const range of ranges) {
- const start = rowIndex(root, split, range.start, range.side)
- if (start === undefined) continue
-
- const end = (() => {
- const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
- if (same) return start
- return rowIndex(root, split, range.end, range.endSide ?? range.side)
- })()
- if (end === undefined) continue
-
- const first = Math.min(start, end)
- const last = Math.max(start, end)
-
- for (const row of rows) {
- const idx = lineIndex(split, row)
- if (idx === undefined) continue
- if (idx < first || idx > last) continue
- row.setAttribute("data-comment-selected", "")
- }
-
- for (const annotation of annotations) {
- const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
- if (Number.isNaN(idx)) continue
- if (idx < first || idx > last) continue
- annotation.setAttribute("data-comment-selected", "")
- }
- }
- }
-
- const setSelectedLines = (range: SelectedLineRange | null) => {
- const active = current()
- if (!active) return
-
- const fixed = fixSelection(range)
- if (fixed === undefined) {
- lastSelection = range
- return
- }
-
- lastSelection = fixed
- active.setSelectedLines(fixed)
- }
-
- const updateSelection = () => {
- const root = getRoot()
- if (!root) return
-
- const selection =
- (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
- if (!selection || selection.isCollapsed) return
-
- const domRange =
- (
- selection as unknown as {
- getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[]
- }
- ).getComposedRanges?.({ shadowRoots: [root] })?.[0] ??
- (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
-
- const startNode = domRange?.startContainer ?? selection.anchorNode
- const endNode = domRange?.endContainer ?? selection.focusNode
- if (!startNode || !endNode) return
-
- if (!root.contains(startNode) || !root.contains(endNode)) return
-
- const start = findLineNumber(startNode)
- const end = findLineNumber(endNode)
- if (start === undefined || end === undefined) return
-
- const startSide = findSide(startNode)
- const endSide = findSide(endNode)
- const side = startSide ?? endSide
-
- const selected: SelectedLineRange = {
- start,
- end,
- }
-
- if (side) selected.side = side
- if (endSide && side && endSide !== side) selected.endSide = endSide
-
- setSelectedLines(selected)
- }
-
- const scheduleSelectionUpdate = () => {
- if (selectionFrame !== undefined) return
-
- selectionFrame = requestAnimationFrame(() => {
- selectionFrame = undefined
- updateSelection()
-
- if (!pendingSelectionEnd) return
- pendingSelectionEnd = false
- props.onLineSelectionEnd?.(lastSelection)
- })
- }
-
- const updateDragSelection = () => {
- if (dragStart === undefined || dragEnd === undefined) return
-
- const selected: SelectedLineRange = {
- start: dragStart,
- end: dragEnd,
- }
-
- if (dragSide) selected.side = dragSide
- if (dragEndSide && dragSide && dragEndSide !== dragSide) selected.endSide = dragEndSide
-
- setSelectedLines(selected)
- }
-
- const scheduleDragUpdate = () => {
- if (dragFrame !== undefined) return
-
- dragFrame = requestAnimationFrame(() => {
- dragFrame = undefined
- updateDragSelection()
- })
- }
-
- const lineFromMouseEvent = (event: MouseEvent) => {
- const path = event.composedPath()
-
- let numberColumn = false
- let line: number | undefined
- let side: SelectionSide | undefined
-
- for (const item of path) {
- if (!(item instanceof HTMLElement)) continue
-
- numberColumn = numberColumn || item.dataset.columnNumber != null
-
- if (side === undefined) {
- const type = item.dataset.lineType
- if (type === "change-deletion") side = "deletions"
- if (type === "change-addition" || type === "change-additions") side = "additions"
- }
-
- if (side === undefined && item.dataset.code != null) {
- side = item.hasAttribute("data-deletions") ? "deletions" : "additions"
- }
-
- if (line === undefined) {
- const primary = item.dataset.line ? parseInt(item.dataset.line, 10) : Number.NaN
- if (!Number.isNaN(primary)) {
- line = primary
- } else {
- const alt = item.dataset.altLine ? parseInt(item.dataset.altLine, 10) : Number.NaN
- if (!Number.isNaN(alt)) line = alt
- }
- }
-
- if (numberColumn && line !== undefined && side !== undefined) break
- }
-
- return { line, numberColumn, side }
- }
-
- const handleMouseDown = (event: MouseEvent) => {
- if (props.enableLineSelection !== true) return
- if (event.button !== 0) return
-
- const { line, numberColumn, side } = lineFromMouseEvent(event)
- if (numberColumn) return
- if (line === undefined) return
-
- dragStart = line
- dragEnd = line
- dragSide = side
- dragEndSide = side
- dragMoved = false
- }
-
- const handleMouseMove = (event: MouseEvent) => {
- if (props.enableLineSelection !== true) return
- if (dragStart === undefined) return
-
- if ((event.buttons & 1) === 0) {
- dragStart = undefined
- dragEnd = undefined
- dragSide = undefined
- dragEndSide = undefined
- dragMoved = false
- return
- }
-
- const { line, side } = lineFromMouseEvent(event)
- if (line === undefined) return
-
- dragEnd = line
- dragEndSide = side
- dragMoved = true
- scheduleDragUpdate()
- }
-
- const handleMouseUp = () => {
- if (props.enableLineSelection !== true) return
- if (dragStart === undefined) return
-
- if (!dragMoved) {
- pendingSelectionEnd = false
- const line = dragStart
- const selected: SelectedLineRange = {
- start: line,
- end: line,
- }
- if (dragSide) selected.side = dragSide
- setSelectedLines(selected)
- props.onLineSelectionEnd?.(lastSelection)
- dragStart = undefined
- dragEnd = undefined
- dragSide = undefined
- dragEndSide = undefined
- dragMoved = false
- return
- }
-
- pendingSelectionEnd = true
- scheduleDragUpdate()
- scheduleSelectionUpdate()
-
- dragStart = undefined
- dragEnd = undefined
- dragSide = undefined
- dragEndSide = undefined
- dragMoved = false
- }
-
- const handleSelectionChange = () => {
- if (props.enableLineSelection !== true) return
- if (dragStart === undefined) return
-
- const selection = window.getSelection()
- if (!selection || selection.isCollapsed) return
-
- scheduleSelectionUpdate()
- }
-
- createEffect(() => {
- const opts = options()
- const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
- const virtualizer = getVirtualizer()
- const annotations = local.annotations
- const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
- const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
-
- const cacheKey = (contents: string) => {
- if (!large()) return sampledChecksum(contents, contents.length)
- return sampledChecksum(contents)
- }
-
- instance?.cleanUp()
- instance = virtualizer
- ? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
- : new FileDiff<T>(opts, workerPool)
- setCurrent(instance)
-
- container.innerHTML = ""
- instance.render({
- oldFile: {
- ...local.before,
- contents: beforeContents,
- cacheKey: cacheKey(beforeContents),
- },
- newFile: {
- ...local.after,
- contents: afterContents,
- cacheKey: cacheKey(afterContents),
- },
- lineAnnotations: annotations,
- containerWrapper: container,
- })
-
- applyScheme()
-
- setRendered((value) => value + 1)
- notifyRendered()
- })
-
- createEffect(() => {
- if (typeof document === "undefined") return
- if (typeof MutationObserver === "undefined") return
-
- const root = document.documentElement
- const monitor = new MutationObserver(() => applyScheme())
- monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
- applyScheme()
-
- onCleanup(() => monitor.disconnect())
- })
-
- createEffect(() => {
- rendered()
- const ranges = local.commentedLines ?? []
- requestAnimationFrame(() => applyCommentedLines(ranges))
- })
-
- createEffect(() => {
- const selected = local.selectedLines ?? null
- setSelectedLines(selected)
- })
-
- createEffect(() => {
- if (props.enableLineSelection !== true) return
-
- container.addEventListener("mousedown", handleMouseDown)
- container.addEventListener("mousemove", handleMouseMove)
- window.addEventListener("mouseup", handleMouseUp)
- document.addEventListener("selectionchange", handleSelectionChange)
-
- onCleanup(() => {
- container.removeEventListener("mousedown", handleMouseDown)
- container.removeEventListener("mousemove", handleMouseMove)
- window.removeEventListener("mouseup", handleMouseUp)
- document.removeEventListener("selectionchange", handleSelectionChange)
- })
- })
-
- onCleanup(() => {
- observer?.disconnect()
-
- if (selectionFrame !== undefined) {
- cancelAnimationFrame(selectionFrame)
- selectionFrame = undefined
- }
-
- if (dragFrame !== undefined) {
- cancelAnimationFrame(dragFrame)
- dragFrame = undefined
- }
-
- dragStart = undefined
- dragEnd = undefined
- dragSide = undefined
- dragEndSide = undefined
- dragMoved = false
- lastSelection = null
- pendingSelectionEnd = false
-
- instance?.cleanUp()
- setCurrent(undefined)
- sharedVirtualizer?.release()
- sharedVirtualizer = undefined
- })
-
- return <div data-component="diff" style={styleVariables} ref={container} />
-}
diff --git a/packages/ui/src/components/file-media.tsx b/packages/ui/src/components/file-media.tsx
new file mode 100644
index 000000000..2fd54588a
--- /dev/null
+++ b/packages/ui/src/components/file-media.tsx
@@ -0,0 +1,265 @@
+import type { FileContent } from "@opencode-ai/sdk/v2"
+import { createEffect, createMemo, createResource, Match, on, Show, Switch, type JSX } from "solid-js"
+import { useI18n } from "../context/i18n"
+import {
+ dataUrlFromMediaValue,
+ hasMediaValue,
+ isBinaryContent,
+ mediaKindFromPath,
+ normalizeMimeType,
+ svgTextFromValue,
+} from "../pierre/media"
+
+export type FileMediaOptions = {
+ mode?: "auto" | "off"
+ path?: string
+ current?: unknown
+ before?: unknown
+ after?: unknown
+ readFile?: (path: string) => Promise<FileContent | undefined>
+ onLoad?: () => void
+ onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void
+}
+
+function mediaValue(cfg: FileMediaOptions, mode: "image" | "audio") {
+ if (cfg.current !== undefined) return cfg.current
+ if (mode === "image") return cfg.after ?? cfg.before
+ return cfg.after ?? cfg.before
+}
+
+export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX.Element }) {
+ const i18n = useI18n()
+ const cfg = () => props.media
+ const kind = createMemo(() => {
+ const media = cfg()
+ if (!media || media.mode === "off") return
+ return mediaKindFromPath(media.path)
+ })
+
+ const isBinary = createMemo(() => {
+ const media = cfg()
+ if (!media || media.mode === "off") return false
+ if (kind()) return false
+ return isBinaryContent(media.current as any)
+ })
+
+ const onLoad = () => props.media?.onLoad?.()
+
+ const deleted = createMemo(() => {
+ const media = cfg()
+ const k = kind()
+ if (!media || !k) return false
+ if (k === "svg") return false
+ if (media.current !== undefined) return false
+ return !hasMediaValue(media.after as any) && hasMediaValue(media.before as any)
+ })
+
+ const direct = createMemo(() => {
+ const media = cfg()
+ const k = kind()
+ if (!media || (k !== "image" && k !== "audio")) return
+ return dataUrlFromMediaValue(mediaValue(media, k), k)
+ })
+
+ const request = createMemo(() => {
+ const media = cfg()
+ const k = kind()
+ if (!media || (k !== "image" && k !== "audio")) return
+ if (media.current !== undefined) return
+ if (deleted()) return
+ if (direct()) return
+ if (!media.path || !media.readFile) return
+
+ return {
+ key: `${k}:${media.path}`,
+ kind: k,
+ path: media.path,
+ readFile: media.readFile,
+ onError: media.onError,
+ }
+ })
+
+ const [loaded] = createResource(request, async (input) => {
+ return input.readFile(input.path).then(
+ (result) => {
+ const src = dataUrlFromMediaValue(result as any, input.kind)
+ if (!src) {
+ input.onError?.({ kind: input.kind })
+ return { key: input.key, error: true as const }
+ }
+
+ return {
+ key: input.key,
+ src,
+ mime: input.kind === "audio" ? normalizeMimeType(result?.mimeType) : undefined,
+ }
+ },
+ () => {
+ input.onError?.({ kind: input.kind })
+ return { key: input.key, error: true as const }
+ },
+ )
+ })
+
+ const remote = createMemo(() => {
+ const input = request()
+ const value = loaded()
+ if (!input || !value || value.key !== input.key) return
+ return value
+ })
+
+ const src = createMemo(() => {
+ const value = remote()
+ return direct() ?? (value && "src" in value ? value.src : undefined)
+ })
+ const status = createMemo(() => {
+ if (direct()) return "ready" as const
+ if (!request()) return "idle" as const
+ if (loaded.loading) return "loading" as const
+ if (remote()?.error) return "error" as const
+ if (src()) return "ready" as const
+ return "idle" as const
+ })
+ const audioMime = createMemo(() => {
+ const value = remote()
+ return value && "mime" in value ? value.mime : undefined
+ })
+
+ const svgSource = createMemo(() => {
+ const media = cfg()
+ if (!media || kind() !== "svg") return
+ return svgTextFromValue(media.current as any)
+ })
+ const svgSrc = createMemo(() => {
+ const media = cfg()
+ if (!media || kind() !== "svg") return
+ return dataUrlFromMediaValue(media.current as any, "svg")
+ })
+ const svgInvalid = createMemo(() => {
+ const media = cfg()
+ if (!media || kind() !== "svg") return
+ if (svgSource() !== undefined) return
+ if (!hasMediaValue(media.current as any)) return
+ return [media.path, media.current] as const
+ })
+
+ createEffect(
+ on(
+ svgInvalid,
+ (value) => {
+ if (!value) return
+ cfg()?.onError?.({ kind: "svg" })
+ },
+ { defer: true },
+ ),
+ )
+
+ const kindLabel = (value: "image" | "audio") =>
+ i18n.t(value === "image" ? "ui.fileMedia.kind.image" : "ui.fileMedia.kind.audio")
+
+ return (
+ <Switch>
+ <Match when={kind() === "image" || kind() === "audio"}>
+ <Show
+ when={src()}
+ fallback={(() => {
+ const media = cfg()
+ const k = kind()
+ if (!media || (k !== "image" && k !== "audio")) return props.fallback()
+ const label = kindLabel(k)
+
+ if (deleted()) {
+ return (
+ <div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
+ {i18n.t("ui.fileMedia.state.removed", { kind: label })}
+ </div>
+ )
+ }
+ if (status() === "loading") {
+ return (
+ <div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
+ {i18n.t("ui.fileMedia.state.loading", { kind: label })}
+ </div>
+ )
+ }
+ if (status() === "error") {
+ return (
+ <div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
+ {i18n.t("ui.fileMedia.state.error", { kind: label })}
+ </div>
+ )
+ }
+ return (
+ <div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
+ {i18n.t("ui.fileMedia.state.unavailable", { kind: label })}
+ </div>
+ )
+ })()}
+ >
+ {(value) => {
+ const k = kind()
+ if (k !== "image" && k !== "audio") return props.fallback()
+ if (k === "image") {
+ return (
+ <div class="flex justify-center bg-background-stronger px-6 py-4">
+ <img
+ src={value()}
+ alt={cfg()?.path}
+ class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
+ onLoad={onLoad}
+ />
+ </div>
+ )
+ }
+
+ return (
+ <div class="flex justify-center bg-background-stronger px-6 py-4">
+ <audio class="w-full max-w-xl" controls preload="metadata" onLoadedMetadata={onLoad}>
+ <source src={value()} type={audioMime()} />
+ </audio>
+ </div>
+ )
+ }}
+ </Show>
+ </Match>
+ <Match when={kind() === "svg"}>
+ {(() => {
+ if (svgSource() === undefined && svgSrc() == null) return props.fallback()
+
+ return (
+ <div class="flex flex-col gap-4 px-6 py-4">
+ <Show when={svgSource() !== undefined}>{props.fallback()}</Show>
+ <Show when={svgSrc()}>
+ {(value) => (
+ <div class="flex justify-center">
+ <img
+ src={value()}
+ alt={cfg()?.path}
+ class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
+ onLoad={onLoad}
+ />
+ </div>
+ )}
+ </Show>
+ </div>
+ )
+ })()}
+ </Match>
+ <Match when={isBinary()}>
+ <div class="flex min-h-56 flex-col items-center justify-center gap-2 px-6 py-10 text-center">
+ <div class="text-14-semibold text-text-strong">
+ {cfg()?.path?.split("/").pop() ?? i18n.t("ui.fileMedia.binary.title")}
+ </div>
+ <div class="text-14-regular text-text-weak">
+ {(() => {
+ const path = cfg()?.path
+ if (!path) return i18n.t("ui.fileMedia.binary.description.default")
+ return i18n.t("ui.fileMedia.binary.description.path", { path })
+ })()}
+ </div>
+ </div>
+ </Match>
+ <Match when={true}>{props.fallback()}</Match>
+ </Switch>
+ )
+}
diff --git a/packages/ui/src/components/file-search.tsx b/packages/ui/src/components/file-search.tsx
new file mode 100644
index 000000000..d83fdb16a
--- /dev/null
+++ b/packages/ui/src/components/file-search.tsx
@@ -0,0 +1,69 @@
+import { Portal } from "solid-js/web"
+import { Icon } from "./icon"
+
+export function FileSearchBar(props: {
+ pos: () => { top: number; right: number }
+ query: () => string
+ index: () => number
+ count: () => number
+ setInput: (el: HTMLInputElement) => void
+ onInput: (value: string) => void
+ onKeyDown: (event: KeyboardEvent) => void
+ onClose: () => void
+ onPrev: () => void
+ onNext: () => void
+}) {
+ return (
+ <Portal>
+ <div
+ class="fixed z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md"
+ style={{
+ top: `${props.pos().top}px`,
+ right: `${props.pos().right}px`,
+ }}
+ onPointerDown={(e) => e.stopPropagation()}
+ >
+ <Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" />
+ <input
+ ref={props.setInput}
+ placeholder="Find"
+ value={props.query()}
+ class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak"
+ onInput={(e) => props.onInput(e.currentTarget.value)}
+ onKeyDown={(e) => props.onKeyDown(e as KeyboardEvent)}
+ />
+ <div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}>
+ {props.count() ? `${props.index() + 1}/${props.count()}` : "0/0"}
+ </div>
+ <div class="flex items-center">
+ <button
+ type="button"
+ class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
+ disabled={props.count() === 0}
+ aria-label="Previous match"
+ onClick={props.onPrev}
+ >
+ <Icon name="chevron-down" size="small" class="rotate-180" />
+ </button>
+ <button
+ type="button"
+ class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
+ disabled={props.count() === 0}
+ aria-label="Next match"
+ onClick={props.onNext}
+ >
+ <Icon name="chevron-down" size="small" />
+ </button>
+ </div>
+ <button
+ type="button"
+ class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
+ aria-label="Close search"
+ onClick={props.onClose}
+ >
+ <Icon name="close-small" size="small" />
+ </button>
+ </div>
+ </Portal>
+ )
+}
diff --git a/packages/ui/src/components/file-ssr.tsx b/packages/ui/src/components/file-ssr.tsx
new file mode 100644
index 000000000..952690783
--- /dev/null
+++ b/packages/ui/src/components/file-ssr.tsx
@@ -0,0 +1,178 @@
+import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs"
+import { type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
+import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
+import { Dynamic, isServer } from "solid-js/web"
+import { useWorkerPool } from "../context/worker-pool"
+import { createDefaultOptions, styleVariables } from "../pierre"
+import { markCommentedDiffLines } from "../pierre/commented-lines"
+import { fixDiffSelection } from "../pierre/diff-selection"
+import {
+ applyViewerScheme,
+ clearReadyWatcher,
+ createReadyWatcher,
+ notifyShadowReady,
+ observeViewerScheme,
+} from "../pierre/file-runtime"
+import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
+import { File, type DiffFileProps, type FileProps } from "./file"
+
+type SSRDiffFileProps<T> = DiffFileProps<T> & {
+ preloadedDiff: PreloadMultiFileDiffResult<T>
+}
+
+function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
+ let container!: HTMLDivElement
+ let fileDiffRef!: HTMLElement
+ let fileDiffInstance: FileDiff<T> | undefined
+ let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
+
+ const ready = createReadyWatcher()
+ const workerPool = useWorkerPool(props.diffStyle)
+
+ const [local, others] = splitProps(props, [
+ "mode",
+ "media",
+ "before",
+ "after",
+ "class",
+ "classList",
+ "annotations",
+ "selectedLines",
+ "commentedLines",
+ "onLineSelected",
+ "onLineSelectionEnd",
+ "onLineNumberSelectionEnd",
+ "onRendered",
+ "preloadedDiff",
+ ])
+
+ const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
+
+ const getVirtualizer = () => {
+ if (sharedVirtualizer) return sharedVirtualizer.virtualizer
+ const result = acquireVirtualizer(container)
+ if (!result) return
+ sharedVirtualizer = result
+ return result.virtualizer
+ }
+
+ const setSelectedLines = (range: DiffFileProps<T>["selectedLines"], attempt = 0) => {
+ const diff = fileDiffInstance
+ if (!diff) return
+
+ const fixed = fixDiffSelection(getRoot(), range ?? null)
+ if (fixed === undefined) {
+ if (attempt >= 120) return
+ requestAnimationFrame(() => setSelectedLines(range ?? null, attempt + 1))
+ return
+ }
+
+ diff.setSelectedLines(fixed)
+ }
+
+ const notifyRendered = () => {
+ notifyShadowReady({
+ state: ready,
+ container,
+ getRoot,
+ isReady: (root) => root.querySelector("[data-line]") != null,
+ settleFrames: 1,
+ onReady: () => {
+ setSelectedLines(local.selectedLines ?? null)
+ local.onRendered?.()
+ },
+ })
+ }
+
+ onMount(() => {
+ if (isServer) return
+
+ onCleanup(observeViewerScheme(() => fileDiffRef))
+
+ const virtualizer = getVirtualizer()
+ fileDiffInstance = virtualizer
+ ? new VirtualizedFileDiff<T>(
+ {
+ ...createDefaultOptions(props.diffStyle),
+ ...others,
+ ...local.preloadedDiff,
+ },
+ virtualizer,
+ virtualMetrics,
+ workerPool,
+ )
+ : new FileDiff<T>(
+ {
+ ...createDefaultOptions(props.diffStyle),
+ ...others,
+ ...local.preloadedDiff,
+ },
+ workerPool,
+ )
+
+ applyViewerScheme(fileDiffRef)
+
+ // @ts-expect-error private field required for hydration
+ fileDiffInstance.fileContainer = fileDiffRef
+ fileDiffInstance.hydrate({
+ oldFile: local.before,
+ newFile: local.after,
+ lineAnnotations: local.annotations ?? [],
+ fileContainer: fileDiffRef,
+ containerWrapper: container,
+ })
+
+ notifyRendered()
+ })
+
+ createEffect(() => {
+ const diff = fileDiffInstance
+ if (!diff) return
+ diff.setLineAnnotations(local.annotations ?? [])
+ diff.rerender()
+ })
+
+ createEffect(() => {
+ setSelectedLines(local.selectedLines ?? null)
+ })
+
+ createEffect(() => {
+ const ranges = local.commentedLines ?? []
+ requestAnimationFrame(() => {
+ const root = getRoot()
+ if (!root) return
+ markCommentedDiffLines(root, ranges)
+ })
+ })
+
+ onCleanup(() => {
+ clearReadyWatcher(ready)
+ fileDiffInstance?.cleanUp()
+ sharedVirtualizer?.release()
+ sharedVirtualizer = undefined
+ })
+
+ return (
+ <div
+ data-component="file"
+ data-mode="diff"
+ style={styleVariables}
+ class={local.class}
+ classList={local.classList}
+ ref={container}
+ >
+ <Dynamic component={DIFFS_TAG_NAME} ref={fileDiffRef} id="ssr-diff">
+ <Show when={isServer}>
+ <template shadowrootmode="open" innerHTML={local.preloadedDiff.prerenderedHTML} />
+ </Show>
+ </Dynamic>
+ </div>
+ )
+}
+
+export type FileSSRProps<T = {}> = FileProps<T>
+
+export function FileSSR<T>(props: FileSSRProps<T>) {
+ if (props.mode !== "diff" || !props.preloadedDiff) return File(props)
+ return DiffSSRViewer(props as SSRDiffFileProps<T>)
+}
diff --git a/packages/ui/src/components/diff.css b/packages/ui/src/components/file.css
index 1d94e417a..a9150e145 100644
--- a/packages/ui/src/components/diff.css
+++ b/packages/ui/src/components/file.css
@@ -1,6 +1,12 @@
-[data-component="diff"] {
+[data-component="file"] {
content-visibility: auto;
+}
+
+[data-component="file"][data-mode="text"] {
+ overflow: hidden;
+}
+[data-component="file"][data-mode="diff"] {
[data-slot="diff-hunk-separator-line-number"] {
position: sticky;
left: 0;
@@ -17,6 +23,7 @@
color: var(--icon-strong-base);
}
}
+
[data-slot="diff-hunk-separator-content"] {
position: sticky;
background-color: var(--surface-diff-hidden-base);
diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx
new file mode 100644
index 000000000..f42fbb24d
--- /dev/null
+++ b/packages/ui/src/components/file.tsx
@@ -0,0 +1,1176 @@
+import { sampledChecksum } from "@opencode-ai/util/encode"
+import {
+ DEFAULT_VIRTUAL_FILE_METRICS,
+ type ExpansionDirections,
+ type DiffLineAnnotation,
+ type FileContents,
+ type FileDiffMetadata,
+ File as PierreFile,
+ type FileDiffOptions,
+ FileDiff,
+ type FileOptions,
+ type LineAnnotation,
+ type SelectedLineRange,
+ type VirtualFileMetrics,
+ VirtualizedFile,
+ VirtualizedFileDiff,
+ Virtualizer,
+} from "@pierre/diffs"
+import { type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
+import { createMediaQuery } from "@solid-primitives/media"
+import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
+import { createDefaultOptions, styleVariables } from "../pierre"
+import { markCommentedDiffLines, markCommentedFileLines } from "../pierre/commented-lines"
+import { fixDiffSelection, findDiffSide, type DiffSelectionSide } from "../pierre/diff-selection"
+import { createFileFind, type FileFindReveal } from "../pierre/file-find"
+import {
+ applyViewerScheme,
+ clearReadyWatcher,
+ createReadyWatcher,
+ getViewerHost,
+ getViewerRoot,
+ notifyShadowReady,
+ observeViewerScheme,
+} from "../pierre/file-runtime"
+import {
+ findCodeSelectionSide,
+ findDiffLineNumber,
+ findElement,
+ findFileLineNumber,
+ readShadowLineSelection,
+} from "../pierre/file-selection"
+import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
+import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
+import { getWorkerPool } from "../pierre/worker"
+import { FileMedia, type FileMediaOptions } from "./file-media"
+import { FileSearchBar } from "./file-search"
+
+const VIRTUALIZE_BYTES = 500_000
+
+const codeMetrics = {
+ ...DEFAULT_VIRTUAL_FILE_METRICS,
+ lineHeight: 24,
+ fileGap: 0,
+} satisfies Partial<VirtualFileMetrics>
+
+type SharedProps<T> = {
+ annotations?: LineAnnotation<T>[] | DiffLineAnnotation<T>[]
+ selectedLines?: SelectedLineRange | null
+ commentedLines?: SelectedLineRange[]
+ onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
+ onRendered?: () => void
+ class?: string
+ classList?: ComponentProps<"div">["classList"]
+ media?: FileMediaOptions
+ search?: FileSearchControl
+}
+
+export type FileSearchReveal = FileFindReveal
+
+export type FileSearchHandle = {
+ focus: () => void
+ setQuery: (value: string) => void
+ clear: () => void
+ reveal: (hit: FileSearchReveal) => boolean
+ expand: (hit: FileSearchReveal) => boolean
+ refresh: () => void
+}
+
+export type FileSearchControl = {
+ shortcuts?: "global" | "disabled"
+ showBar?: boolean
+ disableVirtualization?: boolean
+ register: (handle: FileSearchHandle | null) => void
+}
+
+export type TextFileProps<T = {}> = FileOptions<T> &
+ SharedProps<T> & {
+ mode: "text"
+ file: FileContents
+ annotations?: LineAnnotation<T>[]
+ preloadedDiff?: PreloadMultiFileDiffResult<T>
+ }
+
+export type DiffFileProps<T = {}> = FileDiffOptions<T> &
+ SharedProps<T> & {
+ mode: "diff"
+ before: FileContents
+ after: FileContents
+ annotations?: DiffLineAnnotation<T>[]
+ preloadedDiff?: PreloadMultiFileDiffResult<T>
+ }
+
+export type FileProps<T = {}> = TextFileProps<T> | DiffFileProps<T>
+
+const sharedKeys = [
+ "mode",
+ "media",
+ "class",
+ "classList",
+ "annotations",
+ "selectedLines",
+ "commentedLines",
+ "search",
+ "onLineSelected",
+ "onLineSelectionEnd",
+ "onLineNumberSelectionEnd",
+ "onRendered",
+ "preloadedDiff",
+] as const
+
+const textKeys = ["file", ...sharedKeys] as const
+const diffKeys = ["before", "after", ...sharedKeys] as const
+
+function expansionForHit(diff: FileDiffMetadata, hit: FileSearchReveal) {
+ if (diff.isPartial || diff.hunks.length === 0) return
+
+ const side =
+ hit.side === "deletions"
+ ? {
+ start: (hunk: FileDiffMetadata["hunks"][number]) => hunk.deletionStart,
+ count: (hunk: FileDiffMetadata["hunks"][number]) => hunk.deletionCount,
+ }
+ : {
+ start: (hunk: FileDiffMetadata["hunks"][number]) => hunk.additionStart,
+ count: (hunk: FileDiffMetadata["hunks"][number]) => hunk.additionCount,
+ }
+
+ for (let i = 0; i < diff.hunks.length; i++) {
+ const hunk = diff.hunks[i]
+ const start = side.start(hunk)
+ if (hit.line < start) {
+ return {
+ index: i,
+ direction: i === 0 ? "down" : "both",
+ } satisfies { index: number; direction: ExpansionDirections }
+ }
+
+ const end = start + Math.max(side.count(hunk) - 1, -1)
+ if (hit.line <= end) return
+ }
+
+ return {
+ index: diff.hunks.length,
+ direction: "up",
+ } satisfies { index: number; direction: ExpansionDirections }
+}
+
+// ---------------------------------------------------------------------------
+// Shared viewer hook
+// ---------------------------------------------------------------------------
+
+type MouseHit = {
+ line: number | undefined
+ numberColumn: boolean
+ side?: DiffSelectionSide
+}
+
+type ViewerConfig = {
+ enableLineSelection: () => boolean
+ search: () => FileSearchControl | undefined
+ selectedLines: () => SelectedLineRange | null | undefined
+ commentedLines: () => SelectedLineRange[]
+ onLineSelectionEnd: (range: SelectedLineRange | null) => void
+
+ // mode-specific callbacks
+ lineFromMouseEvent: (event: MouseEvent) => MouseHit
+ setSelectedLines: (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => void
+ updateSelection: (preserveTextSelection: boolean) => void
+ buildDragSelection: () => SelectedLineRange | undefined
+ buildClickSelection: () => SelectedLineRange | undefined
+ onDragStart: (hit: MouseHit) => void
+ onDragMove: (hit: MouseHit) => void
+ onDragReset: () => void
+ markCommented: (root: ShadowRoot, ranges: SelectedLineRange[]) => void
+}
+
+function useFileViewer(config: ViewerConfig) {
+ let wrapper!: HTMLDivElement
+ let container!: HTMLDivElement
+ let overlay!: HTMLDivElement
+ let selectionFrame: number | undefined
+ let dragFrame: number | undefined
+ let dragStart: number | undefined
+ let dragEnd: number | undefined
+ let dragMoved = false
+ let lastSelection: SelectedLineRange | null = null
+ let pendingSelectionEnd = false
+
+ const ready = createReadyWatcher()
+ const bridge = createLineNumberSelectionBridge()
+ const [rendered, setRendered] = createSignal(0)
+
+ const getRoot = () => getViewerRoot(container)
+ const getHost = () => getViewerHost(container)
+
+ const find = createFileFind({
+ wrapper: () => wrapper,
+ overlay: () => overlay,
+ getRoot,
+ shortcuts: config.search()?.shortcuts,
+ })
+
+ // -- selection scheduling --
+
+ const scheduleSelectionUpdate = () => {
+ if (selectionFrame !== undefined) return
+ selectionFrame = requestAnimationFrame(() => {
+ selectionFrame = undefined
+ const finishing = pendingSelectionEnd
+ config.updateSelection(finishing)
+ if (!pendingSelectionEnd) return
+ pendingSelectionEnd = false
+ config.onLineSelectionEnd(lastSelection)
+ })
+ }
+
+ const scheduleDragUpdate = () => {
+ if (dragFrame !== undefined) return
+ dragFrame = requestAnimationFrame(() => {
+ dragFrame = undefined
+ const selected = config.buildDragSelection()
+ if (selected) config.setSelectedLines(selected)
+ })
+ }
+
+ // -- mouse handlers --
+
+ const handleMouseDown = (event: MouseEvent) => {
+ if (!config.enableLineSelection()) return
+ if (event.button !== 0) return
+
+ const hit = config.lineFromMouseEvent(event)
+ if (hit.numberColumn) {
+ bridge.begin(true, hit.line)
+ return
+ }
+ if (hit.line === undefined) return
+
+ bridge.begin(false, hit.line)
+ dragStart = hit.line
+ dragEnd = hit.line
+ dragMoved = false
+ config.onDragStart(hit)
+ }
+
+ const handleMouseMove = (event: MouseEvent) => {
+ if (!config.enableLineSelection()) return
+
+ const hit = config.lineFromMouseEvent(event)
+ if (bridge.track(event.buttons, hit.line)) return
+ if (dragStart === undefined) return
+
+ if ((event.buttons & 1) === 0) {
+ dragStart = undefined
+ dragEnd = undefined
+ dragMoved = false
+ config.onDragReset()
+ bridge.finish()
+ return
+ }
+
+ if (hit.line === undefined) return
+ dragEnd = hit.line
+ dragMoved = true
+ config.onDragMove(hit)
+ scheduleDragUpdate()
+ }
+
+ const handleMouseUp = () => {
+ if (!config.enableLineSelection()) return
+ if (bridge.finish() === "numbers") return
+ if (dragStart === undefined) return
+
+ if (!dragMoved) {
+ pendingSelectionEnd = false
+ const selected = config.buildClickSelection()
+ if (selected) config.setSelectedLines(selected)
+ config.onLineSelectionEnd(lastSelection)
+ dragStart = undefined
+ dragEnd = undefined
+ dragMoved = false
+ config.onDragReset()
+ return
+ }
+
+ pendingSelectionEnd = true
+ scheduleDragUpdate()
+ scheduleSelectionUpdate()
+
+ dragStart = undefined
+ dragEnd = undefined
+ dragMoved = false
+ config.onDragReset()
+ }
+
+ const handleSelectionChange = () => {
+ if (!config.enableLineSelection()) return
+ if (dragStart === undefined) return
+ const selection = window.getSelection()
+ if (!selection || selection.isCollapsed) return
+ scheduleSelectionUpdate()
+ }
+
+ // -- shared effects --
+
+ onMount(() => {
+ onCleanup(observeViewerScheme(getHost))
+ })
+
+ createEffect(() => {
+ rendered()
+ const ranges = config.commentedLines()
+ requestAnimationFrame(() => {
+ const root = getRoot()
+ if (!root) return
+ config.markCommented(root, ranges)
+ })
+ })
+
+ createEffect(() => {
+ config.setSelectedLines(config.selectedLines() ?? null)
+ })
+
+ createEffect(() => {
+ if (!config.enableLineSelection()) return
+
+ container.addEventListener("mousedown", handleMouseDown)
+ container.addEventListener("mousemove", handleMouseMove)
+ window.addEventListener("mouseup", handleMouseUp)
+ document.addEventListener("selectionchange", handleSelectionChange)
+
+ onCleanup(() => {
+ container.removeEventListener("mousedown", handleMouseDown)
+ container.removeEventListener("mousemove", handleMouseMove)
+ window.removeEventListener("mouseup", handleMouseUp)
+ document.removeEventListener("selectionchange", handleSelectionChange)
+ })
+ })
+
+ onCleanup(() => {
+ clearReadyWatcher(ready)
+
+ if (selectionFrame !== undefined) cancelAnimationFrame(selectionFrame)
+ if (dragFrame !== undefined) cancelAnimationFrame(dragFrame)
+
+ selectionFrame = undefined
+ dragFrame = undefined
+ dragStart = undefined
+ dragEnd = undefined
+ dragMoved = false
+ bridge.reset()
+ lastSelection = null
+ pendingSelectionEnd = false
+ })
+
+ return {
+ get wrapper() {
+ return wrapper
+ },
+ set wrapper(v: HTMLDivElement) {
+ wrapper = v
+ },
+ get container() {
+ return container
+ },
+ set container(v: HTMLDivElement) {
+ container = v
+ },
+ get overlay() {
+ return overlay
+ },
+ set overlay(v: HTMLDivElement) {
+ overlay = v
+ },
+ get dragStart() {
+ return dragStart
+ },
+ get dragEnd() {
+ return dragEnd
+ },
+ get lastSelection() {
+ return lastSelection
+ },
+ set lastSelection(v: SelectedLineRange | null) {
+ lastSelection = v
+ },
+ ready,
+ bridge,
+ rendered,
+ setRendered,
+ getRoot,
+ getHost,
+ find,
+ scheduleSelectionUpdate,
+ }
+}
+
+type Viewer = ReturnType<typeof useFileViewer>
+
+type ModeAdapter = Omit<
+ ViewerConfig,
+ "enableLineSelection" | "search" | "selectedLines" | "commentedLines" | "onLineSelectionEnd"
+>
+
+type ModeConfig = {
+ enableLineSelection: () => boolean
+ search: () => FileSearchControl | undefined
+ selectedLines: () => SelectedLineRange | null | undefined
+ commentedLines: () => SelectedLineRange[] | undefined
+ onLineSelectionEnd: (range: SelectedLineRange | null) => void
+}
+
+type RenderTarget = {
+ cleanUp: () => void
+}
+
+type AnnotationTarget<A> = {
+ setLineAnnotations: (annotations: A[]) => void
+ rerender: () => void
+}
+
+type VirtualStrategy = {
+ get: () => Virtualizer | undefined
+ cleanup: () => void
+}
+
+function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
+ return useFileViewer({
+ enableLineSelection: config.enableLineSelection,
+ search: config.search,
+ selectedLines: config.selectedLines,
+ commentedLines: () => config.commentedLines() ?? [],
+ onLineSelectionEnd: config.onLineSelectionEnd,
+ ...adapter,
+ })
+}
+
+function useSearchHandle(opts: {
+ search: () => FileSearchControl | undefined
+ find: ReturnType<typeof createFileFind>
+ expand?: (hit: FileSearchReveal) => boolean
+}) {
+ createEffect(() => {
+ const search = opts.search()
+ if (!search) return
+
+ const handle = {
+ focus: () => {
+ opts.find.focus()
+ },
+ setQuery: (value: string) => {
+ opts.find.activate()
+ opts.find.setQuery(value, { scroll: false })
+ },
+ clear: () => {
+ opts.find.clear()
+ },
+ reveal: (hit: FileSearchReveal) => {
+ opts.find.activate()
+ return opts.find.reveal(hit)
+ },
+ expand: (hit: FileSearchReveal) => opts.expand?.(hit) ?? false,
+ refresh: () => {
+ opts.find.activate()
+ opts.find.refresh()
+ },
+ } satisfies FileSearchHandle
+
+ search.register(handle)
+ onCleanup(() => search.register(null))
+ })
+}
+
+function createLineCallbacks(opts: {
+ viewer: Viewer
+ normalize?: (range: SelectedLineRange | null) => SelectedLineRange | null | undefined
+ onLineSelected?: (range: SelectedLineRange | null) => void
+ onLineSelectionEnd?: (range: SelectedLineRange | null) => void
+ onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
+}) {
+ const select = (range: SelectedLineRange | null) => {
+ if (!opts.normalize) return range
+ const next = opts.normalize(range)
+ if (next !== undefined) return next
+ return range
+ }
+
+ return {
+ onLineSelected: (range: SelectedLineRange | null) => {
+ const next = select(range)
+ opts.viewer.lastSelection = next
+ opts.onLineSelected?.(next)
+ },
+ onLineSelectionEnd: (range: SelectedLineRange | null) => {
+ const next = select(range)
+ opts.viewer.lastSelection = next
+ opts.onLineSelectionEnd?.(next)
+ if (!opts.viewer.bridge.consume(next)) return
+ requestAnimationFrame(() => opts.onLineNumberSelectionEnd?.(next))
+ },
+ }
+}
+
+function useAnnotationRerender<A>(opts: {
+ viewer: Viewer
+ current: () => AnnotationTarget<A> | undefined
+ annotations: () => A[]
+}) {
+ createEffect(() => {
+ opts.viewer.rendered()
+ const active = opts.current()
+ if (!active) return
+ active.setLineAnnotations(opts.annotations())
+ active.rerender()
+ requestAnimationFrame(() => opts.viewer.find.refresh({ reset: true }))
+ })
+}
+
+function notifyRendered(opts: {
+ viewer: Viewer
+ isReady: (root: ShadowRoot) => boolean
+ settleFrames?: number
+ onReady: () => void
+}) {
+ notifyShadowReady({
+ state: opts.viewer.ready,
+ container: opts.viewer.container,
+ getRoot: opts.viewer.getRoot,
+ isReady: opts.isReady,
+ settleFrames: opts.settleFrames,
+ onReady: opts.onReady,
+ })
+}
+
+function renderViewer<I extends RenderTarget>(opts: {
+ viewer: Viewer
+ current: I | undefined
+ create: () => I
+ assign: (value: I) => void
+ draw: (value: I) => void
+ onReady: () => void
+}) {
+ clearReadyWatcher(opts.viewer.ready)
+ opts.current?.cleanUp()
+ const next = opts.create()
+ opts.assign(next)
+
+ opts.viewer.container.innerHTML = ""
+ opts.draw(next)
+
+ applyViewerScheme(opts.viewer.getHost())
+ opts.viewer.setRendered((value) => value + 1)
+ opts.onReady()
+}
+
+function scrollParent(el: HTMLElement): HTMLElement | undefined {
+ let parent = el.parentElement
+ while (parent) {
+ const style = getComputedStyle(parent)
+ if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
+ parent = parent.parentElement
+ }
+}
+
+function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
+ let virtualizer: Virtualizer | undefined
+ let root: Document | HTMLElement | undefined
+
+ const release = () => {
+ virtualizer?.cleanUp()
+ virtualizer = undefined
+ root = undefined
+ }
+
+ return {
+ get: () => {
+ if (!enabled()) {
+ release()
+ return
+ }
+ if (typeof document === "undefined") return
+
+ const wrapper = host()
+ if (!wrapper) return
+
+ const next = scrollParent(wrapper) ?? document
+ if (virtualizer && root === next) return virtualizer
+
+ release()
+ virtualizer = new Virtualizer()
+ root = next
+ virtualizer.setup(next, next instanceof Document ? undefined : wrapper)
+ return virtualizer
+ },
+ cleanup: release,
+ }
+}
+
+function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
+ let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
+
+ const release = () => {
+ shared?.release()
+ shared = undefined
+ }
+
+ return {
+ get: () => {
+ if (!enabled()) {
+ release()
+ return
+ }
+ if (shared) return shared.virtualizer
+
+ const container = host()
+ if (!container) return
+
+ const result = acquireVirtualizer(container)
+ if (!result) return
+ shared = result
+ return result.virtualizer
+ },
+ cleanup: release,
+ }
+}
+
+function parseLine(node: HTMLElement) {
+ if (!node.dataset.line) return
+ const value = parseInt(node.dataset.line, 10)
+ if (Number.isNaN(value)) return
+ return value
+}
+
+function mouseHit(
+ event: MouseEvent,
+ line: (node: HTMLElement) => number | undefined,
+ side?: (node: HTMLElement) => DiffSelectionSide | undefined,
+): MouseHit {
+ const path = event.composedPath()
+ let numberColumn = false
+ let value: number | undefined
+ let branch: DiffSelectionSide | undefined
+
+ for (const item of path) {
+ if (!(item instanceof HTMLElement)) continue
+
+ numberColumn = numberColumn || item.dataset.columnNumber != null
+ if (value === undefined) value = line(item)
+ if (branch === undefined && side) branch = side(item)
+
+ if (numberColumn && value !== undefined && (side == null || branch !== undefined)) break
+ }
+
+ return {
+ line: value,
+ numberColumn,
+ side: branch,
+ }
+}
+
+function diffMouseSide(node: HTMLElement) {
+ const type = node.dataset.lineType
+ if (type === "change-deletion") return "deletions" satisfies DiffSelectionSide
+ if (type === "change-addition" || type === "change-additions") return "additions" satisfies DiffSelectionSide
+ if (node.dataset.code == null) return
+ return node.hasAttribute("data-deletions") ? "deletions" : "additions"
+}
+
+function diffSelectionSide(node: Node | null) {
+ const el = findElement(node)
+ if (!el) return
+ return findDiffSide(el)
+}
+
+// ---------------------------------------------------------------------------
+// Shared JSX shell
+// ---------------------------------------------------------------------------
+
+function ViewerShell(props: {
+ mode: "text" | "diff"
+ viewer: ReturnType<typeof useFileViewer>
+ search: FileSearchControl | undefined
+ class: string | undefined
+ classList: ComponentProps<"div">["classList"] | undefined
+}) {
+ return (
+ <div
+ data-component="file"
+ data-mode={props.mode}
+ style={styleVariables}
+ class="relative outline-none"
+ classList={{
+ ...(props.classList || {}),
+ [props.class ?? ""]: !!props.class,
+ }}
+ ref={(el) => (props.viewer.wrapper = el)}
+ tabIndex={0}
+ onPointerDown={props.viewer.find.onPointerDown}
+ onFocus={props.viewer.find.onFocus}
+ >
+ <Show when={(props.search?.showBar ?? true) && props.viewer.find.open()}>
+ <FileSearchBar
+ pos={props.viewer.find.pos}
+ query={props.viewer.find.query}
+ count={props.viewer.find.count}
+ index={props.viewer.find.index}
+ setInput={props.viewer.find.setInput}
+ onInput={props.viewer.find.setQuery}
+ onKeyDown={props.viewer.find.onInputKeyDown}
+ onClose={props.viewer.find.close}
+ onPrev={() => props.viewer.find.next(-1)}
+ onNext={() => props.viewer.find.next(1)}
+ />
+ </Show>
+ <div ref={(el) => (props.viewer.container = el)} />
+ <div ref={(el) => (props.viewer.overlay = el)} class="pointer-events-none absolute inset-0 z-0" />
+ </div>
+ )
+}
+
+// ---------------------------------------------------------------------------
+// TextViewer
+// ---------------------------------------------------------------------------
+
+function TextViewer<T>(props: TextFileProps<T>) {
+ let instance: PierreFile<T> | VirtualizedFile<T> | undefined
+ let viewer!: Viewer
+
+ const [local, others] = splitProps(props, textKeys)
+
+ const text = () => {
+ const value = local.file.contents as unknown
+ if (typeof value === "string") return value
+ if (Array.isArray(value)) return value.join("\n")
+ if (value == null) return ""
+ return String(value)
+ }
+
+ const lineCount = () => {
+ const value = text()
+ const total = value.split("\n").length - (value.endsWith("\n") ? 1 : 0)
+ return Math.max(1, total)
+ }
+
+ const bytes = createMemo(() => {
+ const value = local.file.contents as unknown
+ if (typeof value === "string") return value.length
+ if (Array.isArray(value)) {
+ return value.reduce(
+ (sum, part) => sum + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
+ 0,
+ )
+ }
+ if (value == null) return 0
+ return String(value).length
+ })
+
+ const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES)
+
+ const virtuals = createLocalVirtualStrategy(() => viewer.wrapper, virtual)
+
+ const lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, parseLine)
+
+ const applySelection = (range: SelectedLineRange | null) => {
+ const current = instance
+ if (!current) return false
+
+ if (virtual()) {
+ current.setSelectedLines(range)
+ return true
+ }
+
+ const root = viewer.getRoot()
+ if (!root) return false
+
+ const total = lineCount()
+ if (root.querySelectorAll("[data-line]").length < total) return false
+
+ if (!range) {
+ current.setSelectedLines(null)
+ return true
+ }
+
+ const start = Math.min(range.start, range.end)
+ const end = Math.max(range.start, range.end)
+ if (start < 1 || end > total) {
+ current.setSelectedLines(null)
+ return true
+ }
+
+ if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) {
+ current.setSelectedLines(null)
+ return true
+ }
+
+ const normalized = (() => {
+ if (range.endSide != null) return { start: range.start, end: range.end }
+ if (range.side !== "deletions") return range
+ if (root.querySelector("[data-deletions]") != null) return range
+ return { start: range.start, end: range.end }
+ })()
+
+ current.setSelectedLines(normalized)
+ return true
+ }
+
+ const setSelectedLines = (range: SelectedLineRange | null) => {
+ viewer.lastSelection = range
+ applySelection(range)
+ }
+
+ const adapter: ModeAdapter = {
+ lineFromMouseEvent,
+ setSelectedLines,
+ updateSelection: (preserveTextSelection) => {
+ const root = viewer.getRoot()
+ if (!root) return
+
+ const selected = readShadowLineSelection({
+ root,
+ lineForNode: findFileLineNumber,
+ sideForNode: findCodeSelectionSide,
+ preserveTextSelection,
+ })
+ if (!selected) return
+
+ setSelectedLines(selected.range)
+ if (!preserveTextSelection || !selected.text) return
+ restoreShadowTextSelection(root, selected.text)
+ },
+ buildDragSelection: () => {
+ if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
+ return { start: Math.min(viewer.dragStart, viewer.dragEnd), end: Math.max(viewer.dragStart, viewer.dragEnd) }
+ },
+ buildClickSelection: () => {
+ if (viewer.dragStart === undefined) return
+ return { start: viewer.dragStart, end: viewer.dragStart }
+ },
+ onDragStart: () => {},
+ onDragMove: () => {},
+ onDragReset: () => {},
+ markCommented: markCommentedFileLines,
+ }
+
+ viewer = useModeViewer(
+ {
+ enableLineSelection: () => props.enableLineSelection === true,
+ search: () => local.search,
+ selectedLines: () => local.selectedLines,
+ commentedLines: () => local.commentedLines,
+ onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
+ },
+ adapter,
+ )
+
+ const lineCallbacks = createLineCallbacks({
+ viewer,
+ onLineSelected: (range) => local.onLineSelected?.(range),
+ onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
+ onLineNumberSelectionEnd: (range) => local.onLineNumberSelectionEnd?.(range),
+ })
+
+ const options = createMemo(() => ({
+ ...createDefaultOptions<T>("unified"),
+ ...others,
+ ...lineCallbacks,
+ }))
+
+ const notify = () => {
+ notifyRendered({
+ viewer,
+ isReady: (root) => {
+ if (virtual()) return root.querySelector("[data-line]") != null
+ return root.querySelectorAll("[data-line]").length >= lineCount()
+ },
+ onReady: () => {
+ applySelection(viewer.lastSelection)
+ viewer.find.refresh({ reset: true })
+ local.onRendered?.()
+ },
+ })
+ }
+
+ useSearchHandle({
+ search: () => local.search,
+ find: viewer.find,
+ })
+
+ // -- render instance --
+
+ createEffect(() => {
+ const opts = options()
+ const workerPool = getWorkerPool("unified")
+ const isVirtual = virtual()
+
+ const virtualizer = virtuals.get()
+
+ renderViewer({
+ viewer,
+ current: instance,
+ create: () =>
+ isVirtual && virtualizer
+ ? new VirtualizedFile<T>(opts, virtualizer, codeMetrics, workerPool)
+ : new PierreFile<T>(opts, workerPool),
+ assign: (value) => {
+ instance = value
+ },
+ draw: (value) => {
+ const contents = text()
+ value.render({
+ file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents },
+ lineAnnotations: [],
+ containerWrapper: viewer.container,
+ })
+ },
+ onReady: notify,
+ })
+ })
+
+ useAnnotationRerender<LineAnnotation<T>>({
+ viewer,
+ current: () => instance,
+ annotations: () => (local.annotations as LineAnnotation<T>[] | undefined) ?? [],
+ })
+
+ // -- cleanup --
+
+ onCleanup(() => {
+ instance?.cleanUp()
+ instance = undefined
+ virtuals.cleanup()
+ })
+
+ return (
+ <ViewerShell mode="text" viewer={viewer} search={local.search} class={local.class} classList={local.classList} />
+ )
+}
+
+// ---------------------------------------------------------------------------
+// DiffViewer
+// ---------------------------------------------------------------------------
+
+function DiffViewer<T>(props: DiffFileProps<T>) {
+ let instance: FileDiff<T> | undefined
+ let dragSide: DiffSelectionSide | undefined
+ let dragEndSide: DiffSelectionSide | undefined
+ let viewer!: Viewer
+
+ const [local, others] = splitProps(props, diffKeys)
+
+ const mobile = createMediaQuery("(max-width: 640px)")
+
+ const lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, findDiffLineNumber, diffMouseSide)
+
+ const setSelectedLines = (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => {
+ const active = instance
+ if (!active) return
+
+ const fixed = fixDiffSelection(viewer.getRoot(), range)
+ if (fixed === undefined) {
+ viewer.lastSelection = range
+ return
+ }
+
+ viewer.lastSelection = fixed
+ active.setSelectedLines(fixed)
+ restoreShadowTextSelection(preserve?.root, preserve?.text)
+ }
+
+ const adapter: ModeAdapter = {
+ lineFromMouseEvent,
+ setSelectedLines,
+ updateSelection: (preserveTextSelection) => {
+ const root = viewer.getRoot()
+ if (!root) return
+
+ const selected = readShadowLineSelection({
+ root,
+ lineForNode: findDiffLineNumber,
+ sideForNode: diffSelectionSide,
+ preserveTextSelection,
+ })
+ if (!selected) return
+
+ if (selected.text) {
+ setSelectedLines(selected.range, { root, text: selected.text })
+ return
+ }
+
+ setSelectedLines(selected.range)
+ },
+ buildDragSelection: () => {
+ if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
+ const selected: SelectedLineRange = { start: viewer.dragStart, end: viewer.dragEnd }
+ if (dragSide) selected.side = dragSide
+ if (dragEndSide && dragSide && dragEndSide !== dragSide) selected.endSide = dragEndSide
+ return selected
+ },
+ buildClickSelection: () => {
+ if (viewer.dragStart === undefined) return
+ const selected: SelectedLineRange = { start: viewer.dragStart, end: viewer.dragStart }
+ if (dragSide) selected.side = dragSide
+ return selected
+ },
+ onDragStart: (hit) => {
+ dragSide = hit.side
+ dragEndSide = hit.side
+ },
+ onDragMove: (hit) => {
+ dragEndSide = hit.side
+ },
+ onDragReset: () => {
+ dragSide = undefined
+ dragEndSide = undefined
+ },
+ markCommented: markCommentedDiffLines,
+ }
+
+ viewer = useModeViewer(
+ {
+ enableLineSelection: () => props.enableLineSelection === true,
+ search: () => local.search,
+ selectedLines: () => local.selectedLines,
+ commentedLines: () => local.commentedLines,
+ onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
+ },
+ adapter,
+ )
+
+ const virtuals = createSharedVirtualStrategy(
+ () => viewer.container,
+ () => local.search?.disableVirtualization !== true,
+ )
+
+ const large = createMemo(() => {
+ const before = typeof local.before?.contents === "string" ? local.before.contents : ""
+ const after = typeof local.after?.contents === "string" ? local.after.contents : ""
+ return Math.max(before.length, after.length) > 500_000
+ })
+
+ const largeOptions = {
+ lineDiffType: "none",
+ maxLineDiffLength: 0,
+ tokenizeMaxLineLength: 1,
+ } satisfies Pick<FileDiffOptions<T>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
+
+ const lineCallbacks = createLineCallbacks({
+ viewer,
+ normalize: (range) => fixDiffSelection(viewer.getRoot(), range),
+ onLineSelected: (range) => local.onLineSelected?.(range),
+ onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
+ onLineNumberSelectionEnd: (range) => local.onLineNumberSelectionEnd?.(range),
+ })
+
+ const options = createMemo<FileDiffOptions<T>>(() => {
+ const base = {
+ ...createDefaultOptions(props.diffStyle),
+ ...others,
+ ...lineCallbacks,
+ }
+
+ const perf = large() ? { ...base, ...largeOptions } : base
+ if (!mobile()) return perf
+ return { ...perf, disableLineNumbers: true }
+ })
+
+ const notify = () => {
+ notifyRendered({
+ viewer,
+ isReady: (root) => root.querySelector("[data-line]") != null,
+ settleFrames: 1,
+ onReady: () => {
+ setSelectedLines(viewer.lastSelection)
+ viewer.find.refresh({ reset: true })
+ local.onRendered?.()
+ },
+ })
+ }
+
+ useSearchHandle({
+ search: () => local.search,
+ find: viewer.find,
+ expand: (hit) => {
+ const active = instance as
+ | ((FileDiff<T> | VirtualizedFileDiff<T>) & {
+ fileDiff?: FileDiffMetadata
+ })
+ | undefined
+ if (!active?.fileDiff) return false
+
+ const next = expansionForHit(active.fileDiff, hit)
+ if (!next) return false
+
+ active.expandHunk(next.index, next.direction)
+ return true
+ },
+ })
+
+ // -- render instance --
+
+ createEffect(() => {
+ const opts = options()
+ const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
+ const virtualizer = virtuals.get()
+ const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
+ const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
+
+ const cacheKey = (contents: string) => {
+ if (!large()) return sampledChecksum(contents, contents.length)
+ return sampledChecksum(contents)
+ }
+
+ renderViewer({
+ viewer,
+ current: instance,
+ create: () =>
+ virtualizer
+ ? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
+ : new FileDiff<T>(opts, workerPool),
+ assign: (value) => {
+ instance = value
+ },
+ draw: (value) => {
+ value.render({
+ oldFile: { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) },
+ newFile: { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) },
+ lineAnnotations: [],
+ containerWrapper: viewer.container,
+ })
+ },
+ onReady: notify,
+ })
+ })
+
+ useAnnotationRerender<DiffLineAnnotation<T>>({
+ viewer,
+ current: () => instance,
+ annotations: () => (local.annotations as DiffLineAnnotation<T>[] | undefined) ?? [],
+ })
+
+ // -- cleanup --
+
+ onCleanup(() => {
+ instance?.cleanUp()
+ instance = undefined
+ virtuals.cleanup()
+ dragSide = undefined
+ dragEndSide = undefined
+ })
+
+ return (
+ <ViewerShell mode="diff" viewer={viewer} search={local.search} class={local.class} classList={local.classList} />
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+export function File<T>(props: FileProps<T>) {
+ if (props.mode === "text") {
+ return <FileMedia media={props.media} fallback={() => TextViewer(props)} />
+ }
+
+ return <FileMedia media={props.media} fallback={() => DiffViewer(props)} />
+}
diff --git a/packages/ui/src/components/line-comment-annotations.tsx b/packages/ui/src/components/line-comment-annotations.tsx
new file mode 100644
index 000000000..6b072d9c5
--- /dev/null
+++ b/packages/ui/src/components/line-comment-annotations.tsx
@@ -0,0 +1,586 @@
+import { type DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs"
+import { createEffect, createMemo, createSignal, onCleanup, Show, type Accessor, type JSX } from "solid-js"
+import { render as renderSolid } from "solid-js/web"
+import { createHoverCommentUtility } from "../pierre/comment-hover"
+import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge"
+import { LineComment, LineCommentEditor } from "./line-comment"
+
+export type LineCommentAnnotationMeta<T> =
+ | { kind: "comment"; key: string; comment: T }
+ | { kind: "draft"; key: string; range: SelectedLineRange }
+
+export type LineCommentAnnotation<T> = {
+ lineNumber: number
+ side?: "additions" | "deletions"
+ metadata: LineCommentAnnotationMeta<T>
+}
+
+type LineCommentAnnotationsProps<T> = {
+ comments: Accessor<T[]>
+ getCommentId: (comment: T) => string
+ getCommentSelection: (comment: T) => SelectedLineRange
+ draftRange: Accessor<SelectedLineRange | null>
+ draftKey: Accessor<string>
+}
+
+type LineCommentAnnotationsWithSideProps<T> = LineCommentAnnotationsProps<T> & {
+ getSide: (range: SelectedLineRange) => "additions" | "deletions"
+}
+
+type HoverCommentLine = {
+ lineNumber: number
+ side?: "additions" | "deletions"
+}
+
+type LineCommentStateProps<T> = {
+ opened: Accessor<T | null>
+ setOpened: (id: T | null) => void
+ selected: Accessor<SelectedLineRange | null>
+ setSelected: (range: SelectedLineRange | null) => void
+ commenting: Accessor<SelectedLineRange | null>
+ setCommenting: (range: SelectedLineRange | null) => void
+ syncSelected?: (range: SelectedLineRange | null) => void
+ hoverSelected?: (range: SelectedLineRange) => void
+}
+
+type LineCommentShape = {
+ id: string
+ selection: SelectedLineRange
+ comment: string
+}
+
+type LineCommentControllerProps<T extends LineCommentShape> = {
+ comments: Accessor<T[]>
+ draftKey: Accessor<string>
+ label: string
+ state: LineCommentStateProps<string>
+ onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
+ onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
+ onDelete?: (comment: T) => void
+ renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element
+ editSubmitLabel?: string
+ onDraftPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
+ getHoverSelectedRange?: Accessor<SelectedLineRange | null>
+ cancelDraftOnCommentToggle?: boolean
+ clearSelectionOnSelectionEndNull?: boolean
+}
+
+type LineCommentControllerWithSideProps<T extends LineCommentShape> = LineCommentControllerProps<T> & {
+ getSide: (range: SelectedLineRange) => "additions" | "deletions"
+}
+
+type CommentProps = {
+ id?: string
+ open: boolean
+ comment: JSX.Element
+ selection: JSX.Element
+ actions?: JSX.Element
+ editor?: DraftProps
+ onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
+ onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
+}
+
+type DraftProps = {
+ value: string
+ selection: JSX.Element
+ onInput: (value: string) => void
+ onCancel: VoidFunction
+ onSubmit: (value: string) => void
+ onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
+ cancelLabel?: string
+ submitLabel?: string
+}
+
+export function createLineCommentAnnotationRenderer<T>(props: {
+ renderComment: (comment: T) => CommentProps
+ renderDraft: (range: SelectedLineRange) => DraftProps
+}) {
+ const nodes = new Map<
+ string,
+ {
+ host: HTMLDivElement
+ dispose: VoidFunction
+ setMeta: (meta: LineCommentAnnotationMeta<T>) => void
+ }
+ >()
+
+ const mount = (meta: LineCommentAnnotationMeta<T>) => {
+ if (typeof document === "undefined") return
+
+ const host = document.createElement("div")
+ host.setAttribute("data-prevent-autofocus", "")
+ const [current, setCurrent] = createSignal(meta)
+
+ const dispose = renderSolid(() => {
+ const active = current()
+ if (active.kind === "comment") {
+ const view = createMemo(() => {
+ const next = current()
+ if (next.kind !== "comment") return props.renderComment(active.comment)
+ return props.renderComment(next.comment)
+ })
+ return (
+ <Show
+ when={view().editor}
+ fallback={
+ <LineComment
+ inline
+ id={view().id}
+ open={view().open}
+ comment={view().comment}
+ selection={view().selection}
+ actions={view().actions}
+ onClick={view().onClick}
+ onMouseEnter={view().onMouseEnter}
+ />
+ }
+ >
+ <LineCommentEditor
+ inline
+ id={view().id}
+ value={view().editor!.value}
+ selection={view().editor!.selection}
+ onInput={view().editor!.onInput}
+ onCancel={view().editor!.onCancel}
+ onSubmit={view().editor!.onSubmit}
+ onPopoverFocusOut={view().editor!.onPopoverFocusOut}
+ cancelLabel={view().editor!.cancelLabel}
+ submitLabel={view().editor!.submitLabel}
+ />
+ </Show>
+ )
+ }
+
+ const view = createMemo(() => {
+ const next = current()
+ if (next.kind !== "draft") return props.renderDraft(active.range)
+ return props.renderDraft(next.range)
+ })
+ return (
+ <LineCommentEditor
+ inline
+ value={view().value}
+ selection={view().selection}
+ onInput={view().onInput}
+ onCancel={view().onCancel}
+ onSubmit={view().onSubmit}
+ onPopoverFocusOut={view().onPopoverFocusOut}
+ />
+ )
+ }, host)
+
+ const node = { host, dispose, setMeta: setCurrent }
+ nodes.set(meta.key, node)
+ return node
+ }
+
+ const render = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotation: A) => {
+ const meta = annotation.metadata
+ const node = nodes.get(meta.key) ?? mount(meta)
+ if (!node) return
+ node.setMeta(meta)
+ return node.host
+ }
+
+ const reconcile = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotations: A[]) => {
+ const next = new Set(annotations.map((annotation) => annotation.metadata.key))
+ for (const [key, node] of nodes) {
+ if (next.has(key)) continue
+ node.dispose()
+ nodes.delete(key)
+ }
+ }
+
+ const cleanup = () => {
+ for (const [, node] of nodes) node.dispose()
+ nodes.clear()
+ }
+
+ return { render, reconcile, cleanup }
+}
+
+export function createLineCommentState<T>(props: LineCommentStateProps<T>) {
+ const [draft, setDraft] = createSignal("")
+ const [editing, setEditing] = createSignal<T | null>(null)
+
+ const toRange = (range: SelectedLineRange | null) => (range ? cloneSelectedLineRange(range) : null)
+ const setSelected = (range: SelectedLineRange | null) => {
+ const next = toRange(range)
+ props.setSelected(next)
+ props.syncSelected?.(toRange(next))
+ return next
+ }
+
+ const setCommenting = (range: SelectedLineRange | null) => {
+ const next = toRange(range)
+ props.setCommenting(next)
+ return next
+ }
+
+ const closeComment = () => {
+ props.setOpened(null)
+ }
+
+ const cancelDraft = () => {
+ setDraft("")
+ setEditing(null)
+ setCommenting(null)
+ }
+
+ const reset = () => {
+ setDraft("")
+ setEditing(null)
+ props.setOpened(null)
+ props.setSelected(null)
+ props.setCommenting(null)
+ }
+
+ const openComment = (id: T, range: SelectedLineRange, options?: { cancelDraft?: boolean }) => {
+ if (options?.cancelDraft) cancelDraft()
+ props.setOpened(id)
+ setSelected(range)
+ }
+
+ const toggleComment = (id: T, range: SelectedLineRange, options?: { cancelDraft?: boolean }) => {
+ if (options?.cancelDraft) cancelDraft()
+ const next = props.opened() === id ? null : id
+ props.setOpened(next)
+ setSelected(range)
+ }
+
+ const openDraft = (range: SelectedLineRange) => {
+ const next = toRange(range)
+ setDraft("")
+ setEditing(null)
+ closeComment()
+ setSelected(next)
+ setCommenting(next)
+ }
+
+ const openEditor = (id: T, range: SelectedLineRange, value: string) => {
+ closeComment()
+ setSelected(range)
+ props.setCommenting(null)
+ setEditing(() => id)
+ setDraft(value)
+ }
+
+ const hoverComment = (range: SelectedLineRange) => {
+ const next = toRange(range)
+ if (!next) return
+ if (props.hoverSelected) {
+ props.hoverSelected(next)
+ return
+ }
+
+ setSelected(next)
+ }
+
+ const finishSelection = (range: SelectedLineRange) => {
+ closeComment()
+ setSelected(range)
+ cancelDraft()
+ }
+
+ createEffect(() => {
+ props.commenting()
+ setDraft("")
+ })
+
+ return {
+ draft,
+ setDraft,
+ editing,
+ opened: props.opened,
+ selected: props.selected,
+ commenting: props.commenting,
+ isOpen: (id: T) => props.opened() === id,
+ isEditing: (id: T) => editing() === id,
+ closeComment,
+ openComment,
+ toggleComment,
+ openDraft,
+ openEditor,
+ hoverComment,
+ cancelDraft,
+ finishSelection,
+ select: setSelected,
+ reset,
+ }
+}
+
+export function createLineCommentController<T extends LineCommentShape>(
+ props: LineCommentControllerWithSideProps<T>,
+): {
+ note: ReturnType<typeof createLineCommentState<string>>
+ annotations: Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
+ renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
+ renderHoverUtility: ReturnType<typeof createLineCommentHoverRenderer>
+ onLineSelected: (range: SelectedLineRange | null) => void
+ onLineSelectionEnd: (range: SelectedLineRange | null) => void
+ onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
+}
+export function createLineCommentController<T extends LineCommentShape>(
+ props: LineCommentControllerProps<T>,
+): {
+ note: ReturnType<typeof createLineCommentState<string>>
+ annotations: Accessor<LineCommentAnnotation<T>[]>
+ renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
+ renderHoverUtility: ReturnType<typeof createLineCommentHoverRenderer>
+ onLineSelected: (range: SelectedLineRange | null) => void
+ onLineSelectionEnd: (range: SelectedLineRange | null) => void
+ onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
+}
+export function createLineCommentController<T extends LineCommentShape>(
+ props: LineCommentControllerProps<T> | LineCommentControllerWithSideProps<T>,
+) {
+ const note = createLineCommentState<string>(props.state)
+
+ const annotations =
+ "getSide" in props
+ ? createLineCommentAnnotations({
+ comments: props.comments,
+ getCommentId: (comment) => comment.id,
+ getCommentSelection: (comment) => comment.selection,
+ draftRange: note.commenting,
+ draftKey: props.draftKey,
+ getSide: props.getSide,
+ })
+ : createLineCommentAnnotations({
+ comments: props.comments,
+ getCommentId: (comment) => comment.id,
+ getCommentSelection: (comment) => comment.selection,
+ draftRange: note.commenting,
+ draftKey: props.draftKey,
+ })
+
+ const { renderAnnotation } = createManagedLineCommentAnnotationRenderer<T>({
+ annotations,
+ renderComment: (comment) => {
+ const edit = () => note.openEditor(comment.id, comment.selection, comment.comment)
+ const remove = () => {
+ note.reset()
+ props.onDelete?.(comment)
+ }
+
+ return {
+ id: comment.id,
+ get open() {
+ return note.isOpen(comment.id) || note.isEditing(comment.id)
+ },
+ comment: comment.comment,
+ selection: formatSelectedLineLabel(comment.selection),
+ get actions() {
+ return props.renderCommentActions?.(comment, { edit, remove })
+ },
+ get editor() {
+ return note.isEditing(comment.id)
+ ? {
+ get value() {
+ return note.draft()
+ },
+ selection: formatSelectedLineLabel(comment.selection),
+ onInput: note.setDraft,
+ onCancel: note.cancelDraft,
+ onSubmit: (value: string) => {
+ props.onUpdate?.({
+ id: comment.id,
+ comment: value,
+ selection: cloneSelectedLineRange(comment.selection),
+ })
+ note.cancelDraft()
+ },
+ submitLabel: props.editSubmitLabel,
+ }
+ : undefined
+ },
+ onMouseEnter: () => note.hoverComment(comment.selection),
+ onClick: () => {
+ if (note.isEditing(comment.id)) return
+ note.toggleComment(comment.id, comment.selection, { cancelDraft: props.cancelDraftOnCommentToggle })
+ },
+ }
+ },
+ renderDraft: (range) => ({
+ get value() {
+ return note.draft()
+ },
+ selection: formatSelectedLineLabel(range),
+ onInput: note.setDraft,
+ onCancel: note.cancelDraft,
+ onSubmit: (comment) => {
+ props.onSubmit({ comment, selection: cloneSelectedLineRange(range) })
+ note.cancelDraft()
+ },
+ onPopoverFocusOut: props.onDraftPopoverFocusOut,
+ }),
+ })
+
+ const renderHoverUtility = createLineCommentHoverRenderer({
+ label: props.label,
+ getSelectedRange: () => {
+ if (note.opened()) return null
+ return props.getHoverSelectedRange?.() ?? note.selected()
+ },
+ onOpenDraft: note.openDraft,
+ })
+
+ const onLineSelected = (range: SelectedLineRange | null) => {
+ if (!range) {
+ note.select(null)
+ note.cancelDraft()
+ return
+ }
+
+ note.select(range)
+ }
+
+ const onLineSelectionEnd = (range: SelectedLineRange | null) => {
+ if (!range) {
+ if (props.clearSelectionOnSelectionEndNull) note.select(null)
+ note.cancelDraft()
+ return
+ }
+
+ note.finishSelection(range)
+ }
+
+ const onLineNumberSelectionEnd = (range: SelectedLineRange | null) => {
+ if (!range) return
+ note.openDraft(range)
+ }
+
+ return {
+ note,
+ annotations,
+ renderAnnotation,
+ renderHoverUtility,
+ onLineSelected,
+ onLineSelectionEnd,
+ onLineNumberSelectionEnd,
+ }
+}
+
+export function createLineCommentAnnotations<T>(
+ props: LineCommentAnnotationsWithSideProps<T>,
+): Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
+export function createLineCommentAnnotations<T>(
+ props: LineCommentAnnotationsProps<T>,
+): Accessor<LineCommentAnnotation<T>[]>
+export function createLineCommentAnnotations<T>(
+ props: LineCommentAnnotationsProps<T> | LineCommentAnnotationsWithSideProps<T>,
+) {
+ const line = (range: SelectedLineRange) => Math.max(range.start, range.end)
+
+ if ("getSide" in props) {
+ return createMemo<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>(() => {
+ const list = props.comments().map((comment) => {
+ const range = props.getCommentSelection(comment)
+ return {
+ side: props.getSide(range),
+ lineNumber: line(range),
+ metadata: {
+ kind: "comment",
+ key: `comment:${props.getCommentId(comment)}`,
+ comment,
+ } satisfies LineCommentAnnotationMeta<T>,
+ }
+ })
+
+ const range = props.draftRange()
+ if (!range) return list
+
+ return [
+ ...list,
+ {
+ side: props.getSide(range),
+ lineNumber: line(range),
+ metadata: {
+ kind: "draft",
+ key: `draft:${props.draftKey()}`,
+ range,
+ } satisfies LineCommentAnnotationMeta<T>,
+ },
+ ]
+ })
+ }
+
+ return createMemo<LineCommentAnnotation<T>[]>(() => {
+ const list = props.comments().map((comment) => {
+ const range = props.getCommentSelection(comment)
+ const entry: LineCommentAnnotation<T> = {
+ lineNumber: line(range),
+ metadata: {
+ kind: "comment",
+ key: `comment:${props.getCommentId(comment)}`,
+ comment,
+ },
+ }
+
+ return entry
+ })
+
+ const range = props.draftRange()
+ if (!range) return list
+
+ const draft: LineCommentAnnotation<T> = {
+ lineNumber: line(range),
+ metadata: {
+ kind: "draft",
+ key: `draft:${props.draftKey()}`,
+ range,
+ },
+ }
+
+ return [...list, draft]
+ })
+}
+
+export function createManagedLineCommentAnnotationRenderer<T>(props: {
+ annotations: Accessor<LineCommentAnnotation<T>[]>
+ renderComment: (comment: T) => CommentProps
+ renderDraft: (range: SelectedLineRange) => DraftProps
+}) {
+ const renderer = createLineCommentAnnotationRenderer<T>({
+ renderComment: props.renderComment,
+ renderDraft: props.renderDraft,
+ })
+
+ createEffect(() => {
+ renderer.reconcile(props.annotations())
+ })
+
+ onCleanup(() => {
+ renderer.cleanup()
+ })
+
+ return {
+ renderAnnotation: renderer.render,
+ }
+}
+
+export function createLineCommentHoverRenderer(props: {
+ label: string
+ getSelectedRange: Accessor<SelectedLineRange | null>
+ onOpenDraft: (range: SelectedLineRange) => void
+}) {
+ return (getHoveredLine: () => HoverCommentLine | undefined) =>
+ createHoverCommentUtility({
+ label: props.label,
+ getHoveredLine,
+ onSelect: (hovered) => {
+ const current = props.getSelectedRange()
+ if (current && lineInSelectedRange(current, hovered.lineNumber, hovered.side)) {
+ props.onOpenDraft(cloneSelectedLineRange(current))
+ return
+ }
+
+ const range: SelectedLineRange = {
+ start: hovered.lineNumber,
+ end: hovered.lineNumber,
+ }
+ if (hovered.side) range.side = hovered.side
+ props.onOpenDraft(range)
+ },
+ })
+}
diff --git a/packages/ui/src/components/line-comment.css b/packages/ui/src/components/line-comment-styles.ts
index 9dc8eb74f..d5be67554 100644
--- a/packages/ui/src/components/line-comment.css
+++ b/packages/ui/src/components/line-comment-styles.ts
@@ -1,9 +1,23 @@
+export const lineCommentStyles = `
+[data-annotation-slot] {
+ padding: 12px;
+ box-sizing: border-box;
+}
+
[data-component="line-comment"] {
position: absolute;
right: 24px;
z-index: var(--line-comment-z, 30);
}
+[data-component="line-comment"][data-inline] {
+ position: relative;
+ right: auto;
+ display: flex;
+ width: 100%;
+ align-items: flex-start;
+}
+
[data-component="line-comment"][data-open] {
z-index: var(--line-comment-open-z, 100);
}
@@ -21,10 +35,20 @@
border: none;
}
+[data-component="line-comment"][data-variant="add"] [data-slot="line-comment-button"] {
+ background: var(--syntax-diff-add);
+}
+
[data-component="line-comment"] [data-component="icon"] {
color: var(--white);
}
+[data-component="line-comment"] [data-slot="line-comment-icon"] {
+ width: 12px;
+ height: 12px;
+ color: var(--white);
+}
+
[data-component="line-comment"] [data-slot="line-comment-button"]:focus {
outline: none;
}
@@ -39,27 +63,56 @@
right: -8px;
z-index: var(--line-comment-popover-z, 40);
min-width: 200px;
- max-width: min(320px, calc(100vw - 48px));
+ max-width: none;
border-radius: 8px;
background: var(--surface-raised-stronger-non-alpha);
- box-shadow: var(--shadow-lg-border-base);
+ box-shadow: var(--shadow-xxs-border);
padding: 12px;
}
+[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"] {
+ position: relative;
+ top: auto;
+ right: auto;
+ margin-left: 8px;
+ flex: 0 1 600px;
+ width: min(100%, 600px);
+ max-width: min(100%, 600px);
+}
+
+[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"][data-inline-body] {
+ margin-left: 0;
+}
+
+[data-component="line-comment"][data-inline][data-variant="default"] [data-slot="line-comment-popover"][data-inline-body] {
+ cursor: pointer;
+}
+
[data-component="line-comment"][data-variant="editor"] [data-slot="line-comment-popover"] {
width: 380px;
- max-width: min(380px, calc(100vw - 48px));
+ max-width: none;
padding: 8px;
border-radius: 14px;
}
+[data-component="line-comment"][data-inline][data-variant="editor"] [data-slot="line-comment-popover"] {
+ flex-basis: 600px;
+}
+
[data-component="line-comment"] [data-slot="line-comment-content"] {
display: flex;
flex-direction: column;
gap: 6px;
}
+[data-component="line-comment"] [data-slot="line-comment-head"] {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+}
+
[data-component="line-comment"] [data-slot="line-comment-text"] {
+ flex: 1;
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-weight: var(--font-weight-regular);
@@ -69,6 +122,13 @@
white-space: pre-wrap;
}
+[data-component="line-comment"] [data-slot="line-comment-tools"] {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+}
+
[data-component="line-comment"] [data-slot="line-comment-label"],
[data-component="line-comment"] [data-slot="line-comment-editor-label"] {
font-family: var(--font-family-sans);
@@ -108,8 +168,56 @@
display: flex;
align-items: center;
gap: 8px;
+ padding-left: 8px;
}
[data-component="line-comment"] [data-slot="line-comment-editor-label"] {
margin-right: auto;
}
+
+[data-component="line-comment"] [data-slot="line-comment-action"] {
+ border: 1px solid var(--border-base);
+ background: var(--surface-base);
+ color: var(--text-strong);
+ border-radius: var(--radius-md);
+ height: 28px;
+ padding: 0 10px;
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-weight: var(--font-weight-medium);
+}
+
+[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="ghost"] {
+ background: transparent;
+}
+
+[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="primary"] {
+ background: var(--text-strong);
+ border-color: var(--text-strong);
+ color: var(--background-base);
+}
+
+[data-component="line-comment"] [data-slot="line-comment-action"]:disabled {
+ opacity: 0.5;
+ pointer-events: none;
+}
+`
+
+let installed = false
+
+export function installLineCommentStyles() {
+ if (installed) return
+ if (typeof document === "undefined") return
+
+ const id = "opencode-line-comment-styles"
+ if (document.getElementById(id)) {
+ installed = true
+ return
+ }
+
+ const style = document.createElement("style")
+ style.id = id
+ style.textContent = lineCommentStyles
+ document.head.appendChild(style)
+ installed = true
+}
diff --git a/packages/ui/src/components/line-comment.tsx b/packages/ui/src/components/line-comment.tsx
index 81e4759b0..6a247990b 100644
--- a/packages/ui/src/components/line-comment.tsx
+++ b/packages/ui/src/components/line-comment.tsx
@@ -1,52 +1,121 @@
-import { onMount, Show, splitProps, type JSX } from "solid-js"
+import { createEffect, createSignal, onMount, Show, splitProps, type JSX } from "solid-js"
import { Button } from "./button"
import { Icon } from "./icon"
+import { installLineCommentStyles } from "./line-comment-styles"
import { useI18n } from "../context/i18n"
-export type LineCommentVariant = "default" | "editor"
+installLineCommentStyles()
+
+export type LineCommentVariant = "default" | "editor" | "add"
+
+function InlineGlyph(props: { icon: "comment" | "plus" }) {
+ return (
+ <svg data-slot="line-comment-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
+ <Show
+ when={props.icon === "comment"}
+ fallback={
+ <path
+ d="M10 5.41699V10.0003M10 10.0003V14.5837M10 10.0003H5.4165M10 10.0003H14.5832"
+ stroke="currentColor"
+ stroke-linecap="square"
+ />
+ }
+ >
+ <path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square" />
+ </Show>
+ </svg>
+ )
+}
export type LineCommentAnchorProps = {
id?: string
top?: number
+ inline?: boolean
+ hideButton?: boolean
open: boolean
variant?: LineCommentVariant
+ icon?: "comment" | "plus"
+ buttonLabel?: string
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
class?: string
popoverClass?: string
- children: JSX.Element
+ children?: JSX.Element
}
export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
- const hidden = () => props.top === undefined
+ const hidden = () => !props.inline && props.top === undefined
const variant = () => props.variant ?? "default"
+ const icon = () => props.icon ?? "comment"
+ const inlineBody = () => props.inline && props.hideButton
return (
<div
data-component="line-comment"
+ data-prevent-autofocus=""
data-variant={variant()}
data-comment-id={props.id}
data-open={props.open ? "" : undefined}
+ data-inline={props.inline ? "" : undefined}
classList={{
[props.class ?? ""]: !!props.class,
}}
- style={{
- top: `${props.top ?? 0}px`,
- opacity: hidden() ? 0 : 1,
- "pointer-events": hidden() ? "none" : "auto",
- }}
+ style={
+ props.inline
+ ? undefined
+ : {
+ top: `${props.top ?? 0}px`,
+ opacity: hidden() ? 0 : 1,
+ "pointer-events": hidden() ? "none" : "auto",
+ }
+ }
>
- <button type="button" data-slot="line-comment-button" onClick={props.onClick} onMouseEnter={props.onMouseEnter}>
- <Icon name="comment" size="small" />
- </button>
- <Show when={props.open}>
+ <Show
+ when={inlineBody()}
+ fallback={
+ <>
+ <button
+ type="button"
+ aria-label={props.buttonLabel}
+ data-slot="line-comment-button"
+ on:mousedown={(e) => e.stopPropagation()}
+ on:mouseup={(e) => e.stopPropagation()}
+ on:click={props.onClick as any}
+ on:mouseenter={props.onMouseEnter as any}
+ >
+ <Show
+ when={props.inline}
+ fallback={<Icon name={icon() === "plus" ? "plus-small" : "comment"} size="small" />}
+ >
+ <InlineGlyph icon={icon()} />
+ </Show>
+ </button>
+ <Show when={props.open}>
+ <div
+ data-slot="line-comment-popover"
+ classList={{
+ [props.popoverClass ?? ""]: !!props.popoverClass,
+ }}
+ on:mousedown={(e) => e.stopPropagation()}
+ on:focusout={props.onPopoverFocusOut as any}
+ >
+ {props.children}
+ </div>
+ </Show>
+ </>
+ }
+ >
<div
data-slot="line-comment-popover"
+ data-inline-body=""
classList={{
[props.popoverClass ?? ""]: !!props.popoverClass,
}}
- onFocusOut={props.onPopoverFocusOut}
+ on:mousedown={(e) => e.stopPropagation()}
+ on:click={props.onClick as any}
+ on:mouseenter={props.onMouseEnter as any}
+ on:focusout={props.onPopoverFocusOut as any}
>
{props.children}
</div>
@@ -58,16 +127,22 @@ export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
export type LineCommentProps = Omit<LineCommentAnchorProps, "children" | "variant"> & {
comment: JSX.Element
selection: JSX.Element
+ actions?: JSX.Element
}
export const LineComment = (props: LineCommentProps) => {
const i18n = useI18n()
- const [split, rest] = splitProps(props, ["comment", "selection"])
+ const [split, rest] = splitProps(props, ["comment", "selection", "actions"])
return (
- <LineCommentAnchor {...rest} variant="default">
+ <LineCommentAnchor {...rest} variant="default" hideButton={props.inline}>
<div data-slot="line-comment-content">
- <div data-slot="line-comment-text">{split.comment}</div>
+ <div data-slot="line-comment-head">
+ <div data-slot="line-comment-text">{split.comment}</div>
+ <Show when={split.actions}>
+ <div data-slot="line-comment-tools">{split.actions}</div>
+ </Show>
+ </div>
<div data-slot="line-comment-label">
{i18n.t("ui.lineComment.label.prefix")}
{split.selection}
@@ -78,6 +153,25 @@ export const LineComment = (props: LineCommentProps) => {
)
}
+export type LineCommentAddProps = Omit<LineCommentAnchorProps, "children" | "variant" | "open" | "icon"> & {
+ label?: string
+}
+
+export const LineCommentAdd = (props: LineCommentAddProps) => {
+ const [split, rest] = splitProps(props, ["label"])
+ const i18n = useI18n()
+
+ return (
+ <LineCommentAnchor
+ {...rest}
+ open={false}
+ variant="add"
+ icon="plus"
+ buttonLabel={split.label ?? i18n.t("ui.lineComment.submit")}
+ />
+ )
+}
+
export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
value: string
selection: JSX.Element
@@ -109,11 +203,16 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
const refs = {
textarea: undefined as HTMLTextAreaElement | undefined,
}
+ const [text, setText] = createSignal(split.value)
const focus = () => refs.textarea?.focus()
+ createEffect(() => {
+ setText(split.value)
+ })
+
const submit = () => {
- const value = split.value.trim()
+ const value = text().trim()
if (!value) return
split.onSubmit(value)
}
@@ -124,7 +223,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
})
return (
- <LineCommentAnchor {...rest} open={true} variant="editor" onClick={() => focus()}>
+ <LineCommentAnchor {...rest} open={true} variant="editor" hideButton={props.inline} onClick={() => focus()}>
<div data-slot="line-comment-editor">
<textarea
ref={(el) => {
@@ -133,19 +232,23 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
data-slot="line-comment-textarea"
rows={split.rows ?? 3}
placeholder={split.placeholder ?? i18n.t("ui.lineComment.placeholder")}
- value={split.value}
- onInput={(e) => split.onInput(e.currentTarget.value)}
- onKeyDown={(e) => {
+ value={text()}
+ on:input={(e) => {
+ const value = (e.currentTarget as HTMLTextAreaElement).value
+ setText(value)
+ split.onInput(value)
+ }}
+ on:keydown={(e) => {
+ const event = e as KeyboardEvent
+ event.stopPropagation()
if (e.key === "Escape") {
- e.preventDefault()
- e.stopPropagation()
+ event.preventDefault()
split.onCancel()
return
}
if (e.key !== "Enter") return
if (e.shiftKey) return
- e.preventDefault()
- e.stopPropagation()
+ event.preventDefault()
submit()
}}
/>
@@ -155,12 +258,37 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
{split.selection}
{i18n.t("ui.lineComment.editorLabel.suffix")}
</div>
- <Button size="small" variant="ghost" onClick={split.onCancel}>
- {split.cancelLabel ?? i18n.t("ui.common.cancel")}
- </Button>
- <Button size="small" variant="primary" disabled={split.value.trim().length === 0} onClick={submit}>
- {split.submitLabel ?? i18n.t("ui.lineComment.submit")}
- </Button>
+ <Show
+ when={!props.inline}
+ fallback={
+ <>
+ <button
+ type="button"
+ data-slot="line-comment-action"
+ data-variant="ghost"
+ on:click={split.onCancel as any}
+ >
+ {split.cancelLabel ?? i18n.t("ui.common.cancel")}
+ </button>
+ <button
+ type="button"
+ data-slot="line-comment-action"
+ data-variant="primary"
+ disabled={text().trim().length === 0}
+ on:click={submit as any}
+ >
+ {split.submitLabel ?? i18n.t("ui.lineComment.submit")}
+ </button>
+ </>
+ }
+ >
+ <Button size="small" variant="ghost" onClick={split.onCancel}>
+ {split.cancelLabel ?? i18n.t("ui.common.cancel")}
+ </Button>
+ <Button size="small" variant="primary" disabled={text().trim().length === 0} onClick={submit}>
+ {split.submitLabel ?? i18n.t("ui.lineComment.submit")}
+ </Button>
+ </Show>
</div>
</div>
</LineCommentAnchor>
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 0f67d683f..e877fc725 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -27,8 +27,7 @@ import {
QuestionInfo,
} from "@opencode-ai/sdk/v2"
import { useData } from "../context"
-import { useDiffComponent } from "../context/diff"
-import { useCodeComponent } from "../context/code"
+import { useFileComponent } from "../context/file"
import { useDialog } from "../context/dialog"
import { useI18n } from "../context/i18n"
import { BasicTool } from "./basic-tool"
@@ -1452,7 +1451,7 @@ ToolRegistry.register({
name: "edit",
render(props) {
const i18n = useI18n()
- const diffComponent = useDiffComponent()
+ const fileComponent = useFileComponent()
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "")
const filename = () => getFilename(props.input.filePath ?? "")
@@ -1499,7 +1498,8 @@ ToolRegistry.register({
>
<div data-component="edit-content">
<Dynamic
- component={diffComponent}
+ component={fileComponent}
+ mode="diff"
before={{
name: props.metadata?.filediff?.file || props.input.filePath,
contents: props.metadata?.filediff?.before || props.input.oldString,
@@ -1523,7 +1523,7 @@ ToolRegistry.register({
name: "write",
render(props) {
const i18n = useI18n()
- const codeComponent = useCodeComponent()
+ const fileComponent = useFileComponent()
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
const path = createMemo(() => props.input.filePath || "")
const filename = () => getFilename(props.input.filePath ?? "")
@@ -1561,7 +1561,8 @@ ToolRegistry.register({
<ToolFileAccordion path={path()}>
<div data-component="write-content">
<Dynamic
- component={codeComponent}
+ component={fileComponent}
+ mode="text"
file={{
name: props.input.filePath,
contents: props.input.content,
@@ -1595,7 +1596,7 @@ ToolRegistry.register({
name: "apply_patch",
render(props) {
const i18n = useI18n()
- const diffComponent = useDiffComponent()
+ const fileComponent = useFileComponent()
const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
const pending = createMemo(() => props.status === "pending" || props.status === "running")
const single = createMemo(() => {
@@ -1703,7 +1704,8 @@ ToolRegistry.register({
<Show when={visible()}>
<div data-component="apply-patch-file-diff">
<Dynamic
- component={diffComponent}
+ component={fileComponent}
+ mode="diff"
before={{ name: file.filePath, contents: file.before }}
after={{ name: file.movePath ?? file.filePath, contents: file.after }}
/>
@@ -1780,7 +1782,8 @@ ToolRegistry.register({
>
<div data-component="apply-patch-file-diff">
<Dynamic
- component={diffComponent}
+ component={fileComponent}
+ mode="diff"
before={{ name: file().filePath, contents: file().before }}
after={{ name: file().movePath ?? file().filePath, contents: file().after }}
/>
diff --git a/packages/ui/src/components/session-review-search.test.ts b/packages/ui/src/components/session-review-search.test.ts
new file mode 100644
index 000000000..060df6407
--- /dev/null
+++ b/packages/ui/src/components/session-review-search.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, test } from "bun:test"
+import { buildSessionSearchHits, stepSessionSearchIndex } from "./session-review-search"
+
+describe("session review search", () => {
+ test("builds hits with line, col, and side", () => {
+ const hits = buildSessionSearchHits({
+ query: "alpha",
+ files: [
+ {
+ file: "a.txt",
+ before: "alpha\nbeta alpha",
+ after: "ALPHA",
+ },
+ ],
+ })
+
+ expect(hits).toEqual([
+ { file: "a.txt", side: "deletions", line: 1, col: 1, len: 5 },
+ { file: "a.txt", side: "deletions", line: 2, col: 6, len: 5 },
+ { file: "a.txt", side: "additions", line: 1, col: 1, len: 5 },
+ ])
+ })
+
+ test("uses non-overlapping matches", () => {
+ const hits = buildSessionSearchHits({
+ query: "aa",
+ files: [{ file: "a.txt", after: "aaaa" }],
+ })
+
+ expect(hits.map((hit) => hit.col)).toEqual([1, 3])
+ })
+
+ test("wraps next and previous navigation", () => {
+ expect(stepSessionSearchIndex(5, 0, -1)).toBe(4)
+ expect(stepSessionSearchIndex(5, 4, 1)).toBe(0)
+ expect(stepSessionSearchIndex(5, 2, 1)).toBe(3)
+ expect(stepSessionSearchIndex(0, 0, 1)).toBe(0)
+ })
+})
diff --git a/packages/ui/src/components/session-review-search.ts b/packages/ui/src/components/session-review-search.ts
new file mode 100644
index 000000000..2cff0adc5
--- /dev/null
+++ b/packages/ui/src/components/session-review-search.ts
@@ -0,0 +1,59 @@
+export type SessionSearchHit = {
+ file: string
+ side: "additions" | "deletions"
+ line: number
+ col: number
+ len: number
+}
+
+type SessionSearchFile = {
+ file: string
+ before?: string
+ after?: string
+}
+
+function hitsForSide(args: { file: string; side: SessionSearchHit["side"]; text: string; needle: string }) {
+ return args.text.split("\n").flatMap((line, i) => {
+ if (!line) return []
+
+ const hay = line.toLowerCase()
+ let at = hay.indexOf(args.needle)
+ if (at < 0) return []
+
+ const out: SessionSearchHit[] = []
+ while (at >= 0) {
+ out.push({
+ file: args.file,
+ side: args.side,
+ line: i + 1,
+ col: at + 1,
+ len: args.needle.length,
+ })
+ at = hay.indexOf(args.needle, at + args.needle.length)
+ }
+
+ return out
+ })
+}
+
+export function buildSessionSearchHits(args: { query: string; files: SessionSearchFile[] }) {
+ const value = args.query.trim().toLowerCase()
+ if (!value) return []
+
+ return args.files.flatMap((file) => {
+ const out: SessionSearchHit[] = []
+ if (typeof file.before === "string") {
+ out.push(...hitsForSide({ file: file.file, side: "deletions", text: file.before, needle: value }))
+ }
+ if (typeof file.after === "string") {
+ out.push(...hitsForSide({ file: file.file, side: "additions", text: file.after, needle: value }))
+ }
+ return out
+ })
+}
+
+export function stepSessionSearchIndex(total: number, current: number, dir: 1 | -1) {
+ if (total <= 0) return 0
+ if (current < 0 || current >= total) return dir > 0 ? 0 : total - 1
+ return (current + dir + total) % total
+}
diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css
index ec048d009..60da85e6f 100644
--- a/packages/ui/src/components/session-review.css
+++ b/packages/ui/src/components/session-review.css
@@ -200,50 +200,6 @@
color: var(--icon-diff-modified-base);
}
- [data-slot="session-review-file-container"] {
- padding: 0;
- }
-
- [data-slot="session-review-image-container"] {
- padding: 12px;
- display: flex;
- justify-content: center;
- background: var(--background-stronger);
- }
-
- [data-slot="session-review-image"] {
- max-width: 100%;
- max-height: 60vh;
- object-fit: contain;
- border-radius: 8px;
- border: 1px solid var(--border-weak-base);
- background: var(--background-base);
- }
-
- [data-slot="session-review-image-placeholder"] {
- font-family: var(--font-family-sans);
- font-size: var(--font-size-small);
- color: var(--text-weak);
- }
-
- [data-slot="session-review-audio-container"] {
- padding: 12px;
- display: flex;
- justify-content: center;
- background: var(--background-stronger);
- }
-
- [data-slot="session-review-audio"] {
- width: 100%;
- max-width: 560px;
- }
-
- [data-slot="session-review-audio-placeholder"] {
- font-family: var(--font-family-sans);
- font-size: var(--font-size-small);
- color: var(--text-weak);
- }
-
[data-slot="session-review-diff-wrapper"] {
position: relative;
overflow: hidden;
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx
index 7f737032e..679935f61 100644
--- a/packages/ui/src/components/session-review.tsx
+++ b/packages/ui/src/components/session-review.tsx
@@ -1,23 +1,30 @@
import { Accordion } from "./accordion"
import { Button } from "./button"
+import { DropdownMenu } from "./dropdown-menu"
import { RadioGroup } from "./radio-group"
import { DiffChanges } from "./diff-changes"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
-import { LineComment, LineCommentEditor } from "./line-comment"
+import { IconButton } from "./icon-button"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { Tooltip } from "./tooltip"
import { ScrollView } from "./scroll-view"
-import { useDiffComponent } from "../context/diff"
+import { FileSearchBar } from "./file-search"
+import type { FileSearchHandle } from "./file"
+import { buildSessionSearchHits, stepSessionSearchIndex, type SessionSearchHit } from "./session-review-search"
+import { useFileComponent } from "../context/file"
import { useI18n } from "../context/i18n"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode"
-import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
+import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { type SelectedLineRange } from "@pierre/diffs"
import { Dynamic } from "solid-js/web"
+import { mediaKindFromPath } from "../pierre/media"
+import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge"
+import { createLineCommentController } from "./line-comment-annotations"
const MAX_DIFF_CHANGED_LINES = 500
@@ -37,6 +44,22 @@ export type SessionReviewLineComment = {
preview?: string
}
+export type SessionReviewCommentUpdate = SessionReviewLineComment & {
+ id: string
+}
+
+export type SessionReviewCommentDelete = {
+ id: string
+ file: string
+}
+
+export type SessionReviewCommentActions = {
+ moreLabel: string
+ editLabel: string
+ deleteLabel: string
+ saveLabel: string
+}
+
export type SessionReviewFocus = { file: string; id: string }
export interface SessionReviewProps {
@@ -47,6 +70,9 @@ export interface SessionReviewProps {
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
onDiffRendered?: () => void
onLineComment?: (comment: SessionReviewLineComment) => void
+ onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
+ onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
+ lineCommentActions?: SessionReviewCommentActions
comments?: SessionReviewComment[]
focusedComment?: SessionReviewFocus | null
onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void
@@ -64,66 +90,35 @@ export interface SessionReviewProps {
readFile?: (path: string) => Promise<FileContent | undefined>
}
-const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"])
-const audioExtensions = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"])
-
-function normalizeMimeType(type: string | undefined): string | undefined {
- if (!type) return
-
- const mime = type.split(";", 1)[0]?.trim().toLowerCase()
- if (!mime) return
-
- if (mime === "audio/x-aac") return "audio/aac"
- if (mime === "audio/x-m4a") return "audio/mp4"
-
- return mime
-}
-
-function getExtension(file: string): string {
- const idx = file.lastIndexOf(".")
- if (idx === -1) return ""
- return file.slice(idx + 1).toLowerCase()
-}
-
-function isImageFile(file: string): boolean {
- return imageExtensions.has(getExtension(file))
-}
-
-function isAudioFile(file: string): boolean {
- return audioExtensions.has(getExtension(file))
-}
-
-function dataUrl(content: FileContent | undefined): string | undefined {
- if (!content) return
- if (content.encoding !== "base64") return
- const mime = normalizeMimeType(content.mimeType)
- if (!mime) return
- if (!mime.startsWith("image/") && !mime.startsWith("audio/")) return
- return `data:${mime};base64,${content.content}`
-}
-
-function dataUrlFromValue(value: unknown): string | undefined {
- if (typeof value === "string") {
- if (value.startsWith("data:image/")) return value
- if (value.startsWith("data:audio/x-aac;")) return value.replace("data:audio/x-aac;", "data:audio/aac;")
- if (value.startsWith("data:audio/x-m4a;")) return value.replace("data:audio/x-m4a;", "data:audio/mp4;")
- if (value.startsWith("data:audio/")) return value
- return
- }
- if (!value || typeof value !== "object") return
-
- const content = (value as { content?: unknown }).content
- const encoding = (value as { encoding?: unknown }).encoding
- const mimeType = (value as { mimeType?: unknown }).mimeType
-
- if (typeof content !== "string") return
- if (encoding !== "base64") return
- if (typeof mimeType !== "string") return
- const mime = normalizeMimeType(mimeType)
- if (!mime) return
- if (!mime.startsWith("image/") && !mime.startsWith("audio/")) return
-
- return `data:${mime};base64,${content}`
+function ReviewCommentMenu(props: {
+ labels: SessionReviewCommentActions
+ onEdit: VoidFunction
+ onDelete: VoidFunction
+}) {
+ return (
+ <div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
+ <DropdownMenu gutter={4} placement="bottom-end">
+ <DropdownMenu.Trigger
+ as={IconButton}
+ icon="dot-grid"
+ variant="ghost"
+ size="small"
+ class="size-6 rounded-md"
+ aria-label={props.labels.moreLabel}
+ />
+ <DropdownMenu.Portal>
+ <DropdownMenu.Content>
+ <DropdownMenu.Item onSelect={props.onEdit}>
+ <DropdownMenu.ItemLabel>{props.labels.editLabel}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Item onSelect={props.onDelete}>
+ <DropdownMenu.ItemLabel>{props.labels.deleteLabel}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ </DropdownMenu.Content>
+ </DropdownMenu.Portal>
+ </DropdownMenu>
+ </div>
+ )
}
function diffId(file: string): string | undefined {
@@ -137,62 +132,37 @@ type SessionReviewSelection = {
range: SelectedLineRange
}
-function findSide(element: HTMLElement): "additions" | "deletions" | undefined {
- const typed = element.closest("[data-line-type]")
- if (typed instanceof HTMLElement) {
- const type = typed.dataset.lineType
- if (type === "change-deletion") return "deletions"
- if (type === "change-addition" || type === "change-additions") return "additions"
- }
-
- const code = element.closest("[data-code]")
- if (!(code instanceof HTMLElement)) return
- return code.hasAttribute("data-deletions") ? "deletions" : "additions"
-}
-
-function findMarker(root: ShadowRoot, range: SelectedLineRange) {
- const marker = (line: number, side?: "additions" | "deletions") => {
- const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
- (node): node is HTMLElement => node instanceof HTMLElement,
- )
- if (nodes.length === 0) return
- if (!side) return nodes[0]
- const match = nodes.find((node) => findSide(node) === side)
- return match ?? nodes[0]
- }
-
- const a = marker(range.start, range.side)
- const b = marker(range.end, range.endSide ?? range.side)
- if (!a) return b
- if (!b) return a
- return a.getBoundingClientRect().top > b.getBoundingClientRect().top ? a : b
-}
-
-function markerTop(wrapper: HTMLElement, marker: HTMLElement) {
- const wrapperRect = wrapper.getBoundingClientRect()
- const rect = marker.getBoundingClientRect()
- return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
-}
-
export const SessionReview = (props: SessionReviewProps) => {
let scroll: HTMLDivElement | undefined
+ let searchInput: HTMLInputElement | undefined
let focusToken = 0
+ let revealToken = 0
+ let highlightedFile: string | undefined
const i18n = useI18n()
- const diffComponent = useDiffComponent()
+ const fileComponent = useFileComponent()
const anchors = new Map<string, HTMLElement>()
- const [store, setStore] = createStore({
+ const searchHandles = new Map<string, FileSearchHandle>()
+ const readyFiles = new Set<string>()
+ const [store, setStore] = createStore<{ open: string[]; force: Record<string, boolean> }>({
open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
+ force: {},
})
const [selection, setSelection] = createSignal<SessionReviewSelection | null>(null)
const [commenting, setCommenting] = createSignal<SessionReviewSelection | null>(null)
const [opened, setOpened] = createSignal<SessionReviewFocus | null>(null)
+ const [searchOpen, setSearchOpen] = createSignal(false)
+ const [searchQuery, setSearchQuery] = createSignal("")
+ const [searchActive, setSearchActive] = createSignal(0)
+ const [searchPos, setSearchPos] = createSignal({ top: 8, right: 8 })
const open = () => props.open ?? store.open
const files = createMemo(() => props.diffs.map((d) => d.file))
const diffs = createMemo(() => new Map(props.diffs.map((d) => [d.file, d] as const)))
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
const hasDiffs = () => files().length > 0
+ const searchValue = createMemo(() => searchQuery().trim())
+ const searchExpanded = createMemo(() => searchValue().length > 0)
const handleChange = (open: string[]) => {
props.onOpenChange?.(open)
@@ -205,13 +175,259 @@ export const SessionReview = (props: SessionReviewProps) => {
handleChange(next)
}
- const selectionLabel = (range: SelectedLineRange) => {
- const start = Math.min(range.start, range.end)
- const end = Math.max(range.start, range.end)
- if (start === end) return `line ${start}`
- return `lines ${start}-${end}`
+ const clearViewerSearch = () => {
+ for (const handle of searchHandles.values()) handle.clear()
+ highlightedFile = undefined
+ }
+
+ const focusSearch = () => {
+ if (!hasDiffs()) return
+ setSearchOpen(true)
+ requestAnimationFrame(() => {
+ searchInput?.focus()
+ searchInput?.select()
+ })
+ }
+
+ const closeSearch = () => {
+ revealToken++
+ setSearchOpen(false)
+ setSearchQuery("")
+ setSearchActive(0)
+ clearViewerSearch()
}
+ const positionSearchBar = () => {
+ if (typeof window === "undefined") return
+ if (!scroll) return
+
+ const rect = scroll.getBoundingClientRect()
+ const title = parseFloat(getComputedStyle(scroll).getPropertyValue("--session-title-height"))
+ const header = Number.isNaN(title) ? 0 : title
+ setSearchPos({
+ top: Math.round(rect.top) + header - 4,
+ right: Math.round(window.innerWidth - rect.right) + 8,
+ })
+ }
+
+ const searchHits = createMemo(() =>
+ buildSessionSearchHits({
+ query: searchQuery(),
+ files: props.diffs.flatMap((diff) => {
+ if (mediaKindFromPath(diff.file)) return []
+
+ return [
+ {
+ file: diff.file,
+ before: typeof diff.before === "string" ? diff.before : undefined,
+ after: typeof diff.after === "string" ? diff.after : undefined,
+ },
+ ]
+ }),
+ }),
+ )
+
+ const waitForViewer = (file: string, token: number) =>
+ new Promise<FileSearchHandle | undefined>((resolve) => {
+ let attempt = 0
+
+ const tick = () => {
+ if (token !== revealToken) {
+ resolve(undefined)
+ return
+ }
+
+ const handle = searchHandles.get(file)
+ if (handle && readyFiles.has(file)) {
+ resolve(handle)
+ return
+ }
+
+ if (attempt >= 180) {
+ resolve(undefined)
+ return
+ }
+
+ attempt++
+ requestAnimationFrame(tick)
+ }
+
+ tick()
+ })
+
+ const waitForFrames = (count: number, token: number) =>
+ new Promise<boolean>((resolve) => {
+ const tick = (left: number) => {
+ if (token !== revealToken) {
+ resolve(false)
+ return
+ }
+
+ if (left <= 0) {
+ resolve(true)
+ return
+ }
+
+ requestAnimationFrame(() => tick(left - 1))
+ }
+
+ tick(count)
+ })
+
+ const revealSearchHit = async (token: number, hit: SessionSearchHit, query: string) => {
+ const diff = diffs().get(hit.file)
+ if (!diff) return
+
+ if (!open().includes(hit.file)) {
+ handleChange([...open(), hit.file])
+ }
+
+ if (!mediaKindFromPath(hit.file) && diff.additions + diff.deletions > MAX_DIFF_CHANGED_LINES) {
+ setStore("force", hit.file, true)
+ }
+
+ const handle = await waitForViewer(hit.file, token)
+ if (!handle || token !== revealToken) return
+ if (searchValue() !== query) return
+ if (!(await waitForFrames(2, token))) return
+
+ if (highlightedFile && highlightedFile !== hit.file) {
+ searchHandles.get(highlightedFile)?.clear()
+ highlightedFile = undefined
+ }
+
+ anchors.get(hit.file)?.scrollIntoView({ block: "nearest" })
+
+ let done = false
+ for (let i = 0; i < 4; i++) {
+ if (token !== revealToken) return
+ if (searchValue() !== query) return
+
+ handle.setQuery(query)
+ if (handle.reveal(hit)) {
+ done = true
+ break
+ }
+
+ const expanded = handle.expand(hit)
+ handle.refresh()
+ if (!(await waitForFrames(expanded ? 2 : 1, token))) return
+ }
+
+ if (!done) return
+
+ if (!(await waitForFrames(1, token))) return
+ handle.reveal(hit)
+
+ highlightedFile = hit.file
+ }
+
+ const navigateSearch = (dir: 1 | -1) => {
+ const total = searchHits().length
+ if (total <= 0) return
+ setSearchActive((value) => stepSessionSearchIndex(total, value, dir))
+ }
+
+ const inReview = (node: unknown, path?: unknown[]) => {
+ if (node === searchInput) return true
+ if (path?.some((item) => item === scroll || item === searchInput)) return true
+ if (path?.some((item) => item instanceof HTMLElement && item.dataset.component === "session-review")) {
+ return true
+ }
+ if (!(node instanceof Node)) return false
+ if (searchInput?.contains(node)) return true
+ if (node instanceof HTMLElement && node.closest("[data-component='session-review']")) return true
+ if (!scroll) return false
+ return scroll.contains(node)
+ }
+
+ createEffect(() => {
+ if (typeof window === "undefined") return
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.defaultPrevented) return
+
+ const mod = event.metaKey || event.ctrlKey
+ if (!mod) return
+
+ const key = event.key.toLowerCase()
+ if (key !== "f" && key !== "g") return
+
+ if (key === "f") {
+ if (!hasDiffs()) return
+ event.preventDefault()
+ event.stopPropagation()
+ focusSearch()
+ return
+ }
+
+ const path = typeof event.composedPath === "function" ? event.composedPath() : undefined
+ if (!inReview(event.target, path) && !inReview(document.activeElement, path)) return
+ if (!searchOpen()) return
+ event.preventDefault()
+ event.stopPropagation()
+ navigateSearch(event.shiftKey ? -1 : 1)
+ }
+
+ window.addEventListener("keydown", onKeyDown, { capture: true })
+ onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true }))
+ })
+
+ createEffect(() => {
+ diffStyle()
+ searchExpanded()
+ readyFiles.clear()
+ })
+
+ createEffect(() => {
+ if (!searchOpen()) return
+ if (!scroll) return
+
+ const root = scroll
+
+ requestAnimationFrame(positionSearchBar)
+ window.addEventListener("resize", positionSearchBar, { passive: true })
+ const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(positionSearchBar)
+ observer?.observe(root)
+
+ onCleanup(() => {
+ window.removeEventListener("resize", positionSearchBar)
+ observer?.disconnect()
+ })
+ })
+
+ createEffect(() => {
+ const total = searchHits().length
+ if (total === 0) {
+ if (searchActive() !== 0) setSearchActive(0)
+ return
+ }
+
+ if (searchActive() >= total) setSearchActive(total - 1)
+ })
+
+ createEffect(() => {
+ diffStyle()
+ const query = searchValue()
+ const hits = searchHits()
+ const token = ++revealToken
+ if (!query || hits.length === 0) {
+ clearViewerSearch()
+ return
+ }
+
+ const hit = hits[Math.min(searchActive(), hits.length - 1)]
+ if (!hit) return
+ void revealSearchHit(token, hit, query)
+ })
+
+ onCleanup(() => {
+ revealToken++
+ clearViewerSearch()
+ readyFiles.clear()
+ searchHandles.clear()
+ })
+
const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
const selectionPreview = (diff: FileDiff, range: SelectedLineRange) => {
@@ -219,11 +435,7 @@ export const SessionReview = (props: SessionReviewProps) => {
const contents = side === "deletions" ? diff.before : diff.after
if (typeof contents !== "string" || contents.length === 0) return undefined
- const start = Math.max(1, Math.min(range.start, range.end))
- const end = Math.max(range.start, range.end)
- const lines = contents.split("\n").slice(start - 1, end)
- if (lines.length === 0) return undefined
- return lines.slice(0, 2).join("\n")
+ return previewSelectedLines(contents, range)
}
createEffect(() => {
@@ -236,7 +448,7 @@ export const SessionReview = (props: SessionReviewProps) => {
setOpened(focus)
const comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id)
- if (comment) setSelection({ file: comment.file, range: comment.selection })
+ if (comment) setSelection({ file: comment.file, range: cloneSelectedLineRange(comment.selection) })
const current = open()
if (!current.includes(focus.file)) {
@@ -249,11 +461,11 @@ export const SessionReview = (props: SessionReviewProps) => {
const root = scroll
if (!root) return
- const anchor = root.querySelector(`[data-comment-id="${focus.id}"]`)
- const ready =
- anchor instanceof HTMLElement && anchor.style.pointerEvents !== "none" && anchor.style.opacity !== "0"
+ const wrapper = anchors.get(focus.file)
+ const anchor = wrapper?.querySelector(`[data-comment-id="${focus.id}"]`)
+ const ready = anchor instanceof HTMLElement
- const target = ready ? anchor : anchors.get(focus.file)
+ const target = ready ? anchor : wrapper
if (!target) {
if (attempt >= 120) return
requestAnimationFrame(() => scrollTo(attempt + 1))
@@ -276,6 +488,58 @@ export const SessionReview = (props: SessionReviewProps) => {
requestAnimationFrame(() => props.onFocusedCommentChange?.(null))
})
+ const handleReviewKeyDown = (event: KeyboardEvent) => {
+ if (event.defaultPrevented) return
+
+ const mod = event.metaKey || event.ctrlKey
+ const key = event.key.toLowerCase()
+ const target = event.target
+ if (mod && key === "f") {
+ event.preventDefault()
+ event.stopPropagation()
+ focusSearch()
+ return
+ }
+
+ if (mod && key === "g") {
+ if (!searchOpen()) return
+ event.preventDefault()
+ event.stopPropagation()
+ navigateSearch(event.shiftKey ? -1 : 1)
+ }
+ }
+
+ const handleSearchInputKeyDown = (event: KeyboardEvent) => {
+ const mod = event.metaKey || event.ctrlKey
+ const key = event.key.toLowerCase()
+
+ if (mod && key === "g") {
+ event.preventDefault()
+ event.stopPropagation()
+ navigateSearch(event.shiftKey ? -1 : 1)
+ return
+ }
+
+ if (mod && key === "f") {
+ event.preventDefault()
+ event.stopPropagation()
+ focusSearch()
+ return
+ }
+
+ if (event.key === "Escape") {
+ event.preventDefault()
+ event.stopPropagation()
+ closeSearch()
+ return
+ }
+
+ if (event.key !== "Enter") return
+ event.preventDefault()
+ event.stopPropagation()
+ navigateSearch(event.shiftKey ? -1 : 1)
+ }
+
return (
<ScrollView
data-component="session-review"
@@ -284,6 +548,7 @@ export const SessionReview = (props: SessionReviewProps) => {
props.scrollRef?.(el)
}}
onScroll={props.onScroll as any}
+ onKeyDown={handleReviewKeyDown}
classList={{
...(props.classList ?? {}),
[props.classes?.root ?? ""]: !!props.classes?.root,
@@ -321,6 +586,25 @@ export const SessionReview = (props: SessionReviewProps) => {
{props.actions}
</div>
</div>
+ <Show when={searchOpen()}>
+ <FileSearchBar
+ pos={searchPos}
+ query={searchQuery}
+ index={() => (searchHits().length ? Math.min(searchActive(), searchHits().length - 1) : 0)}
+ count={() => searchHits().length}
+ setInput={(el) => {
+ searchInput = el
+ }}
+ onInput={(value) => {
+ setSearchQuery(value)
+ setSearchActive(0)
+ }}
+ onKeyDown={(event) => handleSearchInputKeyDown(event)}
+ onClose={closeSearch}
+ onPrev={() => navigateSearch(-1)}
+ onNext={() => navigateSearch(1)}
+ />
+ </Show>
<div data-slot="session-review-container" class={props.classes?.container}>
<Show when={hasDiffs()} fallback={props.empty}>
<Accordion multiple value={open()} onChange={handleChange}>
@@ -332,7 +616,7 @@ export const SessionReview = (props: SessionReviewProps) => {
const item = () => diff()!
const expanded = createMemo(() => open().includes(file))
- const [force, setForce] = createSignal(false)
+ const force = () => !!store.force[file]
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file))
const commentedLines = createMemo(() => comments().map((c) => c.selection))
@@ -340,28 +624,18 @@ export const SessionReview = (props: SessionReviewProps) => {
const beforeText = () => (typeof item().before === "string" ? item().before : "")
const afterText = () => (typeof item().after === "string" ? item().after : "")
const changedLines = () => item().additions + item().deletions
+ const mediaKind = createMemo(() => mediaKindFromPath(file))
const tooLarge = createMemo(() => {
if (!expanded()) return false
if (force()) return false
- if (isImageFile(file)) return false
+ if (mediaKind()) return false
return changedLines() > MAX_DIFF_CHANGED_LINES
})
const isAdded = () => item().status === "added" || (beforeText().length === 0 && afterText().length > 0)
const isDeleted = () =>
item().status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
- const isImage = () => isImageFile(file)
- const isAudio = () => isAudioFile(file)
-
- const diffImageSrc = createMemo(() => dataUrlFromValue(item().after) ?? dataUrlFromValue(item().before))
- const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc())
- const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
-
- const diffAudioSrc = createMemo(() => dataUrlFromValue(item().after) ?? dataUrlFromValue(item().before))
- const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc())
- const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
- const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined)
const selectedLines = createMemo(() => {
const current = selection()
@@ -375,164 +649,74 @@ export const SessionReview = (props: SessionReviewProps) => {
return current.range
})
- const [draft, setDraft] = createSignal("")
- const [positions, setPositions] = createSignal<Record<string, number>>({})
- const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
-
- const getRoot = () => {
- const el = wrapper
- if (!el) return
-
- const host = el.querySelector("diffs-container")
- if (!(host instanceof HTMLElement)) return
- return host.shadowRoot ?? undefined
- }
-
- const updateAnchors = () => {
- const el = wrapper
- if (!el) return
-
- const root = getRoot()
- if (!root) return
-
- const next: Record<string, number> = {}
- for (const item of comments()) {
- const marker = findMarker(root, item.selection)
- if (!marker) continue
- next[item.id] = markerTop(el, marker)
- }
- setPositions(next)
-
- const range = draftRange()
- if (!range) {
- setDraftTop(undefined)
- return
- }
-
- const marker = findMarker(root, range)
- if (!marker) {
- setDraftTop(undefined)
- return
- }
-
- setDraftTop(markerTop(el, marker))
- }
-
- const scheduleAnchors = () => {
- requestAnimationFrame(updateAnchors)
- }
-
- createEffect(() => {
- if (!isImage()) return
- const src = diffImageSrc()
- setImageSrc(src)
- setImageStatus("idle")
- })
-
- createEffect(() => {
- if (!isAudio()) return
- const src = diffAudioSrc()
- setAudioSrc(src)
- setAudioStatus("idle")
- setAudioMime(undefined)
- })
-
- createEffect(() => {
- comments()
- scheduleAnchors()
- })
-
- createEffect(() => {
- const range = draftRange()
- if (!range) return
- setDraft("")
- scheduleAnchors()
- })
-
- createEffect(() => {
- if (!open().includes(file)) return
- if (!isImage()) return
- if (imageSrc()) return
- if (imageStatus() !== "idle") return
- if (isDeleted()) return
-
- const reader = props.readFile
- if (!reader) return
-
- setImageStatus("loading")
- reader(file)
- .then((result) => {
- const src = dataUrl(result)
- if (!src) {
- setImageStatus("error")
- return
- }
- setImageSrc(src)
- setImageStatus("idle")
+ const commentsUi = createLineCommentController<SessionReviewComment>({
+ comments,
+ label: i18n.t("ui.lineComment.submit"),
+ draftKey: () => file,
+ state: {
+ opened: () => {
+ const current = opened()
+ if (!current || current.file !== file) return null
+ return current.id
+ },
+ setOpened: (id) => setOpened(id ? { file, id } : null),
+ selected: selectedLines,
+ setSelected: (range) => setSelection(range ? { file, range } : null),
+ commenting: draftRange,
+ setCommenting: (range) => setCommenting(range ? { file, range } : null),
+ },
+ getSide: selectionSide,
+ clearSelectionOnSelectionEndNull: false,
+ onSubmit: ({ comment, selection }) => {
+ props.onLineComment?.({
+ file,
+ selection,
+ comment,
+ preview: selectionPreview(item(), selection),
+ })
+ },
+ onUpdate: ({ id, comment, selection }) => {
+ props.onLineCommentUpdate?.({
+ id,
+ file,
+ selection,
+ comment,
+ preview: selectionPreview(item(), selection),
})
- .catch(() => {
- setImageStatus("error")
+ },
+ onDelete: (comment) => {
+ props.onLineCommentDelete?.({
+ id: comment.id,
+ file,
})
+ },
+ editSubmitLabel: props.lineCommentActions?.saveLabel,
+ renderCommentActions: props.lineCommentActions
+ ? (comment, controls) => (
+ <ReviewCommentMenu
+ labels={props.lineCommentActions!}
+ onEdit={controls.edit}
+ onDelete={controls.remove}
+ />
+ )
+ : undefined,
})
- createEffect(() => {
- if (!open().includes(file)) return
- if (!isAudio()) return
- if (audioSrc()) return
- if (audioStatus() !== "idle") return
-
- const reader = props.readFile
- if (!reader) return
-
- setAudioStatus("loading")
- reader(file)
- .then((result) => {
- const src = dataUrl(result)
- if (!src) {
- setAudioStatus("error")
- return
- }
- setAudioMime(normalizeMimeType(result?.mimeType))
- setAudioSrc(src)
- setAudioStatus("idle")
- })
- .catch(() => {
- setAudioStatus("error")
- })
+ onCleanup(() => {
+ anchors.delete(file)
+ readyFiles.delete(file)
+ searchHandles.delete(file)
+ if (highlightedFile === file) highlightedFile = undefined
})
const handleLineSelected = (range: SelectedLineRange | null) => {
if (!props.onLineComment) return
-
- if (!range) {
- setSelection(null)
- return
- }
-
- setSelection({ file, range })
+ commentsUi.onLineSelected(range)
}
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
if (!props.onLineComment) return
-
- if (!range) {
- setCommenting(null)
- return
- }
-
- setSelection({ file, range })
- setCommenting({ file, range })
- }
-
- const openComment = (comment: SessionReviewComment) => {
- setOpened({ file: comment.file, id: comment.id })
- setSelection({ file: comment.file, range: comment.selection })
- }
-
- const isCommentOpen = (comment: SessionReviewComment) => {
- const current = opened()
- if (!current) return false
- return current.file === comment.file && current.id === comment.id
+ commentsUi.onLineSelectionEnd(range)
}
return (
@@ -585,7 +769,7 @@ export const SessionReview = (props: SessionReviewProps) => {
{i18n.t("ui.sessionReview.change.removed")}
</span>
</Match>
- <Match when={isImage()}>
+ <Match when={!!mediaKind()}>
<span data-slot="session-review-change" data-type="modified">
{i18n.t("ui.sessionReview.change.modified")}
</span>
@@ -607,33 +791,11 @@ export const SessionReview = (props: SessionReviewProps) => {
ref={(el) => {
wrapper = el
anchors.set(file, el)
- scheduleAnchors()
}}
>
<Show when={expanded()}>
<Switch>
- <Match when={isImage() && imageSrc()}>
- <div data-slot="session-review-image-container">
- <img data-slot="session-review-image" src={imageSrc()} alt={file} />
- </div>
- </Match>
- <Match when={isImage() && isDeleted()}>
- <div data-slot="session-review-image-container" data-removed>
- <span data-slot="session-review-image-placeholder">
- {i18n.t("ui.sessionReview.change.removed")}
- </span>
- </div>
- </Match>
- <Match when={isImage() && !imageSrc()}>
- <div data-slot="session-review-image-container">
- <span data-slot="session-review-image-placeholder">
- {imageStatus() === "loading"
- ? i18n.t("ui.sessionReview.image.loading")
- : i18n.t("ui.sessionReview.image.placeholder")}
- </span>
- </div>
- </Match>
- <Match when={!isImage() && tooLarge()}>
+ <Match when={tooLarge()}>
<div data-slot="session-review-large-diff">
<div data-slot="session-review-large-diff-title">
{i18n.t("ui.sessionReview.largeDiff.title")}
@@ -645,26 +807,52 @@ export const SessionReview = (props: SessionReviewProps) => {
})}
</div>
<div data-slot="session-review-large-diff-actions">
- <Button size="normal" variant="secondary" onClick={() => setForce(true)}>
+ <Button
+ size="normal"
+ variant="secondary"
+ onClick={() => setStore("force", file, true)}
+ >
{i18n.t("ui.sessionReview.largeDiff.renderAnyway")}
</Button>
</div>
</div>
</Match>
- <Match when={!isImage()}>
+ <Match when={true}>
<Dynamic
- component={diffComponent}
+ component={fileComponent}
+ mode="diff"
preloadedDiff={item().preloaded}
diffStyle={diffStyle()}
+ expansionLineCount={searchExpanded() ? Number.MAX_SAFE_INTEGER : 20}
onRendered={() => {
+ readyFiles.add(file)
props.onDiffRendered?.()
- scheduleAnchors()
}}
enableLineSelection={props.onLineComment != null}
+ enableHoverUtility={props.onLineComment != null}
onLineSelected={handleLineSelected}
onLineSelectionEnd={handleLineSelectionEnd}
+ onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
+ annotations={commentsUi.annotations()}
+ renderAnnotation={commentsUi.renderAnnotation}
+ renderHoverUtility={props.onLineComment ? commentsUi.renderHoverUtility : undefined}
selectedLines={selectedLines()}
commentedLines={commentedLines()}
+ search={{
+ shortcuts: "disabled",
+ showBar: false,
+ disableVirtualization: searchExpanded(),
+ register: (handle: FileSearchHandle | null) => {
+ if (!handle) {
+ searchHandles.delete(file)
+ readyFiles.delete(file)
+ if (highlightedFile === file) highlightedFile = undefined
+ return
+ }
+
+ searchHandles.set(file, handle)
+ },
+ }}
before={{
name: file,
contents: typeof item().before === "string" ? item().before : "",
@@ -673,53 +861,16 @@ export const SessionReview = (props: SessionReviewProps) => {
name: file,
contents: typeof item().after === "string" ? item().after : "",
}}
+ media={{
+ mode: "auto",
+ path: file,
+ before: item().before,
+ after: item().after,
+ readFile: props.readFile,
+ }}
/>
</Match>
</Switch>
-
- <For each={comments()}>
- {(comment) => (
- <LineComment
- id={comment.id}
- top={positions()[comment.id]}
- onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })}
- onClick={() => {
- if (isCommentOpen(comment)) {
- setOpened(null)
- return
- }
-
- openComment(comment)
- }}
- open={isCommentOpen(comment)}
- comment={comment.comment}
- selection={selectionLabel(comment.selection)}
- />
- )}
- </For>
-
- <Show when={draftRange()}>
- {(range) => (
- <Show when={draftTop() !== undefined}>
- <LineCommentEditor
- top={draftTop()}
- value={draft()}
- selection={selectionLabel(range())}
- onInput={setDraft}
- onCancel={() => setCommenting(null)}
- onSubmit={(comment) => {
- props.onLineComment?.({
- file,
- selection: range(),
- comment,
- preview: selectionPreview(item(), range()),
- })
- setCommenting(null)
- }}
- />
- </Show>
- )}
- </Show>
</Show>
</div>
</Accordion.Content>
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 0eceb754c..3116d4b65 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -1,6 +1,6 @@
import { AssistantMessage, type FileDiff, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client"
import { useData } from "../context"
-import { useDiffComponent } from "../context/diff"
+import { useFileComponent } from "../context/file"
import { Binary } from "@opencode-ai/util/binary"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
@@ -152,7 +152,7 @@ export function SessionTurn(
) {
const data = useData()
const i18n = useI18n()
- const diffComponent = useDiffComponent()
+ const fileComponent = useFileComponent()
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
@@ -465,7 +465,8 @@ export function SessionTurn(
<Show when={visible()}>
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic
- component={diffComponent}
+ component={fileComponent}
+ mode="diff"
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
/>
diff --git a/packages/ui/src/context/diff.tsx b/packages/ui/src/context/diff.tsx
deleted file mode 100644
index 747de9cc8..000000000
--- a/packages/ui/src/context/diff.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import type { ValidComponent } from "solid-js"
-import { createSimpleContext } from "./helper"
-
-const ctx = createSimpleContext<ValidComponent, { component: ValidComponent }>({
- name: "DiffComponent",
- init: (props) => props.component,
-})
-
-export const DiffComponentProvider = ctx.provider
-export const useDiffComponent = ctx.use
diff --git a/packages/ui/src/context/code.tsx b/packages/ui/src/context/file.tsx
index 3a2511527..f94368cb1 100644
--- a/packages/ui/src/context/code.tsx
+++ b/packages/ui/src/context/file.tsx
@@ -2,9 +2,9 @@ import type { ValidComponent } from "solid-js"
import { createSimpleContext } from "./helper"
const ctx = createSimpleContext<ValidComponent, { component: ValidComponent }>({
- name: "CodeComponent",
+ name: "FileComponent",
init: (props) => props.component,
})
-export const CodeComponentProvider = ctx.provider
-export const useCodeComponent = ctx.use
+export const FileComponentProvider = ctx.provider
+export const useFileComponent = ctx.use
diff --git a/packages/ui/src/context/index.ts b/packages/ui/src/context/index.ts
index 5615dd0ec..2db004985 100644
--- a/packages/ui/src/context/index.ts
+++ b/packages/ui/src/context/index.ts
@@ -1,5 +1,5 @@
export * from "./helper"
export * from "./data"
-export * from "./diff"
+export * from "./file"
export * from "./dialog"
export * from "./i18n"
diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts
index 4d79f3d00..9739edf14 100644
--- a/packages/ui/src/i18n/ar.ts
+++ b/packages/ui/src/i18n/ar.ts
@@ -13,6 +13,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "Diff كبير جدا لعرضه",
"ui.sessionReview.largeDiff.meta": "الحد: {{limit}} سطرًا متغيرًا. الحالي: {{current}} سطرًا متغيرًا.",
"ui.sessionReview.largeDiff.renderAnyway": "اعرض على أي حال",
+ "ui.fileMedia.kind.image": "صورة",
+ "ui.fileMedia.kind.audio": "صوت",
+ "ui.fileMedia.state.removed": "تمت إزالة {{kind}}",
+ "ui.fileMedia.state.loading": "جاري تحميل {{kind}}...",
+ "ui.fileMedia.state.error": "خطأ في تحميل {{kind}}",
+ "ui.fileMedia.state.unavailable": "{{kind}} غير متوفر",
+ "ui.fileMedia.binary.title": "ملف ثنائي",
+ "ui.fileMedia.binary.description.path": "{{path}} عبارة عن ملف ثنائي ولا يمكن عرضه.",
+ "ui.fileMedia.binary.description.default": "هذا ملف ثنائي ولا يمكن عرضه.",
"ui.lineComment.label.prefix": "تعليق على ",
"ui.lineComment.label.suffix": "",
diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts
index 777f1455b..36e4fa8d8 100644
--- a/packages/ui/src/i18n/br.ts
+++ b/packages/ui/src/i18n/br.ts
@@ -13,6 +13,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "Diff grande demais para renderizar",
"ui.sessionReview.largeDiff.meta": "Limite: {{limit}} linhas alteradas. Atual: {{current}} linhas alteradas.",
"ui.sessionReview.largeDiff.renderAnyway": "Renderizar mesmo assim",
+ "ui.fileMedia.kind.image": "imagem",
+ "ui.fileMedia.kind.audio": "áudio",
+ "ui.fileMedia.state.removed": "Removido: {{kind}}",
+ "ui.fileMedia.state.loading": "Carregando {{kind}}...",
+ "ui.fileMedia.state.error": "Erro ao carregar {{kind}}",
+ "ui.fileMedia.state.unavailable": "{{kind}} indisponível",
+ "ui.fileMedia.binary.title": "Arquivo binário",
+ "ui.fileMedia.binary.description.path": "Não é possível exibir {{path}} porque é um arquivo binário.",
+ "ui.fileMedia.binary.description.default": "Não é possível exibir o arquivo porque ele é binário.",
"ui.lineComment.label.prefix": "Comentar em ",
"ui.lineComment.label.suffix": "",
diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts
index e499647df..6727cc50c 100644
--- a/packages/ui/src/i18n/bs.ts
+++ b/packages/ui/src/i18n/bs.ts
@@ -17,6 +17,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "Diff je prevelik za prikaz",
"ui.sessionReview.largeDiff.meta": "Limit: {{limit}} izmijenjenih linija. Trenutno: {{current}} izmijenjenih linija.",
"ui.sessionReview.largeDiff.renderAnyway": "Prikaži svejedno",
+ "ui.fileMedia.kind.image": "slika",
+ "ui.fileMedia.kind.audio": "audio",
+ "ui.fileMedia.state.removed": "Uklonjeno: {{kind}}",
+ "ui.fileMedia.state.loading": "Učitavanje: {{kind}}...",
+ "ui.fileMedia.state.error": "Greška pri učitavanju: {{kind}}",
+ "ui.fileMedia.state.unavailable": "Nedostupno: {{kind}}",
+ "ui.fileMedia.binary.title": "Binarni fajl",
+ "ui.fileMedia.binary.description.path": "{{path}} se ne može prikazati jer je binarni fajl.",
+ "ui.fileMedia.binary.description.default": "Ovaj fajl se ne može prikazati jer je binarni.",
"ui.lineComment.label.prefix": "Komentar na ",
"ui.lineComment.label.suffix": "",
diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts
index 546040598..48afb6cbe 100644
--- a/packages/ui/src/i18n/da.ts
+++ b/packages/ui/src/i18n/da.ts
@@ -14,6 +14,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "Diff er for stor til at blive vist",
"ui.sessionReview.largeDiff.meta": "Grænse: {{limit}} ændrede linjer. Nuværende: {{current}} ændrede linjer.",
"ui.sessionReview.largeDiff.renderAnyway": "Vis alligevel",
+ "ui.fileMedia.kind.image": "billede",
+ "ui.fileMedia.kind.audio": "lyd",
+ "ui.fileMedia.state.removed": "Fjernet: {{kind}}",
+ "ui.fileMedia.state.loading": "Indlæser {{kind}}...",
+ "ui.fileMedia.state.error": "Fejl ved indlæsning: {{kind}}",
+ "ui.fileMedia.state.unavailable": "Utilgængelig: {{kind}}",
+ "ui.fileMedia.binary.title": "Binær fil",
+ "ui.fileMedia.binary.description.path": "{{path}} kan ikke vises, fordi det er en binær fil.",
+ "ui.fileMedia.binary.description.default": "Denne fil kan ikke vises, fordi det er en binær fil.",
"ui.lineComment.label.prefix": "Kommenter på ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Kommenterer på ",
diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts
index bf5730f85..5f4225343 100644
--- a/packages/ui/src/i18n/de.ts
+++ b/packages/ui/src/i18n/de.ts
@@ -18,6 +18,17 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "Diff zu groß zum Rendern",
"ui.sessionReview.largeDiff.meta": "Limit: {{limit}} geänderte Zeilen. Aktuell: {{current}} geänderte Zeilen.",
"ui.sessionReview.largeDiff.renderAnyway": "Trotzdem rendern",
+ "ui.fileMedia.kind.image": "bild",
+ "ui.fileMedia.kind.audio": "audio",
+ "ui.fileMedia.state.removed": "{{kind}} entfernt",
+ "ui.fileMedia.state.loading": "{{kind}} wird geladen",
+ "ui.fileMedia.state.error": "Fehler bei {{kind}}",
+ "ui.fileMedia.state.unavailable": "{{kind}} nicht verfügbar",
+ "ui.fileMedia.binary.title": "Binärdatei",
+ "ui.fileMedia.binary.description.path":
+ "{{path}} kann nicht angezeigt werden, da es sich um eine Binärdatei handelt.",
+ "ui.fileMedia.binary.description.default":
+ "Diese Datei kann nicht angezeigt werden, da es sich um eine Binärdatei handelt.",
"ui.lineComment.label.prefix": "Kommentar zu ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Kommentiere ",
diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts
index 4c9b89c6c..fe1b2ee89 100644
--- a/packages/ui/src/i18n/en.ts
+++ b/packages/ui/src/i18n/en.ts
@@ -14,6 +14,16 @@ export const dict = {
"ui.sessionReview.largeDiff.meta": "Limit: {{limit}} changed lines. Current: {{current}} changed lines.",
"ui.sessionReview.largeDiff.renderAnyway": "Render anyway",
+ "ui.fileMedia.kind.image": "image",
+ "ui.fileMedia.kind.audio": "audio",
+ "ui.fileMedia.state.removed": "Removed {{kind}} file.",
+ "ui.fileMedia.state.loading": "Loading {{kind}}...",
+ "ui.fileMedia.state.error": "Unable to load {{kind}}.",
+ "ui.fileMedia.state.unavailable": "{{kind}} preview unavailable.",
+ "ui.fileMedia.binary.title": "Binary file",
+ "ui.fileMedia.binary.description.path": "{{path}} is binary.",
+ "ui.fileMedia.binary.description.default": "Binary content",
+
"ui.lineComment.label.prefix": "Comment on ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Commenting on ",
diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts
index 2f21b398f..124a3c387 100644
--- a/packages/ui/src/i18n/es.ts
+++ b/packages/ui/src/i18n/es.ts
@@ -13,6 +13,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "Diff demasiado grande para renderizar",
"ui.sessionReview.largeDiff.meta": "Límite: {{limit}} líneas modificadas. Actual: {{current}} líneas modificadas.",
"ui.sessionReview.largeDiff.renderAnyway": "Renderizar de todos modos",
+ "ui.fileMedia.kind.image": "imagen",
+ "ui.fileMedia.kind.audio": "audio",
+ "ui.fileMedia.state.removed": "Archivo de {{kind}} eliminado",
+ "ui.fileMedia.state.loading": "Cargando archivo de {{kind}}",
+ "ui.fileMedia.state.error": "Error en el archivo de {{kind}}",
+ "ui.fileMedia.state.unavailable": "Archivo de {{kind}} no disponible",
+ "ui.fileMedia.binary.title": "Archivo binario",
+ "ui.fileMedia.binary.description.path": "No se puede mostrar {{path}} porque es un archivo binario.",
+ "ui.fileMedia.binary.description.default": "No se puede mostrar este archivo porque es un archivo binario.",
"ui.lineComment.label.prefix": "Comentar en ",
"ui.lineComment.label.suffix": "",
diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts
index d4ea93868..13fda5891 100644
--- a/packages/ui/src/i18n/fr.ts
+++ b/packages/ui/src/i18n/fr.ts
@@ -13,6 +13,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "Diff trop volumineux pour être affiché",
"ui.sessionReview.largeDiff.meta": "Limite : {{limit}} lignes modifiées. Actuel : {{current}} lignes modifiées.",
"ui.sessionReview.largeDiff.renderAnyway": "Afficher quand même",
+ "ui.fileMedia.kind.image": "image",
+ "ui.fileMedia.kind.audio": "audio",
+ "ui.fileMedia.state.removed": "Fichier {{kind}} supprimé",
+ "ui.fileMedia.state.loading": "Chargement du fichier {{kind}}",
+ "ui.fileMedia.state.error": "Erreur avec le fichier {{kind}}",
+ "ui.fileMedia.state.unavailable": "Fichier {{kind}} indisponible",
+ "ui.fileMedia.binary.title": "Fichier binaire",
+ "ui.fileMedia.binary.description.path": "Impossible d'afficher {{path}} car il s'agit d'un fichier binaire.",
+ "ui.fileMedia.binary.description.default": "Impossible d'afficher ce fichier car il s'agit d'un fichier binaire.",
"ui.lineComment.label.prefix": "Commenter sur ",
"ui.lineComment.label.suffix": "",
diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts
index 0a4366ebe..27e7f32ab 100644
--- a/packages/ui/src/i18n/ja.ts
+++ b/packages/ui/src/i18n/ja.ts
@@ -14,6 +14,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "差分が大きすぎて表示できません",
"ui.sessionReview.largeDiff.meta": "上限: {{limit}} 変更行。現在: {{current}} 変更行。",
"ui.sessionReview.largeDiff.renderAnyway": "それでも表示する",
+ "ui.fileMedia.kind.image": "画像",
+ "ui.fileMedia.kind.audio": "音声",
+ "ui.fileMedia.state.removed": "{{kind}}は削除されました",
+ "ui.fileMedia.state.loading": "{{kind}}を読み込んでいます...",
+ "ui.fileMedia.state.error": "{{kind}}の読み込みに失敗しました",
+ "ui.fileMedia.state.unavailable": "{{kind}}は表示できません",
+ "ui.fileMedia.binary.title": "バイナリファイル",
+ "ui.fileMedia.binary.description.path": "{{path}} はバイナリファイルのため表示できません。",
+ "ui.fileMedia.binary.description.default": "このファイルはバイナリファイルのため表示できません。",
"ui.lineComment.label.prefix": "",
"ui.lineComment.label.suffix": "へのコメント",
"ui.lineComment.editorLabel.prefix": "",
diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts
index 58bd51b99..4ac8f4a30 100644
--- a/packages/ui/src/i18n/ko.ts
+++ b/packages/ui/src/i18n/ko.ts
@@ -13,6 +13,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "차이가 너무 커서 렌더링할 수 없습니다",
"ui.sessionReview.largeDiff.meta": "제한: {{limit}} 변경 줄. 현재: {{current}} 변경 줄.",
"ui.sessionReview.largeDiff.renderAnyway": "그래도 렌더링",
+ "ui.fileMedia.kind.image": "이미지",
+ "ui.fileMedia.kind.audio": "오디오",
+ "ui.fileMedia.state.removed": "{{kind}} 제거됨",
+ "ui.fileMedia.state.loading": "{{kind}} 로드 중...",
+ "ui.fileMedia.state.error": "{{kind}} 로드 오류",
+ "ui.fileMedia.state.unavailable": "{{kind}} 사용 불가",
+ "ui.fileMedia.binary.title": "바이너리 파일",
+ "ui.fileMedia.binary.description.path": "{{path}}은(는) 바이너리 파일이므로 표시할 수 없습니다.",
+ "ui.fileMedia.binary.description.default": "바이너리 파일이므로 표시할 수 없습니다.",
"ui.lineComment.label.prefix": "",
"ui.lineComment.label.suffix": "에 댓글 달기",
diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts
index b7e604f9a..5f414209b 100644
--- a/packages/ui/src/i18n/no.ts
+++ b/packages/ui/src/i18n/no.ts
@@ -16,6 +16,15 @@ export const dict: Record<Keys, string> = {
"ui.sessionReview.largeDiff.title": "Diff er for stor til å gjengi",
"ui.sessionReview.largeDiff.meta": "Grense: {{limit}} endrede linjer. Nåværende: {{current}} endrede linjer.",
"ui.sessionReview.largeDiff.renderAnyway": "Gjengi likevel",
+ "ui.fileMedia.kind.image": "bilde",
+ "ui.fileMedia.kind.audio": "lyd",
+ "ui.fileMedia.state.removed": "Fjernet: {{kind}}",
+ "ui.fileMedia.state.loading": "Laster inn {{kind}}...",
+ "ui.fileMedia.state.error": "Feil ved innlasting: {{kind}}",
+ "ui.fileMedia.state.unavailable": "Ikke tilgjengelig: {{kind}}",
+ "ui.fileMedia.binary.title": "Binærfil",
+ "ui.fileMedia.binary.description.path": "{{path}} kan ikke vises fordi det er en binærfil.",
+ "ui.fileMedia.binary.description.default": "Denne filen kan ikke vises fordi det er en binærfil.",
"ui.lineComment.label.prefix": "Kommenter på ",
"ui.lineComment.label.suffix": "",
diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts
index fbccb9220..b0ef94dd4 100644
--- a/packages/ui/src/i18n/pl.ts
+++ b/packages/ui/src/i18n/pl.ts
@@ -14,6 +14,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "Diff jest zbyt duży, aby go wyrenderować",
"ui.sessionReview.largeDiff.meta": "Limit: {{limit}} zmienionych linii. Obecnie: {{current}} zmienionych linii.",
"ui.sessionReview.largeDiff.renderAnyway": "Renderuj mimo to",
+ "ui.fileMedia.kind.image": "obraz",
+ "ui.fileMedia.kind.audio": "dźwięk",
+ "ui.fileMedia.state.removed": "{{kind}} usunięty",
+ "ui.fileMedia.state.loading": "Wczytywanie: {{kind}}...",
+ "ui.fileMedia.state.error": "Błąd wczytywania: {{kind}}",
+ "ui.fileMedia.state.unavailable": "{{kind}} niedostępny",
+ "ui.fileMedia.binary.title": "Plik binarny",
+ "ui.fileMedia.binary.description.path": "Nie można wyświetlić pliku {{path}}, ponieważ jest to plik binarny.",
+ "ui.fileMedia.binary.description.default": "Nie można wyświetlić tego pliku, ponieważ jest to plik binarny.",
"ui.lineComment.label.prefix": "Komentarz do ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Komentowanie: ",
diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts
index 705f2d210..6c2eb290d 100644
--- a/packages/ui/src/i18n/ru.ts
+++ b/packages/ui/src/i18n/ru.ts
@@ -14,6 +14,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "Diff слишком большой для отображения",
"ui.sessionReview.largeDiff.meta": "Лимит: {{limit}} изменённых строк. Текущий: {{current}} изменённых строк.",
"ui.sessionReview.largeDiff.renderAnyway": "Отобразить всё равно",
+ "ui.fileMedia.kind.image": "изображение",
+ "ui.fileMedia.kind.audio": "аудио",
+ "ui.fileMedia.state.removed": "{{kind}} удалено",
+ "ui.fileMedia.state.loading": "Загружается {{kind}}...",
+ "ui.fileMedia.state.error": "Не удалось загрузить {{kind}}",
+ "ui.fileMedia.state.unavailable": "{{kind}} недоступно",
+ "ui.fileMedia.binary.title": "Бинарный файл",
+ "ui.fileMedia.binary.description.path": "Невозможно отобразить {{path}}, так как это бинарный файл.",
+ "ui.fileMedia.binary.description.default": "Невозможно отобразить этот файл, так как он бинарный.",
"ui.lineComment.label.prefix": "Комментарий к ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Комментирование: ",
diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts
index cf536e1ff..091d1b70c 100644
--- a/packages/ui/src/i18n/th.ts
+++ b/packages/ui/src/i18n/th.ts
@@ -14,6 +14,15 @@ export const dict = {
"ui.sessionReview.largeDiff.meta":
"ขีดจำกัด: {{limit}} บรรทัดที่เปลี่ยนแปลง. ปัจจุบัน: {{current}} บรรทัดที่เปลี่ยนแปลง.",
"ui.sessionReview.largeDiff.renderAnyway": "แสดงผลต่อไป",
+ "ui.fileMedia.kind.image": "รูปภาพ",
+ "ui.fileMedia.kind.audio": "เสียง",
+ "ui.fileMedia.state.removed": "ลบ{{kind}}แล้ว",
+ "ui.fileMedia.state.loading": "กำลังโหลด{{kind}}...",
+ "ui.fileMedia.state.error": "เกิดข้อผิดพลาดในการโหลด{{kind}}",
+ "ui.fileMedia.state.unavailable": "{{kind}}ไม่พร้อมใช้งาน",
+ "ui.fileMedia.binary.title": "ไฟล์ไบนารี",
+ "ui.fileMedia.binary.description.path": "{{path}} เป็นไฟล์ไบนารีและไม่สามารถแสดงผลได้",
+ "ui.fileMedia.binary.description.default": "ไฟล์ไบนารีไม่สามารถแสดงผลได้",
"ui.lineComment.label.prefix": "แสดงความคิดเห็นบน ",
"ui.lineComment.label.suffix": "",
diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts
index 5d3d5613d..8e7d9fcd2 100644
--- a/packages/ui/src/i18n/zh.ts
+++ b/packages/ui/src/i18n/zh.ts
@@ -17,6 +17,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "差异过大,无法渲染",
"ui.sessionReview.largeDiff.meta": "限制:{{limit}} 行变更。当前:{{current}} 行变更。",
"ui.sessionReview.largeDiff.renderAnyway": "仍然渲染",
+ "ui.fileMedia.kind.image": "图片",
+ "ui.fileMedia.kind.audio": "音频",
+ "ui.fileMedia.state.removed": "{{kind}}已移除",
+ "ui.fileMedia.state.loading": "正在加载{{kind}}...",
+ "ui.fileMedia.state.error": "加载{{kind}}失败",
+ "ui.fileMedia.state.unavailable": "{{kind}}不可预览",
+ "ui.fileMedia.binary.title": "二进制文件",
+ "ui.fileMedia.binary.description.path": "无法显示 {{path}},因为它是二进制文件。",
+ "ui.fileMedia.binary.description.default": "无法显示此文件,因为它是二进制文件。",
"ui.lineComment.label.prefix": "评论 ",
"ui.lineComment.label.suffix": "",
diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts
index b61349e25..781cde457 100644
--- a/packages/ui/src/i18n/zht.ts
+++ b/packages/ui/src/i18n/zht.ts
@@ -17,6 +17,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "差異過大,無法渲染",
"ui.sessionReview.largeDiff.meta": "限制:{{limit}} 行變更。目前:{{current}} 行變更。",
"ui.sessionReview.largeDiff.renderAnyway": "仍然渲染",
+ "ui.fileMedia.kind.image": "圖片",
+ "ui.fileMedia.kind.audio": "音訊",
+ "ui.fileMedia.state.removed": "{{kind}}已移除",
+ "ui.fileMedia.state.loading": "正在載入{{kind}}...",
+ "ui.fileMedia.state.error": "載入{{kind}}失敗",
+ "ui.fileMedia.state.unavailable": "{{kind}}無法預覽",
+ "ui.fileMedia.binary.title": "二進位檔案",
+ "ui.fileMedia.binary.description.path": "無法顯示 {{path}},因為它是二進位檔案。",
+ "ui.fileMedia.binary.description.default": "無法顯示此檔案,因為它是二進位檔案。",
"ui.lineComment.label.prefix": "評論 ",
"ui.lineComment.label.suffix": "",
diff --git a/packages/ui/src/pierre/comment-hover.ts b/packages/ui/src/pierre/comment-hover.ts
new file mode 100644
index 000000000..1d3674cf6
--- /dev/null
+++ b/packages/ui/src/pierre/comment-hover.ts
@@ -0,0 +1,74 @@
+export type HoverCommentLine = {
+ lineNumber: number
+ side?: "additions" | "deletions"
+}
+
+export function createHoverCommentUtility(props: {
+ label: string
+ getHoveredLine: () => HoverCommentLine | undefined
+ onSelect: (line: HoverCommentLine) => void
+}) {
+ if (typeof document === "undefined") return
+
+ const button = document.createElement("button")
+ button.type = "button"
+ button.ariaLabel = props.label
+ button.textContent = "+"
+ button.style.width = "20px"
+ button.style.height = "20px"
+ button.style.display = "flex"
+ button.style.alignItems = "center"
+ button.style.justifyContent = "center"
+ button.style.border = "none"
+ button.style.borderRadius = "var(--radius-md)"
+ button.style.background = "var(--icon-interactive-base)"
+ button.style.color = "var(--white)"
+ button.style.boxShadow = "var(--shadow-xs)"
+ button.style.fontSize = "14px"
+ button.style.lineHeight = "1"
+ button.style.cursor = "pointer"
+ button.style.position = "relative"
+ button.style.left = "30px"
+ button.style.top = "calc((var(--diffs-line-height, 24px) - 20px) / 2)"
+
+ let line: HoverCommentLine | undefined
+
+ const sync = () => {
+ const next = props.getHoveredLine()
+ if (!next) return
+ line = next
+ }
+
+ const loop = () => {
+ if (!button.isConnected) return
+ sync()
+ requestAnimationFrame(loop)
+ }
+
+ const open = () => {
+ const next = props.getHoveredLine() ?? line
+ if (!next) return
+ props.onSelect(next)
+ }
+
+ requestAnimationFrame(loop)
+ button.addEventListener("mouseenter", sync)
+ button.addEventListener("mousemove", sync)
+ button.addEventListener("pointerdown", (event) => {
+ event.preventDefault()
+ event.stopPropagation()
+ sync()
+ })
+ button.addEventListener("mousedown", (event) => {
+ event.preventDefault()
+ event.stopPropagation()
+ sync()
+ })
+ button.addEventListener("click", (event) => {
+ event.preventDefault()
+ event.stopPropagation()
+ open()
+ })
+
+ return button
+}
diff --git a/packages/ui/src/pierre/commented-lines.ts b/packages/ui/src/pierre/commented-lines.ts
new file mode 100644
index 000000000..d2fa64866
--- /dev/null
+++ b/packages/ui/src/pierre/commented-lines.ts
@@ -0,0 +1,91 @@
+import { type SelectedLineRange } from "@pierre/diffs"
+import { diffLineIndex, diffRowIndex, findDiffSide } from "./diff-selection"
+
+export type CommentSide = "additions" | "deletions"
+
+function annotationIndex(node: HTMLElement) {
+ const value = node.dataset.lineAnnotation?.split(",")[1]
+ if (!value) return
+ const line = parseInt(value, 10)
+ if (Number.isNaN(line)) return
+ return line
+}
+
+function clear(root: ShadowRoot) {
+ const marked = Array.from(root.querySelectorAll("[data-comment-selected]"))
+ for (const node of marked) {
+ if (!(node instanceof HTMLElement)) continue
+ node.removeAttribute("data-comment-selected")
+ }
+}
+
+export function markCommentedDiffLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
+ clear(root)
+
+ const diffs = root.querySelector("[data-diff]")
+ if (!(diffs instanceof HTMLElement)) return
+
+ const split = diffs.dataset.diffType === "split"
+ const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
+ (node): node is HTMLElement => node instanceof HTMLElement,
+ )
+ if (rows.length === 0) return
+
+ const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
+ (node): node is HTMLElement => node instanceof HTMLElement,
+ )
+
+ for (const range of ranges) {
+ const start = diffRowIndex(root, split, range.start, range.side as CommentSide | undefined)
+ if (start === undefined) continue
+
+ const end = (() => {
+ const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
+ if (same) return start
+ return diffRowIndex(root, split, range.end, (range.endSide ?? range.side) as CommentSide | undefined)
+ })()
+ if (end === undefined) continue
+
+ const first = Math.min(start, end)
+ const last = Math.max(start, end)
+
+ for (const row of rows) {
+ const idx = diffLineIndex(split, row)
+ if (idx === undefined || idx < first || idx > last) continue
+ row.setAttribute("data-comment-selected", "")
+ }
+
+ for (const annotation of annotations) {
+ const idx = annotationIndex(annotation)
+ if (idx === undefined || idx < first || idx > last) continue
+ annotation.setAttribute("data-comment-selected", "")
+ }
+ }
+}
+
+export function markCommentedFileLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
+ clear(root)
+
+ const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter(
+ (node): node is HTMLElement => node instanceof HTMLElement,
+ )
+
+ for (const range of ranges) {
+ const start = Math.max(1, Math.min(range.start, range.end))
+ const end = Math.max(range.start, range.end)
+
+ for (let line = start; line <= end; line++) {
+ const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`))
+ for (const node of nodes) {
+ if (!(node instanceof HTMLElement)) continue
+ node.setAttribute("data-comment-selected", "")
+ }
+ }
+
+ for (const annotation of annotations) {
+ const line = annotationIndex(annotation)
+ if (line === undefined || line < start || line > end) continue
+ annotation.setAttribute("data-comment-selected", "")
+ }
+ }
+}
diff --git a/packages/ui/src/pierre/diff-selection.ts b/packages/ui/src/pierre/diff-selection.ts
new file mode 100644
index 000000000..bc008b1b2
--- /dev/null
+++ b/packages/ui/src/pierre/diff-selection.ts
@@ -0,0 +1,71 @@
+import { type SelectedLineRange } from "@pierre/diffs"
+
+export type DiffSelectionSide = "additions" | "deletions"
+
+export function findDiffSide(node: HTMLElement): DiffSelectionSide {
+ const line = node.closest("[data-line], [data-alt-line]")
+ if (line instanceof HTMLElement) {
+ const type = line.dataset.lineType
+ if (type === "change-deletion") return "deletions"
+ if (type === "change-addition" || type === "change-additions") return "additions"
+ }
+
+ const code = node.closest("[data-code]")
+ if (!(code instanceof HTMLElement)) return "additions"
+ return code.hasAttribute("data-deletions") ? "deletions" : "additions"
+}
+
+export function diffLineIndex(split: boolean, node: HTMLElement) {
+ const raw = node.dataset.lineIndex
+ if (!raw) return
+
+ const values = raw
+ .split(",")
+ .map((x) => parseInt(x, 10))
+ .filter((x) => !Number.isNaN(x))
+ if (values.length === 0) return
+ if (!split) return values[0]
+ if (values.length === 2) return values[1]
+ return values[0]
+}
+
+export function diffRowIndex(root: ShadowRoot, split: boolean, line: number, side: DiffSelectionSide | undefined) {
+ const rows = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
+ (node): node is HTMLElement => node instanceof HTMLElement,
+ )
+ if (rows.length === 0) return
+
+ const target = side ?? "additions"
+ for (const row of rows) {
+ if (findDiffSide(row) === target) return diffLineIndex(split, row)
+ if (parseInt(row.dataset.altLine ?? "", 10) === line) return diffLineIndex(split, row)
+ }
+}
+
+export function fixDiffSelection(root: ShadowRoot | undefined, range: SelectedLineRange | null) {
+ if (!range) return range
+ if (!root) return
+
+ const diffs = root.querySelector("[data-diff]")
+ if (!(diffs instanceof HTMLElement)) return
+
+ const split = diffs.dataset.diffType === "split"
+ const start = diffRowIndex(root, split, range.start, range.side)
+ const end = diffRowIndex(root, split, range.end, range.endSide ?? range.side)
+
+ if (start === undefined || end === undefined) {
+ if (root.querySelector("[data-line], [data-alt-line]") == null) return
+ return null
+ }
+ if (start <= end) return range
+
+ const side = range.endSide ?? range.side
+ const swapped: SelectedLineRange = {
+ start: range.end,
+ end: range.start,
+ }
+
+ if (side) swapped.side = side
+ if (range.endSide && range.side) swapped.endSide = range.side
+ return swapped
+}
diff --git a/packages/ui/src/pierre/file-find.ts b/packages/ui/src/pierre/file-find.ts
new file mode 100644
index 000000000..7d55cfa72
--- /dev/null
+++ b/packages/ui/src/pierre/file-find.ts
@@ -0,0 +1,576 @@
+import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
+
+export type FindHost = {
+ element: () => HTMLElement | undefined
+ open: () => void
+ close: () => void
+ next: (dir: 1 | -1) => void
+ isOpen: () => boolean
+}
+
+type FileFindSide = "additions" | "deletions"
+
+export type FileFindReveal = {
+ side: FileFindSide
+ line: number
+ col: number
+ len: number
+}
+
+type FileFindHit = FileFindReveal & {
+ range: Range
+ alt?: number
+}
+
+const hosts = new Set<FindHost>()
+let target: FindHost | undefined
+let current: FindHost | undefined
+let installed = false
+
+function isEditable(node: unknown): boolean {
+ if (!(node instanceof HTMLElement)) return false
+ if (node.closest("[data-prevent-autofocus]")) return true
+ if (node.isContentEditable) return true
+ return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName)
+}
+
+function hostForNode(node: unknown) {
+ if (!(node instanceof Node)) return
+ for (const host of hosts) {
+ const el = host.element()
+ if (el && el.isConnected && el.contains(node)) return host
+ }
+}
+
+function installShortcuts() {
+ if (installed) return
+ if (typeof window === "undefined") return
+ installed = true
+
+ window.addEventListener(
+ "keydown",
+ (event) => {
+ if (event.defaultPrevented) return
+ if (isEditable(event.target)) return
+
+ const mod = event.metaKey || event.ctrlKey
+ if (!mod) return
+
+ const key = event.key.toLowerCase()
+ if (key === "g") {
+ const host = current
+ if (!host || !host.isOpen()) return
+ event.preventDefault()
+ event.stopPropagation()
+ host.next(event.shiftKey ? -1 : 1)
+ return
+ }
+
+ if (key !== "f") return
+
+ const active = current
+ if (active && active.isOpen()) {
+ event.preventDefault()
+ event.stopPropagation()
+ active.open()
+ return
+ }
+
+ const host = hostForNode(document.activeElement) ?? hostForNode(event.target) ?? target ?? Array.from(hosts)[0]
+ if (!host) return
+
+ event.preventDefault()
+ event.stopPropagation()
+ host.open()
+ },
+ { capture: true },
+ )
+}
+
+function clearHighlightFind() {
+ const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights
+ if (!api) return
+ api.delete("opencode-find")
+ api.delete("opencode-find-current")
+}
+
+function supportsHighlights() {
+ const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown }
+ return typeof g.Highlight === "function" && g.CSS?.highlights != null
+}
+
+function scrollParent(el: HTMLElement): HTMLElement | undefined {
+ let parent = el.parentElement
+ while (parent) {
+ const style = getComputedStyle(parent)
+ if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
+ parent = parent.parentElement
+ }
+}
+
+type CreateFileFindOptions = {
+ wrapper: () => HTMLElement | undefined
+ overlay: () => HTMLDivElement | undefined
+ getRoot: () => ShadowRoot | undefined
+ shortcuts?: "global" | "disabled"
+}
+
+export function createFileFind(opts: CreateFileFindOptions) {
+ let input: HTMLInputElement | undefined
+ let overlayFrame: number | undefined
+ let overlayScroll: HTMLElement[] = []
+ let mode: "highlights" | "overlay" = "overlay"
+ let hits: FileFindHit[] = []
+
+ const [open, setOpen] = createSignal(false)
+ const [query, setQuery] = createSignal("")
+ const [index, setIndex] = createSignal(0)
+ const [count, setCount] = createSignal(0)
+ const [pos, setPos] = createSignal({ top: 8, right: 8 })
+
+ const clearOverlayScroll = () => {
+ for (const el of overlayScroll) el.removeEventListener("scroll", scheduleOverlay)
+ overlayScroll = []
+ }
+
+ const clearOverlay = () => {
+ const el = opts.overlay()
+ if (!el) return
+ if (overlayFrame !== undefined) {
+ cancelAnimationFrame(overlayFrame)
+ overlayFrame = undefined
+ }
+ el.innerHTML = ""
+ }
+
+ const renderOverlay = () => {
+ if (mode !== "overlay") {
+ clearOverlay()
+ return
+ }
+
+ const wrapper = opts.wrapper()
+ const overlay = opts.overlay()
+ if (!wrapper || !overlay) return
+
+ clearOverlay()
+ if (hits.length === 0) return
+
+ const base = wrapper.getBoundingClientRect()
+ const currentIndex = index()
+ const frag = document.createDocumentFragment()
+
+ for (let i = 0; i < hits.length; i++) {
+ const range = hits[i].range
+ const active = i === currentIndex
+ for (const rect of Array.from(range.getClientRects())) {
+ if (!rect.width || !rect.height) continue
+
+ const mark = document.createElement("div")
+ mark.style.position = "absolute"
+ mark.style.left = `${Math.round(rect.left - base.left)}px`
+ mark.style.top = `${Math.round(rect.top - base.top)}px`
+ mark.style.width = `${Math.round(rect.width)}px`
+ mark.style.height = `${Math.round(rect.height)}px`
+ mark.style.borderRadius = "2px"
+ mark.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)"
+ mark.style.opacity = active ? "0.55" : "0.35"
+ if (active) mark.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)"
+ frag.appendChild(mark)
+ }
+ }
+
+ overlay.appendChild(frag)
+ }
+
+ function scheduleOverlay() {
+ if (mode !== "overlay") return
+ if (!open()) return
+ if (overlayFrame !== undefined) return
+
+ overlayFrame = requestAnimationFrame(() => {
+ overlayFrame = undefined
+ renderOverlay()
+ })
+ }
+
+ const syncOverlayScroll = () => {
+ if (mode !== "overlay") return
+ const root = opts.getRoot()
+
+ const next = root
+ ? Array.from(root.querySelectorAll("[data-code]")).filter(
+ (node): node is HTMLElement => node instanceof HTMLElement,
+ )
+ : []
+ if (next.length === overlayScroll.length && next.every((el, i) => el === overlayScroll[i])) return
+
+ clearOverlayScroll()
+ overlayScroll = next
+ for (const el of overlayScroll) el.addEventListener("scroll", scheduleOverlay, { passive: true })
+ }
+
+ const clearFind = () => {
+ clearHighlightFind()
+ clearOverlay()
+ clearOverlayScroll()
+ hits = []
+ setCount(0)
+ setIndex(0)
+ }
+
+ const positionBar = () => {
+ if (typeof window === "undefined") return
+ const wrapper = opts.wrapper()
+ if (!wrapper) return
+
+ const root = scrollParent(wrapper) ?? wrapper
+ const rect = root.getBoundingClientRect()
+ const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
+ const header = Number.isNaN(title) ? 0 : title
+
+ setPos({
+ top: Math.round(rect.top) + header - 4,
+ right: Math.round(window.innerWidth - rect.right) + 8,
+ })
+ }
+
+ const scan = (root: ShadowRoot, value: string) => {
+ const needle = value.toLowerCase()
+ const ranges: FileFindHit[] = []
+ const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter(
+ (node): node is HTMLElement => node instanceof HTMLElement,
+ )
+
+ for (const col of cols) {
+ const text = col.textContent
+ if (!text) continue
+
+ const hay = text.toLowerCase()
+ let at = hay.indexOf(needle)
+ if (at === -1) continue
+
+ const row = col.closest("[data-line], [data-alt-line]")
+ if (!(row instanceof HTMLElement)) continue
+
+ const primary = parseInt(row.dataset.line ?? "", 10)
+ const alt = parseInt(row.dataset.altLine ?? "", 10)
+ const line = (() => {
+ if (!Number.isNaN(primary)) return primary
+ if (!Number.isNaN(alt)) return alt
+ })()
+ if (line === undefined) continue
+
+ const side = (() => {
+ const code = col.closest("[data-code]")
+ if (code instanceof HTMLElement) return code.hasAttribute("data-deletions") ? "deletions" : "additions"
+
+ const row = col.closest("[data-line-type]")
+ if (!(row instanceof HTMLElement)) return "additions"
+ const type = row.dataset.lineType
+ if (type === "change-deletion") return "deletions"
+ return "additions"
+ })() as FileFindSide
+
+ const nodes: Text[] = []
+ const ends: number[] = []
+ const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT)
+ let node = walker.nextNode()
+ let pos = 0
+ while (node) {
+ if (node instanceof Text) {
+ pos += node.data.length
+ nodes.push(node)
+ ends.push(pos)
+ }
+ node = walker.nextNode()
+ }
+ if (nodes.length === 0) continue
+
+ const locate = (offset: number) => {
+ let lo = 0
+ let hi = ends.length - 1
+ while (lo < hi) {
+ const mid = (lo + hi) >> 1
+ if (ends[mid] >= offset) hi = mid
+ else lo = mid + 1
+ }
+ const prev = lo === 0 ? 0 : ends[lo - 1]
+ return { node: nodes[lo], offset: offset - prev }
+ }
+
+ while (at !== -1) {
+ const start = locate(at)
+ const end = locate(at + value.length)
+ const range = document.createRange()
+ range.setStart(start.node, start.offset)
+ range.setEnd(end.node, end.offset)
+ ranges.push({
+ range,
+ side,
+ line,
+ alt: Number.isNaN(alt) ? undefined : alt,
+ col: at + 1,
+ len: value.length,
+ })
+ at = hay.indexOf(needle, at + value.length)
+ }
+ }
+
+ return ranges
+ }
+
+ const scrollToRange = (range: Range) => {
+ const scroll = () => {
+ const start = range.startContainer
+ const el = start instanceof Element ? start : start.parentElement
+ el?.scrollIntoView({ block: "center", inline: "center" })
+ }
+
+ scroll()
+ requestAnimationFrame(scroll)
+ }
+
+ const setHighlights = (ranges: FileFindHit[], currentIndex: number) => {
+ const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights
+ const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight
+ if (!api || typeof Highlight !== "function") return false
+
+ api.delete("opencode-find")
+ api.delete("opencode-find-current")
+
+ const active = ranges[currentIndex]?.range
+ if (active) api.set("opencode-find-current", new Highlight(active))
+
+ const rest = ranges.flatMap((hit, i) => (i === currentIndex ? [] : [hit.range]))
+ if (rest.length > 0) api.set("opencode-find", new Highlight(...rest))
+ return true
+ }
+
+ const select = (currentIndex: number, scroll: boolean) => {
+ const active = hits[currentIndex]?.range
+ if (!active) return false
+
+ setIndex(currentIndex)
+
+ if (mode === "highlights") {
+ if (!setHighlights(hits, currentIndex)) {
+ mode = "overlay"
+ apply({ reset: true, scroll })
+ return false
+ }
+ if (scroll) scrollToRange(active)
+ return true
+ }
+
+ clearHighlightFind()
+ syncOverlayScroll()
+ if (scroll) scrollToRange(active)
+ scheduleOverlay()
+ return true
+ }
+
+ const apply = (args?: { reset?: boolean; scroll?: boolean }) => {
+ if (!open()) return
+
+ const value = query().trim()
+ if (!value) {
+ clearFind()
+ return
+ }
+
+ const root = opts.getRoot()
+ if (!root) return
+
+ mode = supportsHighlights() ? "highlights" : "overlay"
+
+ const ranges = scan(root, value)
+ const total = ranges.length
+ const desired = args?.reset ? 0 : index()
+ const currentIndex = total ? Math.min(desired, total - 1) : 0
+
+ hits = ranges
+ setCount(total)
+ setIndex(currentIndex)
+
+ const active = ranges[currentIndex]?.range
+ if (mode === "highlights") {
+ clearOverlay()
+ clearOverlayScroll()
+ if (!setHighlights(ranges, currentIndex)) {
+ mode = "overlay"
+ clearHighlightFind()
+ syncOverlayScroll()
+ scheduleOverlay()
+ }
+ if (args?.scroll && active) scrollToRange(active)
+ return
+ }
+
+ clearHighlightFind()
+ syncOverlayScroll()
+ if (args?.scroll && active) scrollToRange(active)
+ scheduleOverlay()
+ }
+
+ const close = () => {
+ setOpen(false)
+ setQuery("")
+ clearFind()
+ if (current === host) current = undefined
+ }
+
+ const clear = () => {
+ setQuery("")
+ clearFind()
+ }
+
+ const activate = () => {
+ if (opts.shortcuts !== "disabled") {
+ if (current && current !== host) current.close()
+ current = host
+ target = host
+ }
+
+ if (!open()) setOpen(true)
+ }
+
+ const focus = () => {
+ activate()
+ requestAnimationFrame(() => {
+ apply({ scroll: true })
+ input?.focus()
+ input?.select()
+ })
+ }
+
+ const next = (dir: 1 | -1) => {
+ if (!open()) return
+ const total = count()
+ if (total <= 0) return
+
+ const currentIndex = (index() + dir + total) % total
+ select(currentIndex, true)
+ }
+
+ const reveal = (targetHit: FileFindReveal) => {
+ if (!open()) return false
+ if (hits.length === 0) return false
+
+ const exact = hits.findIndex(
+ (hit) =>
+ hit.side === targetHit.side &&
+ hit.line === targetHit.line &&
+ hit.col === targetHit.col &&
+ hit.len === targetHit.len,
+ )
+ const fallback = hits.findIndex(
+ (hit) =>
+ (hit.line === targetHit.line || hit.alt === targetHit.line) &&
+ hit.col === targetHit.col &&
+ hit.len === targetHit.len,
+ )
+
+ const nextIndex = exact >= 0 ? exact : fallback
+ if (nextIndex < 0) return false
+ return select(nextIndex, true)
+ }
+
+ const host: FindHost = {
+ element: opts.wrapper,
+ isOpen: () => open(),
+ next,
+ open: focus,
+ close,
+ }
+
+ onMount(() => {
+ mode = supportsHighlights() ? "highlights" : "overlay"
+ if (opts.shortcuts !== "disabled") {
+ installShortcuts()
+ hosts.add(host)
+ if (!target) target = host
+ }
+
+ onCleanup(() => {
+ if (opts.shortcuts !== "disabled") {
+ hosts.delete(host)
+ if (current === host) {
+ current = undefined
+ clearHighlightFind()
+ }
+ if (target === host) target = undefined
+ }
+ })
+ })
+
+ createEffect(() => {
+ if (!open()) return
+
+ const update = () => positionBar()
+ requestAnimationFrame(update)
+ window.addEventListener("resize", update, { passive: true })
+
+ const wrapper = opts.wrapper()
+ if (!wrapper) return
+ const root = scrollParent(wrapper) ?? wrapper
+ const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(() => update())
+ observer?.observe(root)
+
+ onCleanup(() => {
+ window.removeEventListener("resize", update)
+ observer?.disconnect()
+ })
+ })
+
+ onCleanup(() => {
+ clearOverlayScroll()
+ clearOverlay()
+ if (current === host) {
+ current = undefined
+ clearHighlightFind()
+ }
+ })
+
+ return {
+ open,
+ query,
+ count,
+ index,
+ pos,
+ setInput: (el: HTMLInputElement) => {
+ input = el
+ },
+ setQuery: (value: string, args?: { scroll?: boolean }) => {
+ setQuery(value)
+ setIndex(0)
+ apply({ reset: true, scroll: args?.scroll ?? true })
+ },
+ clear,
+ activate,
+ focus,
+ close,
+ next,
+ reveal,
+ refresh: (args?: { reset?: boolean; scroll?: boolean }) => apply(args),
+ onPointerDown: () => {
+ if (opts.shortcuts === "disabled") return
+ target = host
+ opts.wrapper()?.focus({ preventScroll: true })
+ },
+ onFocus: () => {
+ if (opts.shortcuts === "disabled") return
+ target = host
+ },
+ onInputKeyDown: (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.preventDefault()
+ close()
+ return
+ }
+ if (event.key !== "Enter") return
+ event.preventDefault()
+ next(event.shiftKey ? -1 : 1)
+ },
+ }
+}
diff --git a/packages/ui/src/pierre/file-runtime.ts b/packages/ui/src/pierre/file-runtime.ts
new file mode 100644
index 000000000..a20721003
--- /dev/null
+++ b/packages/ui/src/pierre/file-runtime.ts
@@ -0,0 +1,114 @@
+type ReadyWatcher = {
+ observer?: MutationObserver
+ token: number
+}
+
+export function createReadyWatcher(): ReadyWatcher {
+ return { token: 0 }
+}
+
+export function clearReadyWatcher(state: ReadyWatcher) {
+ state.observer?.disconnect()
+ state.observer = undefined
+}
+
+export function getViewerHost(container: HTMLElement | undefined) {
+ if (!container) return
+ const host = container.querySelector("diffs-container")
+ if (!(host instanceof HTMLElement)) return
+ return host
+}
+
+export function getViewerRoot(container: HTMLElement | undefined) {
+ return getViewerHost(container)?.shadowRoot ?? undefined
+}
+
+export function applyViewerScheme(host: HTMLElement | undefined) {
+ if (!host) return
+ if (typeof document === "undefined") return
+
+ const scheme = document.documentElement.dataset.colorScheme
+ if (scheme === "dark" || scheme === "light") {
+ host.dataset.colorScheme = scheme
+ return
+ }
+
+ host.removeAttribute("data-color-scheme")
+}
+
+export function observeViewerScheme(getHost: () => HTMLElement | undefined) {
+ if (typeof document === "undefined") return () => {}
+
+ applyViewerScheme(getHost())
+ if (typeof MutationObserver === "undefined") return () => {}
+
+ const root = document.documentElement
+ const monitor = new MutationObserver(() => applyViewerScheme(getHost()))
+ monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
+ return () => monitor.disconnect()
+}
+
+export function notifyShadowReady(opts: {
+ state: ReadyWatcher
+ container: HTMLElement
+ getRoot: () => ShadowRoot | undefined
+ isReady: (root: ShadowRoot) => boolean
+ onReady: () => void
+ settleFrames?: number
+}) {
+ clearReadyWatcher(opts.state)
+ opts.state.token += 1
+
+ const token = opts.state.token
+ const settle = Math.max(0, opts.settleFrames ?? 0)
+
+ const runReady = () => {
+ const step = (left: number) => {
+ if (token !== opts.state.token) return
+ if (left <= 0) {
+ opts.onReady()
+ return
+ }
+ requestAnimationFrame(() => step(left - 1))
+ }
+
+ requestAnimationFrame(() => step(settle))
+ }
+
+ const observeRoot = (root: ShadowRoot) => {
+ if (opts.isReady(root)) {
+ runReady()
+ return
+ }
+
+ if (typeof MutationObserver === "undefined") return
+
+ clearReadyWatcher(opts.state)
+ opts.state.observer = new MutationObserver(() => {
+ if (token !== opts.state.token) return
+ if (!opts.isReady(root)) return
+
+ clearReadyWatcher(opts.state)
+ runReady()
+ })
+ opts.state.observer.observe(root, { childList: true, subtree: true })
+ }
+
+ const root = opts.getRoot()
+ if (!root) {
+ if (typeof MutationObserver === "undefined") return
+
+ opts.state.observer = new MutationObserver(() => {
+ if (token !== opts.state.token) return
+
+ const next = opts.getRoot()
+ if (!next) return
+
+ observeRoot(next)
+ })
+ opts.state.observer.observe(opts.container, { childList: true, subtree: true })
+ return
+ }
+
+ observeRoot(root)
+}
diff --git a/packages/ui/src/pierre/file-selection.ts b/packages/ui/src/pierre/file-selection.ts
new file mode 100644
index 000000000..fdc34729e
--- /dev/null
+++ b/packages/ui/src/pierre/file-selection.ts
@@ -0,0 +1,85 @@
+import { type SelectedLineRange } from "@pierre/diffs"
+import { toRange } from "./selection-bridge"
+
+export function findElement(node: Node | null): HTMLElement | undefined {
+ if (!node) return
+ if (node instanceof HTMLElement) return node
+ return node.parentElement ?? undefined
+}
+
+export function findFileLineNumber(node: Node | null): number | undefined {
+ const el = findElement(node)
+ if (!el) return
+
+ const line = el.closest("[data-line]")
+ if (!(line instanceof HTMLElement)) return
+
+ const value = parseInt(line.dataset.line ?? "", 10)
+ if (Number.isNaN(value)) return
+ return value
+}
+
+export function findDiffLineNumber(node: Node | null): number | undefined {
+ const el = findElement(node)
+ if (!el) return
+
+ const line = el.closest("[data-line], [data-alt-line]")
+ if (!(line instanceof HTMLElement)) return
+
+ const primary = parseInt(line.dataset.line ?? "", 10)
+ if (!Number.isNaN(primary)) return primary
+
+ const alt = parseInt(line.dataset.altLine ?? "", 10)
+ if (!Number.isNaN(alt)) return alt
+}
+
+export function findCodeSelectionSide(node: Node | null): SelectedLineRange["side"] {
+ const el = findElement(node)
+ if (!el) return
+
+ const code = el.closest("[data-code]")
+ if (!(code instanceof HTMLElement)) return
+ if (code.hasAttribute("data-deletions")) return "deletions"
+ return "additions"
+}
+
+export function readShadowLineSelection(opts: {
+ root: ShadowRoot
+ lineForNode: (node: Node | null) => number | undefined
+ sideForNode?: (node: Node | null) => SelectedLineRange["side"]
+ preserveTextSelection?: boolean
+}) {
+ const selection =
+ (opts.root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
+ if (!selection || selection.isCollapsed) return
+
+ const domRange =
+ (
+ selection as unknown as {
+ getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => StaticRange[]
+ }
+ ).getComposedRanges?.({ shadowRoots: [opts.root] })?.[0] ??
+ (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
+
+ const startNode = domRange?.startContainer ?? selection.anchorNode
+ const endNode = domRange?.endContainer ?? selection.focusNode
+ if (!startNode || !endNode) return
+ if (!opts.root.contains(startNode) || !opts.root.contains(endNode)) return
+
+ const start = opts.lineForNode(startNode)
+ const end = opts.lineForNode(endNode)
+ if (start === undefined || end === undefined) return
+
+ const startSide = opts.sideForNode?.(startNode)
+ const endSide = opts.sideForNode?.(endNode)
+ const side = startSide ?? endSide
+
+ const range: SelectedLineRange = { start, end }
+ if (side) range.side = side
+ if (endSide && side && endSide !== side) range.endSide = endSide
+
+ return {
+ range,
+ text: opts.preserveTextSelection && domRange ? toRange(domRange).cloneRange() : undefined,
+ }
+}
diff --git a/packages/ui/src/pierre/index.ts b/packages/ui/src/pierre/index.ts
index f226a9ae1..22586f0f5 100644
--- a/packages/ui/src/pierre/index.ts
+++ b/packages/ui/src/pierre/index.ts
@@ -1,5 +1,6 @@
import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs"
import { ComponentProps } from "solid-js"
+import { lineCommentStyles } from "../components/line-comment-styles"
export type DiffProps<T = {}> = FileDiffOptions<T> & {
before: FileContents
@@ -7,13 +8,15 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
annotations?: DiffLineAnnotation<T>[]
selectedLines?: SelectedLineRange | null
commentedLines?: SelectedLineRange[]
+ onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
onRendered?: () => void
class?: string
classList?: ComponentProps<"div">["classList"]
}
const unsafeCSS = `
-[data-diff] {
+[data-diff],
+[data-file] {
--diffs-bg: light-dark(var(--diffs-light-bg), var(--diffs-dark-bg));
--diffs-bg-buffer: var(--diffs-bg-buffer-override, light-dark( color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer))));
--diffs-bg-hover: var(--diffs-bg-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 97%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-mixer))));
@@ -44,7 +47,8 @@ const unsafeCSS = `
--diffs-bg-selection-text: rgb(from var(--surface-warning-strong) r g b / 0.2);
}
-:host([data-color-scheme='dark']) [data-diff] {
+:host([data-color-scheme='dark']) [data-diff],
+:host([data-color-scheme='dark']) [data-file] {
--diffs-selection-number-fg: #fdfbfb;
--diffs-bg-selection: var(--diffs-bg-selection-override, rgb(from var(--solaris-dark-6) r g b / 0.65));
--diffs-bg-selection-number: var(
@@ -53,7 +57,8 @@ const unsafeCSS = `
);
}
-[data-diff] ::selection {
+[data-diff] ::selection,
+[data-file] ::selection {
background-color: var(--diffs-bg-selection-text);
}
@@ -69,25 +74,48 @@ const unsafeCSS = `
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
}
+[data-file] [data-line][data-comment-selected]:not([data-selected-line]) {
+ box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
+}
+
[data-diff] [data-column-number][data-comment-selected]:not([data-selected-line]) {
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
color: var(--diffs-selection-number-fg);
}
+[data-file] [data-column-number][data-comment-selected]:not([data-selected-line]) {
+ box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
+ color: var(--diffs-selection-number-fg);
+}
+
[data-diff] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] {
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
}
+[data-file] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] {
+ box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
+}
+
[data-diff] [data-line][data-selected-line] {
background-color: var(--diffs-bg-selection);
box-shadow: inset 2px 0 0 var(--diffs-selection-border);
}
+[data-file] [data-line][data-selected-line] {
+ background-color: var(--diffs-bg-selection);
+ box-shadow: inset 2px 0 0 var(--diffs-selection-border);
+}
+
[data-diff] [data-column-number][data-selected-line] {
background-color: var(--diffs-bg-selection-number);
color: var(--diffs-selection-number-fg);
}
+[data-file] [data-column-number][data-selected-line] {
+ background-color: var(--diffs-bg-selection-number);
+ color: var(--diffs-selection-number-fg);
+}
+
[data-diff] [data-column-number][data-line-type='context'][data-selected-line],
[data-diff] [data-column-number][data-line-type='context-expanded'][data-selected-line],
[data-diff] [data-column-number][data-line-type='change-addition'][data-selected-line],
@@ -123,9 +151,13 @@ const unsafeCSS = `
}
[data-code] {
overflow-x: auto !important;
- overflow-y: hidden !important;
+ overflow-y: clip !important;
}
-}`
+}
+
+${lineCommentStyles}
+
+`
export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) {
return {
diff --git a/packages/ui/src/pierre/media.ts b/packages/ui/src/pierre/media.ts
new file mode 100644
index 000000000..1ee63c25b
--- /dev/null
+++ b/packages/ui/src/pierre/media.ts
@@ -0,0 +1,110 @@
+import type { FileContent } from "@opencode-ai/sdk/v2"
+
+export type MediaKind = "image" | "audio" | "svg"
+
+const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"])
+const audioExtensions = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"])
+
+type MediaValue = unknown
+
+function mediaRecord(value: unknown) {
+ if (!value || typeof value !== "object") return
+ return value as Partial<FileContent> & {
+ content?: unknown
+ encoding?: unknown
+ mimeType?: unknown
+ type?: unknown
+ }
+}
+
+export function normalizeMimeType(type: string | undefined) {
+ if (!type) return
+ const mime = type.split(";", 1)[0]?.trim().toLowerCase()
+ if (!mime) return
+ if (mime === "audio/x-aac") return "audio/aac"
+ if (mime === "audio/x-m4a") return "audio/mp4"
+ return mime
+}
+
+export function fileExtension(path: string | undefined) {
+ if (!path) return ""
+ const idx = path.lastIndexOf(".")
+ if (idx === -1) return ""
+ return path.slice(idx + 1).toLowerCase()
+}
+
+export function mediaKindFromPath(path: string | undefined): MediaKind | undefined {
+ const ext = fileExtension(path)
+ if (ext === "svg") return "svg"
+ if (imageExtensions.has(ext)) return "image"
+ if (audioExtensions.has(ext)) return "audio"
+}
+
+export function isBinaryContent(value: MediaValue) {
+ return mediaRecord(value)?.type === "binary"
+}
+
+function validDataUrl(value: string, kind: MediaKind) {
+ if (kind === "svg") return value.startsWith("data:image/svg+xml") ? value : undefined
+ if (kind === "image") return value.startsWith("data:image/") ? value : undefined
+ if (value.startsWith("data:audio/x-aac;")) return value.replace("data:audio/x-aac;", "data:audio/aac;")
+ if (value.startsWith("data:audio/x-m4a;")) return value.replace("data:audio/x-m4a;", "data:audio/mp4;")
+ if (value.startsWith("data:audio/")) return value
+}
+
+export function dataUrlFromMediaValue(value: MediaValue, kind: MediaKind) {
+ if (!value) return
+
+ if (typeof value === "string") {
+ return validDataUrl(value, kind)
+ }
+
+ const record = mediaRecord(value)
+ if (!record) return
+
+ if (typeof record.content !== "string") return
+
+ const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined)
+ if (!mime) return
+
+ if (kind === "svg") {
+ if (mime !== "image/svg+xml") return
+ if (record.encoding === "base64") return `data:image/svg+xml;base64,${record.content}`
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(record.content)}`
+ }
+
+ if (kind === "image" && !mime.startsWith("image/")) return
+ if (kind === "audio" && !mime.startsWith("audio/")) return
+ if (record.encoding !== "base64") return
+
+ return `data:${mime};base64,${record.content}`
+}
+
+function decodeBase64Utf8(value: string) {
+ if (typeof atob !== "function") return
+
+ try {
+ const raw = atob(value)
+ const bytes = Uint8Array.from(raw, (x) => x.charCodeAt(0))
+ if (typeof TextDecoder === "function") return new TextDecoder().decode(bytes)
+ return raw
+ } catch {}
+}
+
+export function svgTextFromValue(value: MediaValue) {
+ const record = mediaRecord(value)
+ if (!record) return
+ if (typeof record.content !== "string") return
+
+ const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined)
+ if (mime !== "image/svg+xml") return
+ if (record.encoding === "base64") return decodeBase64Utf8(record.content)
+ return record.content
+}
+
+export function hasMediaValue(value: MediaValue) {
+ if (typeof value === "string") return value.length > 0
+ const record = mediaRecord(value)
+ if (!record) return false
+ return typeof record.content === "string" && record.content.length > 0
+}
diff --git a/packages/ui/src/pierre/selection-bridge.ts b/packages/ui/src/pierre/selection-bridge.ts
new file mode 100644
index 000000000..d493ead3d
--- /dev/null
+++ b/packages/ui/src/pierre/selection-bridge.ts
@@ -0,0 +1,129 @@
+import { type SelectedLineRange } from "@pierre/diffs"
+
+type PointerMode = "none" | "text" | "numbers"
+type Side = SelectedLineRange["side"]
+type LineSpan = Pick<SelectedLineRange, "start" | "end">
+
+export function formatSelectedLineLabel(range: LineSpan) {
+ const start = Math.min(range.start, range.end)
+ const end = Math.max(range.start, range.end)
+ if (start === end) return `line ${start}`
+ return `lines ${start}-${end}`
+}
+
+export function previewSelectedLines(source: string, range: LineSpan) {
+ const start = Math.max(1, Math.min(range.start, range.end))
+ const end = Math.max(range.start, range.end)
+ const lines = source.split("\n").slice(start - 1, end)
+ if (lines.length === 0) return
+ return lines.slice(0, 2).join("\n")
+}
+
+export function cloneSelectedLineRange(range: SelectedLineRange): SelectedLineRange {
+ const next: SelectedLineRange = {
+ start: range.start,
+ end: range.end,
+ }
+
+ if (range.side) next.side = range.side
+ if (range.endSide) next.endSide = range.endSide
+ return next
+}
+
+export function lineInSelectedRange(range: SelectedLineRange | null | undefined, line: number, side?: Side) {
+ if (!range) return false
+
+ const start = Math.min(range.start, range.end)
+ const end = Math.max(range.start, range.end)
+ if (line < start || line > end) return false
+ if (!side) return true
+
+ const first = range.side
+ const last = range.endSide ?? first
+ if (!first && !last) return true
+ if (!first || !last) return (first ?? last) === side
+ if (first === last) return first === side
+ if (line === start) return first === side
+ if (line === end) return last === side
+ return true
+}
+
+export function isSingleLineSelection(range: SelectedLineRange | null) {
+ if (!range) return false
+ return range.start === range.end && (range.endSide == null || range.endSide === range.side)
+}
+
+export function toRange(source: Range | StaticRange): Range {
+ if (source instanceof Range) return source
+ const range = new Range()
+ range.setStart(source.startContainer, source.startOffset)
+ range.setEnd(source.endContainer, source.endOffset)
+ return range
+}
+
+export function restoreShadowTextSelection(root: ShadowRoot | undefined, range: Range | undefined) {
+ if (!root || !range) return
+
+ requestAnimationFrame(() => {
+ const selection =
+ (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
+ if (!selection) return
+
+ try {
+ selection.removeAllRanges()
+ selection.addRange(range)
+ } catch {}
+ })
+}
+
+export function createLineNumberSelectionBridge() {
+ let mode: PointerMode = "none"
+ let line: number | undefined
+ let moved = false
+ let pending = false
+
+ const clear = () => {
+ mode = "none"
+ line = undefined
+ moved = false
+ }
+
+ return {
+ begin(numberColumn: boolean, next: number | undefined) {
+ if (!numberColumn) {
+ mode = "text"
+ return
+ }
+
+ mode = "numbers"
+ line = next
+ moved = false
+ },
+ track(buttons: number, next: number | undefined) {
+ if (mode !== "numbers") return false
+
+ if ((buttons & 1) === 0) {
+ clear()
+ return true
+ }
+
+ if (next !== undefined && line !== undefined && next !== line) moved = true
+ return true
+ },
+ finish() {
+ const current = mode
+ pending = current === "numbers" && moved
+ clear()
+ return current
+ },
+ consume(range: SelectedLineRange | null) {
+ const result = pending && !isSingleLineSelection(range)
+ pending = false
+ return result
+ },
+ reset() {
+ pending = false
+ clear()
+ },
+ }
+}
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index c0af0ac9b..f822371f7 100644
--- a/packages/ui/src/styles/index.css
+++ b/packages/ui/src/styles/index.css
@@ -13,9 +13,8 @@
@import "../components/button.css" layer(components);
@import "../components/card.css" layer(components);
@import "../components/checkbox.css" layer(components);
-@import "../components/code.css" layer(components);
+@import "../components/file.css" layer(components);
@import "../components/collapsible.css" layer(components);
-@import "../components/diff.css" layer(components);
@import "../components/diff-changes.css" layer(components);
@import "../components/context-menu.css" layer(components);
@import "../components/dropdown-menu.css" layer(components);
@@ -28,7 +27,6 @@
@import "../components/icon-button.css" layer(components);
@import "../components/image-preview.css" layer(components);
@import "../components/keybind.css" layer(components);
-@import "../components/line-comment.css" layer(components);
@import "../components/text-field.css" layer(components);
@import "../components/inline-input.css" layer(components);
@import "../components/list.css" layer(components);