{
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) {
.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>((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, setStore: SetStoreFunction) {
const [state, setState] = createStore({
focus: null as CommentFocus | null,
@@ -70,6 +101,7 @@ function createCommentSessionState(store: Store, setStore: SetStor
id: uuid(),
time: Date.now(),
...input,
+ selection: cloneSelection(input.selection),
}
batch(() => {
@@ -87,6 +119,23 @@ function createCommentSessionState(store: Store, 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, 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) => 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>
+
+ 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 & { 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 & { 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 (
+ event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
+
+
+
+
+
+ {props.editLabel}
+
+
+ {props.deleteLabel}
+
+
+
+
+
+ )
}
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(() => {
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,
- 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 syncSelected = (range: SelectedLineRange | null) => {
+ const p = path()
+ if (!p) return
+ file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null)
}
- const getRoot = () => {
- const el = wrap
- if (!el) return
+ const activeSelection = () => note.selected ?? selectedLines()
- const host = el.querySelector("diffs-container")
- if (!(host instanceof HTMLElement)) return
+ 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) => (
+
+ ),
+ onDraftPopoverFocusOut: (e: FocusEvent) => {
+ const current = e.currentTarget as HTMLDivElement
+ const target = e.relatedTarget
+ if (target instanceof Node && current.contains(target)) 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 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 next: Record = {}
- 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)
- }
-
- 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 range = note.commenting
- if (!range) {
- setNote("draftTop", undefined)
- return
- }
-
- const marker = findMarker(root, range)
- if (marker) {
- setNote("draftTop", markerTop(el, marker))
- return
- }
-
- setNote("draftTop", large ? estimateTop(range) : undefined)
- }
-
- const scheduleComments = () => {
- requestAnimationFrame(updateComments)
- }
+ setTimeout(() => {
+ if (!document.activeElement || !current.contains(document.activeElement)) {
+ setNote("commenting", null)
+ }
+ }, 0)
+ },
+ })
createEffect(() => {
- commentLayout()
- scheduleComments()
+ if (typeof window === "undefined") return
+
+ 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
+
+ event.preventDefault()
+ event.stopPropagation()
+ find?.focus()
+ }
+
+ 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) => (
- {
- wrap = el
- scheduleComments()
- }}
- class={`relative overflow-hidden ${wrapperClass}`}
- >
+ const renderFile = (source: string) => (
+
{
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"),
+ })
+ },
+ }}
/>
-
- {(comment) => (
- {
- 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)
- }}
- />
- )}
-
-
- {(range) => (
-
- 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)
- }}
- />
-
- )}
-
)
@@ -526,36 +464,7 @@ export function FileTabContent(props: { tab: string }) {
onScroll={handleScroll as any}
>
-
-
-
requestAnimationFrame(restoreScroll)}
- />
-
-
-
-
- {renderCode(svgContent() ?? "", "")}
-
-
-
-
-
-
-
-
-
-
-
- {path()?.split("/").pop()}
- {language.t("session.files.binaryContent")}
-
-
-
- {renderCode(contents(), "pb-40")}
+ {renderFile(contents())}
{language.t("common.loading")}...
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: {
- {(message) => (
- {
- 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,
- }}
- >
- {
+ const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? []))
+ return (
+ {
+ 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,
+ }}
+ >
+ 0}>
+
+
+
+
+
+
+ )
+ }}
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
- if (el.scrollTop !== s.y) el.scrollTop = s.y
- if (el.scrollLeft !== s.x) el.scrollLeft = s.x
+ const maxY = Math.max(0, el.scrollHeight - el.clientHeight)
+ const maxX = Math.max(0, el.scrollWidth - el.clientWidth)
+
+ 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 queueRestore = () => {
+ if (userInteracted || restoreFrame !== undefined) return
+ restoreFrame = requestAnimationFrame(doRestore)
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
- pending = {
- x: event.currentTarget.scrollLeft,
- y: event.currentTarget.scrollTop,
- }
- if (frame !== undefined) return
+ if (!layout.ready() || !userInteracted) return
- frame = requestAnimationFrame(() => {
- frame = undefined
+ const el = event.currentTarget
+ if (el.clientHeight === 0 || el.clientWidth === 0) return
- const next = pending
- pending = undefined
- if (!next) 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 () {
-
-
-
- {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)
- }
+
+
+ {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 = () => (
-
-
-
-
- v{info().version}
-
-
-
-
- {model()?.name ?? modelID()}
-
-
- {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
-
-
+ const title = () => (
+
+
+
+
+ v{info().version}
- {info().title}
-
- )
-
- const turns = () => (
-
+ )
- const wide = createMemo(() => diffs().length === 0)
+ const turns = () => (
+
+ )
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
+ const wide = createMemo(() => diffs().length === 0)
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+ 1}>
+
+
+
- {title()}
-
-
- 1}>
-
-
-
-
-
-
-
-
+
+
+
+
- 0}>
-
-
+
+ 0}>
+
+
+
+
+
+
+
+ 0}>
+
+
+
+ Session
+
+
+ {diffs().length} Files Changed
+
+
+
+ {turns()}
+
+
-
- 0}>
-
-
-
- Session
-
-
- {diffs().length} Files Changed
-
-
-
- {turns()}
-
-
-
-
-
-
- {turns()}
-
-
-
-
+
+
+
+
+
+ {turns()}
+
+
+
- )
- })}
-
-
-
+
+ )
+ })}
+
+
>
)
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
-
-type SelectionSide = "additions" | "deletions"
-
-export type CodeProps = FileOptions & {
- file: FileContents
- annotations?: LineAnnotation[]
- 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()
-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(props: CodeProps) {
- 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 | VirtualizedFile | 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("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(opts, v, codeMetrics, workerPool) : new File(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"] }) => (
-
- )
-
- return (
- {
- findTarget = host
- wrapper.focus({ preventScroll: true })
- }}
- onFocus={() => {
- findTarget = host
- }}
- >
-
-
-
-
-
-
-
-
- )
-}
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 = DiffProps & {
- preloadedDiff: PreloadMultiFileDiffResult
-}
-
-export function Diff(props: SSRDiffProps) {
- 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 | undefined
- let sharedVirtualizer: NonNullable> | 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(
- {
- ...createDefaultOptions(props.diffStyle),
- ...others,
- ...props.preloadedDiff,
- },
- virtualizer,
- virtualMetrics,
- workerPool,
- )
- : new FileDiff(
- {
- ...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 (
-
-
-
-
-
-
-
- )
-}
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(props: DiffProps) {
- let container!: HTMLDivElement
- let observer: MutationObserver | undefined
- let sharedVirtualizer: NonNullable> | 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, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
-
- const options = createMemo>(() => {
- const base = {
- ...createDefaultOptions(props.diffStyle),
- ...others,
- }
-
- const perf = large() ? { ...base, ...largeOptions } : base
- if (!mobile()) return perf
-
- return {
- ...perf,
- disableLineNumbers: true,
- }
- })
-
- let instance: FileDiff | undefined
- const [current, setCurrent] = createSignal | 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(opts, virtualizer, virtualMetrics, workerPool)
- : new FileDiff(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
-}
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
+ 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 (
+
+
+ {
+ const media = cfg()
+ const k = kind()
+ if (!media || (k !== "image" && k !== "audio")) return props.fallback()
+ const label = kindLabel(k)
+
+ if (deleted()) {
+ return (
+
+ {i18n.t("ui.fileMedia.state.removed", { kind: label })}
+
+ )
+ }
+ if (status() === "loading") {
+ return (
+
+ {i18n.t("ui.fileMedia.state.loading", { kind: label })}
+
+ )
+ }
+ if (status() === "error") {
+ return (
+
+ {i18n.t("ui.fileMedia.state.error", { kind: label })}
+
+ )
+ }
+ return (
+
+ {i18n.t("ui.fileMedia.state.unavailable", { kind: label })}
+
+ )
+ })()}
+ >
+ {(value) => {
+ const k = kind()
+ if (k !== "image" && k !== "audio") return props.fallback()
+ if (k === "image") {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+ }}
+
+
+
+ {(() => {
+ if (svgSource() === undefined && svgSrc() == null) return props.fallback()
+
+ return (
+
+ {props.fallback()}
+
+ {(value) => (
+
+
+
+ )}
+
+
+ )
+ })()}
+
+
+
+
+ {cfg()?.path?.split("/").pop() ?? i18n.t("ui.fileMedia.binary.title")}
+
+
+ {(() => {
+ const path = cfg()?.path
+ if (!path) return i18n.t("ui.fileMedia.binary.description.default")
+ return i18n.t("ui.fileMedia.binary.description.path", { path })
+ })()}
+
+
+
+ {props.fallback()}
+
+ )
+}
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 (
+
+ e.stopPropagation()}
+ >
+
+ props.onInput(e.currentTarget.value)}
+ onKeyDown={(e) => props.onKeyDown(e as KeyboardEvent)}
+ />
+
+ {props.count() ? `${props.index() + 1}/${props.count()}` : "0/0"}
+
+
+
+
+
+
+
+
+ )
+}
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 = DiffFileProps & {
+ preloadedDiff: PreloadMultiFileDiffResult
+}
+
+function DiffSSRViewer(props: SSRDiffFileProps) {
+ let container!: HTMLDivElement
+ let fileDiffRef!: HTMLElement
+ let fileDiffInstance: FileDiff | undefined
+ let sharedVirtualizer: NonNullable> | 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["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(
+ {
+ ...createDefaultOptions(props.diffStyle),
+ ...others,
+ ...local.preloadedDiff,
+ },
+ virtualizer,
+ virtualMetrics,
+ workerPool,
+ )
+ : new FileDiff(
+ {
+ ...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 (
+
+
+
+
+
+
+
+ )
+}
+
+export type FileSSRProps = FileProps
+
+export function FileSSR(props: FileSSRProps) {
+ if (props.mode !== "diff" || !props.preloadedDiff) return File(props)
+ return DiffSSRViewer(props as SSRDiffFileProps)
+}
diff --git a/packages/ui/src/components/diff.css b/packages/ui/src/components/file.css
similarity index 86%
rename from packages/ui/src/components/diff.css
rename to 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
+
+type SharedProps = {
+ annotations?: LineAnnotation[] | DiffLineAnnotation[]
+ 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 = FileOptions &
+ SharedProps & {
+ mode: "text"
+ file: FileContents
+ annotations?: LineAnnotation[]
+ preloadedDiff?: PreloadMultiFileDiffResult
+ }
+
+export type DiffFileProps = FileDiffOptions &
+ SharedProps & {
+ mode: "diff"
+ before: FileContents
+ after: FileContents
+ annotations?: DiffLineAnnotation[]
+ preloadedDiff?: PreloadMultiFileDiffResult
+ }
+
+export type FileProps = TextFileProps | DiffFileProps
+
+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
+
+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 = {
+ 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
+ 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(opts: {
+ viewer: Viewer
+ current: () => AnnotationTarget | 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(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> | 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
+ search: FileSearchControl | undefined
+ class: string | undefined
+ classList: ComponentProps<"div">["classList"] | undefined
+}) {
+ return (
+ (props.viewer.wrapper = el)}
+ tabIndex={0}
+ onPointerDown={props.viewer.find.onPointerDown}
+ onFocus={props.viewer.find.onFocus}
+ >
+
+ props.viewer.find.next(-1)}
+ onNext={() => props.viewer.find.next(1)}
+ />
+
+ (props.viewer.container = el)} />
+ (props.viewer.overlay = el)} class="pointer-events-none absolute inset-0 z-0" />
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// TextViewer
+// ---------------------------------------------------------------------------
+
+function TextViewer(props: TextFileProps) {
+ let instance: PierreFile | VirtualizedFile | 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("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(opts, virtualizer, codeMetrics, workerPool)
+ : new PierreFile(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>({
+ viewer,
+ current: () => instance,
+ annotations: () => (local.annotations as LineAnnotation[] | undefined) ?? [],
+ })
+
+ // -- cleanup --
+
+ onCleanup(() => {
+ instance?.cleanUp()
+ instance = undefined
+ virtuals.cleanup()
+ })
+
+ return (
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// DiffViewer
+// ---------------------------------------------------------------------------
+
+function DiffViewer(props: DiffFileProps) {
+ let instance: FileDiff | 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, "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>(() => {
+ 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 | VirtualizedFileDiff) & {
+ 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(opts, virtualizer, virtualMetrics, workerPool)
+ : new FileDiff(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>({
+ viewer,
+ current: () => instance,
+ annotations: () => (local.annotations as DiffLineAnnotation[] | undefined) ?? [],
+ })
+
+ // -- cleanup --
+
+ onCleanup(() => {
+ instance?.cleanUp()
+ instance = undefined
+ virtuals.cleanup()
+ dragSide = undefined
+ dragEndSide = undefined
+ })
+
+ return (
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+export function File(props: FileProps) {
+ if (props.mode === "text") {
+ return TextViewer(props)} />
+ }
+
+ return 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 =
+ | { kind: "comment"; key: string; comment: T }
+ | { kind: "draft"; key: string; range: SelectedLineRange }
+
+export type LineCommentAnnotation = {
+ lineNumber: number
+ side?: "additions" | "deletions"
+ metadata: LineCommentAnnotationMeta
+}
+
+type LineCommentAnnotationsProps = {
+ comments: Accessor
+ getCommentId: (comment: T) => string
+ getCommentSelection: (comment: T) => SelectedLineRange
+ draftRange: Accessor
+ draftKey: Accessor
+}
+
+type LineCommentAnnotationsWithSideProps = LineCommentAnnotationsProps & {
+ getSide: (range: SelectedLineRange) => "additions" | "deletions"
+}
+
+type HoverCommentLine = {
+ lineNumber: number
+ side?: "additions" | "deletions"
+}
+
+type LineCommentStateProps = {
+ opened: Accessor
+ setOpened: (id: T | null) => void
+ selected: Accessor
+ setSelected: (range: SelectedLineRange | null) => void
+ commenting: Accessor
+ setCommenting: (range: SelectedLineRange | null) => void
+ syncSelected?: (range: SelectedLineRange | null) => void
+ hoverSelected?: (range: SelectedLineRange) => void
+}
+
+type LineCommentShape = {
+ id: string
+ selection: SelectedLineRange
+ comment: string
+}
+
+type LineCommentControllerProps = {
+ comments: Accessor
+ draftKey: Accessor
+ label: string
+ state: LineCommentStateProps
+ 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
+ getHoverSelectedRange?: Accessor
+ cancelDraftOnCommentToggle?: boolean
+ clearSelectionOnSelectionEndNull?: boolean
+}
+
+type LineCommentControllerWithSideProps = LineCommentControllerProps & {
+ 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
+ onMouseEnter?: JSX.EventHandlerUnion
+}
+
+type DraftProps = {
+ value: string
+ selection: JSX.Element
+ onInput: (value: string) => void
+ onCancel: VoidFunction
+ onSubmit: (value: string) => void
+ onPopoverFocusOut?: JSX.EventHandlerUnion
+ cancelLabel?: string
+ submitLabel?: string
+}
+
+export function createLineCommentAnnotationRenderer(props: {
+ renderComment: (comment: T) => CommentProps
+ renderDraft: (range: SelectedLineRange) => DraftProps
+}) {
+ const nodes = new Map<
+ string,
+ {
+ host: HTMLDivElement
+ dispose: VoidFunction
+ setMeta: (meta: LineCommentAnnotationMeta) => void
+ }
+ >()
+
+ const mount = (meta: LineCommentAnnotationMeta) => {
+ 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 (
+
+ }
+ >
+
+
+ )
+ }
+
+ const view = createMemo(() => {
+ const next = current()
+ if (next.kind !== "draft") return props.renderDraft(active.range)
+ return props.renderDraft(next.range)
+ })
+ return (
+
+ )
+ }, host)
+
+ const node = { host, dispose, setMeta: setCurrent }
+ nodes.set(meta.key, node)
+ return node
+ }
+
+ const render = }>(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 = }>(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(props: LineCommentStateProps) {
+ const [draft, setDraft] = createSignal("")
+ const [editing, setEditing] = createSignal(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(
+ props: LineCommentControllerWithSideProps,
+): {
+ note: ReturnType>
+ annotations: Accessor>[]>
+ renderAnnotation: ReturnType>["renderAnnotation"]
+ renderHoverUtility: ReturnType
+ onLineSelected: (range: SelectedLineRange | null) => void
+ onLineSelectionEnd: (range: SelectedLineRange | null) => void
+ onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
+}
+export function createLineCommentController(
+ props: LineCommentControllerProps,
+): {
+ note: ReturnType>
+ annotations: Accessor[]>
+ renderAnnotation: ReturnType>["renderAnnotation"]
+ renderHoverUtility: ReturnType
+ onLineSelected: (range: SelectedLineRange | null) => void
+ onLineSelectionEnd: (range: SelectedLineRange | null) => void
+ onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
+}
+export function createLineCommentController(
+ props: LineCommentControllerProps | LineCommentControllerWithSideProps,
+) {
+ const note = createLineCommentState(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({
+ 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(
+ props: LineCommentAnnotationsWithSideProps,
+): Accessor>[]>
+export function createLineCommentAnnotations(
+ props: LineCommentAnnotationsProps,
+): Accessor[]>
+export function createLineCommentAnnotations(
+ props: LineCommentAnnotationsProps | LineCommentAnnotationsWithSideProps,
+) {
+ const line = (range: SelectedLineRange) => Math.max(range.start, range.end)
+
+ if ("getSide" in props) {
+ return createMemo>[]>(() => {
+ 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,
+ }
+ })
+
+ 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,
+ },
+ ]
+ })
+ }
+
+ return createMemo[]>(() => {
+ const list = props.comments().map((comment) => {
+ const range = props.getCommentSelection(comment)
+ const entry: LineCommentAnnotation = {
+ 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 = {
+ lineNumber: line(range),
+ metadata: {
+ kind: "draft",
+ key: `draft:${props.draftKey()}`,
+ range,
+ },
+ }
+
+ return [...list, draft]
+ })
+}
+
+export function createManagedLineCommentAnnotationRenderer(props: {
+ annotations: Accessor[]>
+ renderComment: (comment: T) => CommentProps
+ renderDraft: (range: SelectedLineRange) => DraftProps
+}) {
+ const renderer = createLineCommentAnnotationRenderer({
+ 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
+ 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
similarity index 52%
rename from packages/ui/src/components/line-comment.css
rename to 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 (
+
+ )
+}
export type LineCommentAnchorProps = {
id?: string
top?: number
+ inline?: boolean
+ hideButton?: boolean
open: boolean
variant?: LineCommentVariant
+ icon?: "comment" | "plus"
+ buttonLabel?: string
onClick?: JSX.EventHandlerUnion
onMouseEnter?: JSX.EventHandlerUnion
onPopoverFocusOut?: JSX.EventHandlerUnion
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 (
-
-
+
+
+
+
+ e.stopPropagation()}
+ on:focusout={props.onPopoverFocusOut as any}
+ >
+ {props.children}
+
+
+ >
+ }
+ >
e.stopPropagation()}
+ on:click={props.onClick as any}
+ on:mouseenter={props.onMouseEnter as any}
+ on:focusout={props.onPopoverFocusOut as any}
>
{props.children}
@@ -58,16 +127,22 @@ export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
export type LineCommentProps = Omit & {
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 (
-
+
- {split.comment}
+
+ {split.comment}
+
+ {split.actions}
+
+
{i18n.t("ui.lineComment.label.prefix")}
{split.selection}
@@ -78,6 +153,25 @@ export const LineComment = (props: LineCommentProps) => {
)
}
+export type LineCommentAddProps = Omit & {
+ label?: string
+}
+
+export const LineCommentAdd = (props: LineCommentAddProps) => {
+ const [split, rest] = splitProps(props, ["label"])
+ const i18n = useI18n()
+
+ return (
+
+ )
+}
+
export type LineCommentEditorProps = Omit & {
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 (
- focus()}>
+ focus()}>
-
-
+
+
+
+ >
+ }
+ >
+
+
+
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({
>
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({
(props.metadata.files ?? []) as ApplyPatchFile[])
const pending = createMemo(() => props.status === "pending" || props.status === "running")
const single = createMemo(() => {
@@ -1703,7 +1704,8 @@ ToolRegistry.register({
@@ -1780,7 +1782,8 @@ ToolRegistry.register({
>
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
}
-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 (
+ event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
+
+
+
+
+
+ {props.labels.editLabel}
+
+
+ {props.labels.deleteLabel}
+
+
+
+
+
+ )
}
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()
- const [store, setStore] = createStore({
+ const searchHandles = new Map()
+ const readyFiles = new Set()
+ const [store, setStore] = createStore<{ open: string[]; force: Record }>({
open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
+ force: {},
})
const [selection, setSelection] = createSignal(null)
const [commenting, setCommenting] = createSignal(null)
const [opened, setOpened] = createSignal(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((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((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 (
{
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}
+
+ (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)}
+ />
+
@@ -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(diffImageSrc())
- const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
-
- const diffAudioSrc = createMemo(() => dataUrlFromValue(item().after) ?? dataUrlFromValue(item().before))
- const [audioSrc, setAudioSrc] = createSignal(diffAudioSrc())
- const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
- const [audioMime, setAudioMime] = createSignal(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>({})
- const [draftTop, setDraftTop] = createSignal(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 = {}
- 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({
+ 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),
})
- .catch(() => {
- setImageStatus("error")
+ },
+ onUpdate: ({ id, comment, selection }) => {
+ props.onLineCommentUpdate?.({
+ id,
+ file,
+ selection,
+ comment,
+ preview: selectionPreview(item(), selection),
})
+ },
+ onDelete: (comment) => {
+ props.onLineCommentDelete?.({
+ id: comment.id,
+ file,
+ })
+ },
+ editSubmitLabel: props.lineCommentActions?.saveLabel,
+ renderCommentActions: props.lineCommentActions
+ ? (comment, controls) => (
+
+ )
+ : 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")}
-
+
{i18n.t("ui.sessionReview.change.modified")}
@@ -607,33 +791,11 @@ export const SessionReview = (props: SessionReviewProps) => {
ref={(el) => {
wrapper = el
anchors.set(file, el)
- scheduleAnchors()
}}
>
-
-
-
-
-
-
-
-
- {i18n.t("ui.sessionReview.change.removed")}
-
-
-
-
-
-
- {imageStatus() === "loading"
- ? i18n.t("ui.sessionReview.image.loading")
- : i18n.t("ui.sessionReview.image.placeholder")}
-
-
-
-
+
{i18n.t("ui.sessionReview.largeDiff.title")}
@@ -645,26 +807,52 @@ export const SessionReview = (props: SessionReviewProps) => {
})}
-
-
+
{
+ 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,
+ }}
/>
-
-
- {(comment) => (
- 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)}
- />
- )}
-
-
-
- {(range) => (
-
- setCommenting(null)}
- onSubmit={(comment) => {
- props.onLineComment?.({
- file,
- selection: range(),
- comment,
- preview: selectionPreview(item(), range()),
- })
- setCommenting(null)
- }}
- />
-
- )}
-
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(
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({
- 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
similarity index 65%
rename from packages/ui/src/context/code.tsx
rename to 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({
- 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 = {
"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()
+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 = FileDiffOptions & {
before: FileContents
@@ -7,13 +8,15 @@ export type DiffProps = FileDiffOptions & {
annotations?: DiffLineAnnotation[]
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(style: FileDiffOptions["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 & {
+ 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
+
+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);
diff --git a/specs/file-component-unification-plan.md b/specs/file-component-unification-plan.md
new file mode 100644
index 000000000..d63d665fd
--- /dev/null
+++ b/specs/file-component-unification-plan.md
@@ -0,0 +1,426 @@
+# File Component Unification Plan
+
+Single path for text, diff, and media
+
+---
+
+## Define goal
+
+Introduce one public UI component API that renders plain text files or diffs from the same entry point, so selection, comments, search, theming, and media behavior are maintained once.
+
+### Goal
+
+- Add a unified `File` component in `packages/ui/src/components/file.tsx` that chooses plain or diff rendering from props.
+- Centralize shared behavior now split between `packages/ui/src/components/code.tsx` and `packages/ui/src/components/diff.tsx`.
+- Bring the existing find/search UX to diff rendering through a shared engine.
+- Consolidate media rendering logic currently split across `packages/ui/src/components/session-review.tsx` and `packages/app/src/pages/session/file-tabs.tsx`.
+- Provide a clear SSR path for preloaded diffs without keeping a third independent implementation.
+
+### Non-goal
+
+- Do not change `@pierre/diffs` behavior or fork its internals.
+- Do not redesign line comment UX, diff visuals, or keyboard shortcuts.
+- Do not remove legacy `Code`/`Diff` APIs in the first pass.
+- Do not add new media types beyond parity unless explicitly approved.
+- Do not refactor unrelated session review or file tab layout code outside integration points.
+
+---
+
+## Audit duplication
+
+The current split duplicates runtime logic and makes feature parity drift likely.
+
+### Duplicate categories
+
+- Rendering lifecycle is duplicated in `code.tsx` and `diff.tsx`, including instance creation, cleanup, `onRendered` readiness, and shadow root lookup.
+- Theme sync is duplicated in `code.tsx`, `diff.tsx`, and `diff-ssr.tsx` through similar `applyScheme` and `MutationObserver` code.
+- Line selection wiring is duplicated in `code.tsx` and `diff.tsx`, including drag state, shadow selection reads, and line-number bridge integration.
+- Comment annotation rerender flow is duplicated in `code.tsx`, `diff.tsx`, and `diff-ssr.tsx`.
+- Commented line marking is split across `markCommentedFileLines` and `markCommentedDiffLines`, with similar timing and effect wiring.
+- Diff selection normalization (`fixSelection`) exists twice in `diff.tsx` and `diff-ssr.tsx`.
+- Search exists only in `code.tsx`, so diff lacks find and the feature cannot be maintained in one place.
+- Contexts are split (`context/code.tsx`, `context/diff.tsx`), which forces consumers to choose paths early.
+- Media rendering is duplicated outside the core viewers in `session-review.tsx` and `file-tabs.tsx`.
+
+### Drift pain points
+
+- Any change to comments, theming, or selection requires touching multiple files.
+- Diff SSR and client diff can drift because they carry separate normalization and marking code.
+- Search cannot be added to diff cleanly without more duplication unless the viewer runtime is unified.
+
+---
+
+## Design architecture
+
+Use one public component with a discriminated prop shape and split shared behavior into small runtime modules.
+
+### Public API proposal
+
+- Add `packages/ui/src/components/file.tsx` as the primary client entry point.
+- Export a single `File` component that accepts a discriminated union with two primary modes.
+- Use an explicit `mode` prop (`"text"` or `"diff"`) to avoid ambiguous prop inference and keep type errors clear.
+
+### Proposed prop shape
+
+- Shared props:
+ - `annotations`
+ - `selectedLines`
+ - `commentedLines`
+ - `onLineSelected`
+ - `onLineSelectionEnd`
+ - `onLineNumberSelectionEnd`
+ - `onRendered`
+ - `class`
+ - `classList`
+ - selection and hover flags already supported by current viewers
+- Text mode props:
+ - `mode: "text"`
+ - `file` (`FileContents`)
+ - text renderer options from `@pierre/diffs` `FileOptions`
+- Diff mode props:
+ - `mode: "diff"`
+ - `before`
+ - `after`
+ - `diffStyle`
+ - diff renderer options from `FileDiffOptions`
+ - optional `preloadedDiff` only for SSR-aware entry or hydration adapter
+- Media props (shared, optional):
+ - `media` config for `"auto" | "off"` behavior
+ - path/name metadata
+ - optional lazy loader (`readFile`) for session review use
+ - optional custom placeholders for binary or removed content
+
+### Internal module split
+
+- `packages/ui/src/components/file.tsx`
+ Public unified component and mode routing.
+- `packages/ui/src/components/file-ssr.tsx`
+ Unified SSR entry for preloaded diff hydration.
+- `packages/ui/src/components/file-search.tsx`
+ Shared find bar UI and host registration.
+- `packages/ui/src/components/file-media.tsx`
+ Shared image/audio/svg/binary rendering shell.
+- `packages/ui/src/pierre/file-runtime.ts`
+ Common render lifecycle, instance setup, cleanup, scheme sync, and readiness notification.
+- `packages/ui/src/pierre/file-selection.ts`
+ Shared selection/drag/line-number bridge controller with mode adapters.
+- `packages/ui/src/pierre/diff-selection.ts`
+ Diff-specific `fixSelection` and row/side normalization reused by client and SSR.
+- `packages/ui/src/pierre/file-find.ts`
+ Shared find engine (scan, highlight API, overlay fallback, match navigation).
+- `packages/ui/src/pierre/media.ts`
+ MIME normalization, data URL helpers, and media type detection.
+
+### Wrapper strategy
+
+- Keep `packages/ui/src/components/code.tsx` as a thin compatibility wrapper over unified `File` in text mode.
+- Keep `packages/ui/src/components/diff.tsx` as a thin compatibility wrapper over unified `File` in diff mode.
+- Keep `packages/ui/src/components/diff-ssr.tsx` as a thin compatibility wrapper over unified SSR entry.
+
+---
+
+## Phase delivery
+
+Ship this in small phases so each step is reviewable and reversible.
+
+### Phase 0: Align interfaces
+
+- Document the final prop contract and adapter behavior before moving logic.
+- Add a short migration note in the plan PR description so reviewers know wrappers stay in place.
+
+#### Acceptance
+
+- Final prop names and mode shape are agreed up front.
+- No runtime code changes land yet.
+
+### Phase 1: Extract shared runtime pieces
+
+- Move duplicated theme sync and render readiness logic from `code.tsx` and `diff.tsx` into shared runtime helpers.
+- Move diff selection normalization (`fixSelection` and helpers) out of both `diff.tsx` and `diff-ssr.tsx` into `packages/ui/src/pierre/diff-selection.ts`.
+- Extract shared selection controller flow into `packages/ui/src/pierre/file-selection.ts` with mode callbacks for line parsing and normalization.
+- Keep `code.tsx`, `diff.tsx`, and `diff-ssr.tsx` behavior unchanged from the outside.
+
+#### Acceptance
+
+- `code.tsx`, `diff.tsx`, and `diff-ssr.tsx` are smaller and call shared helpers.
+- Line selection, comments, and theme sync still work in current consumers.
+- No consumer imports change yet.
+
+### Phase 2: Introduce unified client entry
+
+- Create `packages/ui/src/components/file.tsx` and wire it to shared runtime pieces.
+- Route text mode to `@pierre/diffs` `File` or `VirtualizedFile` and diff mode to `FileDiff` or `VirtualizedFileDiff`.
+- Preserve current performance rules, including virtualization thresholds and large-diff options.
+- Keep search out of this phase if it risks scope creep, but leave extension points in place.
+
+#### Acceptance
+
+- New unified component renders text and diff with parity to existing components.
+- `code.tsx` and `diff.tsx` can be rewritten as thin adapters without behavior changes.
+- Existing consumers still work through old `Code` and `Diff` exports.
+
+### Phase 3: Add unified context path
+
+- Add `packages/ui/src/context/file.tsx` with `FileComponentProvider` and `useFileComponent`.
+- Update `packages/ui/src/context/index.ts` to export the new context.
+- Keep `context/code.tsx` and `context/diff.tsx` as compatibility shims that adapt to `useFileComponent`.
+- Migrate `packages/app/src/app.tsx` and `packages/enterprise/src/routes/share/[shareID].tsx` to provide the unified component once wrappers are stable.
+
+#### Acceptance
+
+- New consumers can use one context path.
+- Existing `useCodeComponent` and `useDiffComponent` hooks still resolve and render correctly.
+- Provider wiring in app and enterprise stays compatible during transition.
+
+### Phase 4: Share find and enable diff search
+
+- Extract the find engine and find bar UI from `code.tsx` into shared modules.
+- Hook the shared find host into unified `File` for both text and diff modes.
+- Keep current shortcuts (`Ctrl/Cmd+F`, `Ctrl/Cmd+G`, `Shift+Ctrl/Cmd+G`) and active-host behavior.
+- Preserve CSS Highlight API support with overlay fallback.
+
+#### Acceptance
+
+- Text mode search behaves the same as today.
+- Diff mode now supports the same find UI and shortcuts.
+- Multiple viewer instances still route shortcuts to the focused/active host correctly.
+
+### Phase 5: Consolidate media rendering
+
+- Extract media type detection and data URL helpers from `session-review.tsx` and `file-tabs.tsx` into shared UI helpers.
+- Add `file-media.tsx` and let unified `File` optionally render media or binary placeholders before falling back to text/diff.
+- Migrate `session-review.tsx` and `file-tabs.tsx` to pass media props instead of owning media-specific branches.
+- Keep session-specific layout and i18n strings in the consumer where they are not generic.
+
+#### Acceptance
+
+- Image/audio/svg/binary handling no longer duplicates core detection and load state logic.
+- Session review and file tabs still render the same media states and placeholders.
+- Text/diff comment and selection behavior is unchanged when media is not shown.
+
+### Phase 6: Align SSR and preloaded diffs
+
+- Create `packages/ui/src/components/file-ssr.tsx` with the same unified prop shape plus `preloadedDiff`.
+- Reuse shared diff normalization, theme sync, and commented-line marking helpers.
+- Convert `packages/ui/src/components/diff-ssr.tsx` into a thin adapter that forwards to the unified SSR entry in diff mode.
+- Migrate enterprise share page imports to `@opencode-ai/ui/file-ssr` when convenient, but keep `diff-ssr` export working.
+
+#### Acceptance
+
+- Preloaded diff hydration still works in `packages/enterprise/src/routes/share/[shareID].tsx`.
+- SSR diff and client diff now share normalization and comment marking helpers.
+- No duplicate `fixSelection` implementation remains.
+
+### Phase 7: Clean up and document
+
+- Remove dead internal helpers left behind in `code.tsx` and `diff.tsx`.
+- Add a short migration doc for downstream consumers that want to switch from `Code`/`Diff` to unified `File`.
+- Mark `Code`/`Diff` contexts and components as compatibility APIs in comments or docs.
+
+#### Acceptance
+
+- No stale duplicate helpers remain in legacy wrappers.
+- Unified path is the default recommendation for new UI work.
+
+---
+
+## Preserve compatibility
+
+Keep old APIs working while moving internals under them.
+
+### Context migration strategy
+
+- Introduce `FileComponentProvider` without deleting `CodeComponentProvider` or `DiffComponentProvider`.
+- Implement `useCodeComponent` and `useDiffComponent` as adapters around the unified context where possible.
+- If full adapter reuse is messy at first, keep old contexts and providers as thin wrappers that internally provide mapped unified props.
+
+### Consumer migration targets
+
+- `packages/app/src/pages/session/file-tabs.tsx` should move from `useCodeComponent` to `useFileComponent`.
+- `packages/ui/src/components/session-review.tsx`, `session-turn.tsx`, and `message-part.tsx` should move from `useDiffComponent` to `useFileComponent`.
+- `packages/app/src/app.tsx` and `packages/enterprise/src/routes/share/[shareID].tsx` should eventually provide only the unified provider.
+- Keep legacy hooks available until all call sites are migrated and reviewed.
+
+### Compatibility checkpoints
+
+- `@opencode-ai/ui/code`, `@opencode-ai/ui/diff`, and `@opencode-ai/ui/diff-ssr` imports must keep working during migration.
+- Existing prop names on `Code` and `Diff` wrappers should remain stable to avoid broad app changes in one PR.
+
+---
+
+## Unify search
+
+Port the current find feature into a shared engine and attach it to both modes.
+
+### Shared engine plan
+
+- Move keyboard host registry and active-target logic out of `code.tsx` into `packages/ui/src/pierre/file-find.ts`.
+- Move the find bar UI into `packages/ui/src/components/file-search.tsx`.
+- Keep DOM-based scanning and highlight/overlay rendering shared, since both text and diff render into the same shadow-root patterns.
+
+### Diff-specific handling
+
+- Search should scan both unified and split diff columns through the same selectors used in the current code find feature.
+- Match navigation should scroll the active range into view without interfering with line selection state.
+- Search refresh should run after `onRendered`, diff style changes, annotation rerenders, and query changes.
+
+### Scope guard
+
+- Preserve the current DOM-scan behavior first, even if virtualized search is limited to mounted rows.
+- If full-document virtualized search is required, treat it as a follow-up with a text-index layer rather than blocking the core refactor.
+
+---
+
+## Consolidate media
+
+Move media rendering logic into shared UI so text, diff, and media routing live behind one entry.
+
+### Ownership plan
+
+- Put media detection and normalization helpers in `packages/ui/src/pierre/media.ts`.
+- Put shared rendering UI in `packages/ui/src/components/file-media.tsx`.
+- Keep layout-specific wrappers in `session-review.tsx` and `file-tabs.tsx`, but remove duplicated media branching and load-state code from them.
+
+### Proposed media props
+
+- `media.mode`: `"auto"` or `"off"` for default behavior.
+- `media.path`: file path for extension checks and labels.
+- `media.current`: loaded file content for plain-file views.
+- `media.before` and `media.after`: diff-side values for image/audio previews.
+- `media.readFile`: optional lazy loader for session review expansion.
+- `media.renderBinaryPlaceholder`: optional consumer override for binary states.
+- `media.renderLoading` and `media.renderError`: optional consumer overrides when generic text is not enough.
+
+### Parity targets
+
+- Keep current image and audio support from session review.
+- Keep current SVG and binary handling from file tabs.
+- Defer video or PDF support unless explicitly requested.
+
+---
+
+## Align SSR
+
+Make SSR diff hydration a mode of the unified viewer instead of a parallel implementation.
+
+### SSR plan
+
+- Add `packages/ui/src/components/file-ssr.tsx` as the unified SSR entry with a diff-only path in phase one.
+- Reuse shared diff helpers for `fixSelection`, theme sync, and commented-line marking.
+- Keep the private `fileContainer` hydration workaround isolated in the SSR module so client code stays clean.
+
+### Integration plan
+
+- Keep `packages/ui/src/components/diff-ssr.tsx` as a forwarding adapter for compatibility.
+- Update enterprise share route to the unified SSR import after client and context migrations are stable.
+- Align prop names with the client `File` component so `SessionReview` can swap client/SSR providers without branching logic.
+
+### Defer item
+
+- Plain-file SSR hydration is not needed for this refactor and can stay out of scope.
+
+---
+
+## Verify behavior
+
+Use typechecks and targeted UI checks after each phase, and avoid repo-root runs.
+
+### Typecheck plan
+
+- Run `bun run typecheck` from `packages/ui` after phases 1-7 changes there.
+- Run `bun run typecheck` from `packages/app` after migrating file tabs or app provider wiring.
+- Run `bun run typecheck` from `packages/enterprise` after SSR/provider changes on the share route.
+
+### Targeted UI checks
+
+- Text mode:
+ - small file render
+ - virtualized large file render
+ - drag selection and line-number selection
+ - comment annotations and commented-line marks
+ - find shortcuts and match navigation
+- Diff mode:
+ - unified and split styles
+ - large diff fallback options
+ - diff selection normalization across sides
+ - comments and commented-line marks
+ - new find UX parity
+- Media:
+ - image, audio, SVG, and binary states in file tabs
+ - image and audio diff previews in session review
+ - lazy load and error placeholders
+- SSR:
+ - enterprise share page preloaded diffs hydrate correctly
+ - theme switching still updates hydrated diffs
+
+### Regression focus
+
+- Watch scroll restore behavior in `packages/app/src/pages/session/file-tabs.tsx`.
+- Watch multi-instance find shortcut routing in screens with many viewers.
+- Watch cleanup paths for listeners and virtualizers to avoid leaks.
+
+---
+
+## Manage risk
+
+Keep wrappers and adapters in place until the unified path is proven.
+
+### Key risks
+
+- Selection regressions are the highest risk because text and diff have similar but not identical line semantics.
+- SSR hydration can break subtly if client and SSR prop shapes drift.
+- Shared find host state can misroute shortcuts when many viewers are mounted.
+- Media consolidation can accidentally change placeholder timing or load behavior.
+
+### Rollback strategy
+
+- Land each phase in separate PRs or clearly separated commits on `dev`.
+- If a phase regresses behavior, revert only that phase and keep earlier extractions.
+- Keep `code.tsx`, `diff.tsx`, and `diff-ssr.tsx` wrappers intact until final verification, so a rollback only changes internals.
+- If diff search is unstable, disable it behind the unified component while keeping the rest of the refactor.
+
+---
+
+## Order implementation
+
+Follow this sequence to keep reviews small and reduce merge risk.
+
+1. Finalize prop shape and file names for the unified component and context.
+2. Extract shared diff normalization, theme sync, and render-ready helpers with no public API changes.
+3. Extract shared selection controller and migrate `code.tsx` and `diff.tsx` to it.
+4. Add the unified client `File` component and convert `code.tsx`/`diff.tsx` into wrappers.
+5. Add `FileComponentProvider` and migrate provider wiring in `app.tsx` and enterprise share route.
+6. Migrate consumer hooks (`file-tabs`, `session-review`, `message-part`, `session-turn`) to the unified context.
+7. Extract and share find engine/UI, then enable search in diff mode.
+8. Extract media helpers/UI and migrate `session-review.tsx` and `file-tabs.tsx`.
+9. Add unified `file-ssr.tsx`, convert `diff-ssr.tsx` to a wrapper, and migrate enterprise imports.
+10. Remove dead duplication and write a short migration note for future consumers.
+
+---
+
+## Decide open items
+
+Resolve these before coding to avoid rework mid-refactor.
+
+### API decisions
+
+- Should the unified component require `mode`, or should it infer mode from props for convenience.
+- Should the public export be named `File` only, or also ship a temporary alias like `UnifiedFile` for migration clarity.
+- Should `preloadedDiff` live on the main `File` props or only on `file-ssr.tsx`.
+
+### Search decisions
+
+- Is DOM-only search acceptable for virtualized content in the first pass.
+- Should find state reset on every rerender, or preserve query and index across diff style toggles.
+
+### Media decisions
+
+- Which placeholders and strings should stay consumer-owned versus shared in UI.
+- Whether SVG should be treated as media-only, text-only, or a mixed mode with both preview and source.
+- Whether video support should be included now or explicitly deferred.
+
+### Migration decisions
+
+- How long `CodeComponentProvider` and `DiffComponentProvider` should remain supported.
+- Whether to migrate all consumers in one PR after wrappers land, or in follow-up PRs by surface area.
+- Whether `diff-ssr` should remain as a permanent alias for compatibility.
diff --git a/specs/session-review-cross-diff-search-plan.md b/specs/session-review-cross-diff-search-plan.md
new file mode 100644
index 000000000..6a15d5bec
--- /dev/null
+++ b/specs/session-review-cross-diff-search-plan.md
@@ -0,0 +1,234 @@
+# Session Review Cross-Diff Search Plan
+
+One search input for all diffs in the review pane
+
+---
+
+## Goal
+
+Add a single search UI to `SessionReview` that searches across all diff files in the accordion and supports next/previous navigation across files.
+
+Navigation should auto-open the target accordion item and reveal the active match inside the existing unified `File` diff viewer.
+
+---
+
+## Non-goals
+
+- Do not change diff rendering visuals, line comments, or file selection behavior.
+- Do not add regex, fuzzy search, or replace.
+- Do not change `@pierre/diffs` internals.
+
+---
+
+## Current behavior
+
+- `SessionReview` renders one `File` diff viewer per accordion item, but only mounts the viewer when that item is expanded.
+- Large diffs may be blocked behind the `MAX_DIFF_CHANGED_LINES` gate until the user clicks "render anyway".
+- `File` owns a local search engine (`createFileFind`) with:
+ - query state
+ - hit counting
+ - current match index
+ - highlighting (CSS Highlight API or overlay fallback)
+ - `Cmd/Ctrl+F` and `Cmd/Ctrl+G` keyboard handling
+- `FileSearchBar` is currently rendered per viewer.
+- There is no parent-level search state in `SessionReview`.
+
+---
+
+## UX requirements
+
+- Add one search bar in the `SessionReview` header (input, total count, prev, next, close).
+- Show a global count like `3/17` across all searchable diffs.
+- `Cmd/Ctrl+F` inside the session review pane opens the session-level search.
+- `Cmd/Ctrl+G`, `Shift+Cmd/Ctrl+G`, `Enter`, and `Shift+Enter` navigate globally.
+- Navigating to a match in a collapsed file auto-expands that file.
+- The active match scrolls into view and is highlighted in the target viewer.
+- Media/binary diffs are excluded from search.
+- Empty query clears highlights and resets to `0/0`.
+
+---
+
+## Architecture proposal
+
+Use a hybrid model:
+
+- A **session-level match index** for global searching/counting/navigation across all diffs.
+- The existing **per-viewer search engine** for local highlighting and scrolling in the active file.
+
+This avoids mounting every accordion item just to search while reusing the existing DOM highlight behavior.
+
+### High-level pieces
+
+- `SessionReview` owns the global query, hit list, and active hit index.
+- `File` exposes a small controlled search handle (register, set query, clear, reveal hit).
+- `SessionReview` keeps a map of mounted file viewers and their search handles.
+- `SessionReview` resolves next/prev hits, expands files as needed, then tells the target viewer to reveal the hit.
+
+---
+
+## Data model and interfaces
+
+```ts
+type SessionSearchHit = {
+ file: string
+ side: "additions" | "deletions"
+ line: number
+ col: number
+ len: number
+}
+
+type SessionSearchState = {
+ query: string
+ hits: SessionSearchHit[]
+ active: number
+}
+```
+
+```ts
+type FileSearchReveal = {
+ side: "additions" | "deletions"
+ line: number
+ col: number
+ len: number
+}
+
+type FileSearchHandle = {
+ setQuery: (value: string) => void
+ clear: () => void
+ reveal: (hit: FileSearchReveal) => boolean
+ refresh: () => void
+}
+```
+
+```ts
+type FileSearchControl = {
+ shortcuts?: "global" | "disabled"
+ showBar?: boolean
+ register: (handle: FileSearchHandle | null) => void
+}
+```
+
+---
+
+## Integration steps
+
+### Phase 1: Expose controlled search on `File`
+
+- Extend `createFileFind` and `File` to support a controlled search handle.
+- Keep existing per-viewer search behavior as the default path.
+- Add a way to disable per-viewer global shortcuts when hosted inside `SessionReview`.
+
+#### Acceptance
+
+- `File` still supports local search unchanged by default.
+- `File` can optionally register a search handle and accept controlled reveal calls.
+
+### Phase 2: Add session-level search state in `SessionReview`
+
+- Add a single search UI in the `SessionReview` header (can reuse `FileSearchBar` visuals or extract shared presentational pieces).
+- Build a global hit list from `props.diffs` string content.
+- Index hits by file/side/line/column/length.
+
+#### Acceptance
+
+- Header search appears once for the pane.
+- Global hit count updates as query changes.
+- Media/binary diffs are excluded.
+
+### Phase 3: Wire global navigation to viewers
+
+- Register a `FileSearchHandle` per mounted diff viewer.
+- On next/prev, resolve the active global hit and:
+ 1. expand the target file if needed
+ 2. wait for the viewer to mount/render
+ 3. call `handle.setQuery(query)` and `handle.reveal(hit)`
+
+#### Acceptance
+
+- Next/prev moves across files.
+- Collapsed targets auto-open.
+- Active match is highlighted in the target diff.
+
+### Phase 4: Handle large-diff gating
+
+- Lift `render anyway` state from local accordion item state into a file-keyed map in `SessionReview`.
+- If navigation targets a gated file, force-render it before reveal.
+
+#### Acceptance
+
+- Global search can navigate into a large diff without manual user expansion/render.
+
+### Phase 5: Keyboard and race-condition polish
+
+- Route `Cmd/Ctrl+F`, `Cmd/Ctrl+G`, `Shift+Cmd/Ctrl+G` to session search when focus is in the review pane.
+- Add token/cancel guards so fast navigation does not reveal stale targets after async mounts.
+
+#### Acceptance
+
+- Keyboard shortcuts consistently target session-level search.
+- No stale reveal jumps during rapid navigation.
+
+---
+
+## Edge cases
+
+- Empty query: clear all viewer highlights, reset count/index.
+- No results: keep the search bar open, disable prev/next.
+- Added/deleted files: index only the available side.
+- Collapsed files: queue reveal until `onRendered` fires.
+- Large diffs: auto-force render before reveal.
+- Split diff mode: handle duplicate text on both sides without losing side info.
+- Do not clear line comment draft or selected lines when navigating search results.
+
+---
+
+## Testing plan
+
+### Unit tests
+
+- Session hit-index builder:
+ - line/column mapping
+ - additions/deletions side tagging
+ - wrap-around next/prev behavior
+- `File` controlled search handle:
+ - `setQuery`
+ - `clear`
+ - `reveal` by side/line/column in unified and split diff
+
+### Component / integration tests
+
+- Search across multiple diffs and navigate across collapsed accordion items.
+- Global counter updates correctly (`current/total`).
+- Split and unified diff styles both navigate correctly.
+- Large diff target auto-renders on navigation.
+- Existing line comment draft remains intact while searching.
+
+### Manual verification
+
+- `Cmd/Ctrl+F` opens session-level search in the review pane.
+- `Cmd/Ctrl+G` / `Shift+Cmd/Ctrl+G` navigate globally.
+- Highlighting and scroll behavior stay stable with many open diffs.
+
+---
+
+## Risks and rollback
+
+### Key risks
+
+- Global index and DOM highlights can drift if line/column mapping does not match viewer DOM content exactly.
+- Keyboard shortcut conflicts between session-level search and per-viewer search.
+- Performance impact when indexing many large diffs in one session.
+
+### Rollback plan
+
+- Gate session-level search behind a `SessionReview` prop/flag during rollout.
+- If unstable, disable the session-level path and keep existing per-viewer search unchanged.
+
+---
+
+## Open questions
+
+- Should search match file paths as well as content, or content only?
+- In split mode, should the same text on both sides count as two matches?
+- Should auto-navigation into gated large diffs silently render them, or show a prompt first?
+- Should the session-level search bar reuse `FileSearchBar` directly or split out a shared non-portal variant?