Compare commits

...

8 Commits

Author SHA1 Message Date
adamelmore
6164930da1 chore: refactor diff and code into file component 2026-02-24 15:30:30 -06:00
adamelmore
2f573ec96e wip: progress 2026-02-24 15:26:28 -06:00
Adam
434e62fbf5 Merge branch 'dev' into composer 2026-02-24 11:16:57 -06:00
adamelmore
b7943c5367 chore: cleanup 2026-02-24 08:17:20 -06:00
adamelmore
cf6d9013f4 chore: cleanup 2026-02-24 08:04:22 -06:00
Adam
4ce72d0c54 wip(app): better diff/code comments 2026-02-22 11:35:05 -06:00
Adam
73c619ee46 wip(app): better diff/code comments 2026-02-22 06:19:09 -06:00
Adam
6ef6d538b2 wip(app): better diff/code comments 2026-02-21 19:29:27 -06:00
36 changed files with 4328 additions and 2997 deletions

View File

@@ -1,11 +1,9 @@
import "@/index.css"
import { Code } from "@opencode-ai/ui/code"
import { File } from "@opencode-ai/ui/file"
import { I18nProvider } from "@opencode-ai/ui/context"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { Diff } from "@opencode-ai/ui/diff"
import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta"
@@ -122,9 +120,7 @@ export function AppBaseProviders(props: ParentProps) {
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProviderWithNativeParser>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
</DiffComponentProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProviderWithNativeParser>
</DialogProvider>
</ErrorBoundary>

View File

@@ -9,7 +9,7 @@ import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { Code } from "@opencode-ai/ui/code"
import { File } from "@opencode-ai/ui/file"
import { Markdown } from "@opencode-ai/ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
@@ -47,7 +47,8 @@ function RawMessageContent(props: { message: Message; getParts: (id: string) =>
})
return (
<Code
<File
mode="text"
file={file()}
overflow="wrap"
class="select-text"

View File

@@ -44,6 +44,17 @@ function aggregate(comments: Record<string, LineComment[]>) {
.sort((a, b) => a.time - b.time)
}
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
const next: SelectedLineRange = {
start: selection.start,
end: selection.end,
}
if (selection.side) next.side = selection.side
if (selection.endSide) next.endSide = selection.endSide
return next
}
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
const [state, setState] = createStore({
focus: null as CommentFocus | null,
@@ -70,6 +81,7 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
id: uuid(),
time: Date.now(),
...input,
selection: cloneSelection(input.selection),
}
batch(() => {

View File

@@ -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

View File

@@ -378,11 +378,32 @@ export default function Page() {
})
}
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

View File

@@ -1,12 +1,12 @@
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, Show, 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 { 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 { Tabs } from "@opencode-ai/ui/tabs"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
@@ -17,13 +17,6 @@ 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}`
}
export function FileTabContent(props: { tab: string }) {
const params = useParams()
const layout = useLayout()
@@ -31,7 +24,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))
@@ -50,66 +43,24 @@ 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"),
})
path()
svgToast.shown = false
})
const svgPreviewUrl = createMemo(() => {
if (!isSvg()) return
const c = state()?.content
if (!c) return
if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
})
const imageDataUrl = createMemo(() => {
if (!isImage()) return
const c = state()?.content
return `data:${c?.mimeType};base64,${c?.content}`
})
const selectedLines = createMemo(() => {
const selectedLines = createMemo<SelectedLineRange | null>(() => {
const p = path()
if (!p) return null
if (file.ready()) return file.selectedLines(p) ?? null
return getSessionHandoff(sessionKey())?.files[p] ?? null
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
})
const selectionPreview = (source: string, selection: FileSelection) => {
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
const end = Math.max(selection.startLine, selection.endLine)
const lines = source.split("\n").slice(start - 1, end)
if (lines.length === 0) return undefined
return lines.slice(0, 2).join("\n")
return previewSelectedLines(source, {
start: selection.startLine,
end: selection.endLine,
})
}
const addCommentToContext = (input: {
@@ -145,129 +96,73 @@ export function FileTabContent(props: { tab: string }) {
})
}
let wrap: HTMLDivElement | undefined
const fileComments = createMemo(() => {
const p = path()
if (!p) return []
return comments.list(p)
})
const commentLayout = createMemo(() => {
return fileComments()
.map((comment) => `${comment.id}:${comment.selection.start}:${comment.selection.end}`)
.join("|")
})
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
const [note, setNote] = createStore({
openedComment: null as string | null,
commenting: null as SelectedLineRange | null,
draft: "",
positions: {} as Record<string, number>,
draftTop: undefined as number | undefined,
selected: null as SelectedLineRange | null,
})
const setCommenting = (range: SelectedLineRange | null) => {
setNote("commenting", range)
scheduleComments()
if (!range) return
setNote("draft", "")
const 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" })
},
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<string, number> = {}
for (const comment of fileComments()) {
const marker = findMarker(root, comment.selection)
if (marker) next[comment.id] = markerTop(el, marker)
else if (large) next[comment.id] = estimateTop(comment.selection)
}
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)
}
createEffect(() => {
commentLayout()
scheduleComments()
setTimeout(() => {
if (!document.activeElement || !current.contains(document.activeElement)) {
setNote("commenting", null)
}
}, 0)
},
})
createEffect(
on(
path,
() => {
commentsUi.note.reset()
},
{ defer: true },
),
)
createEffect(() => {
const focus = comments.focus()
const p = path()
@@ -278,9 +173,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 +312,111 @@ export function FileTabContent(props: { tab: string }) {
cancelAnimationFrame(scrollFrame)
})
const renderCode = (source: string, wrapperClass: string) => (
<div
ref={(el) => {
wrap = el
scheduleComments()
}}
class={`relative overflow-hidden ${wrapperClass}`}
>
const renderText = (source: string, wrapperClass: string, key = cacheKey()) => (
<div class={`relative overflow-hidden ${wrapperClass}`}>
<Dynamic
component={codeComponent}
component={fileComponent}
mode="text"
file={{
name: path() ?? "",
contents: source,
cacheKey: key,
}}
enableLineSelection
enableHoverUtility
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
requestAnimationFrame(restoreScroll)
}}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
renderHoverUtility={commentsUi.renderHoverUtility}
onLineSelected={(range: SelectedLineRange | null) => {
commentsUi.onLineSelected(range)
}}
onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
commentsUi.onLineSelectionEnd(range)
}}
overflow="scroll"
class="select-text"
media={{ mode: "off" }}
/>
</div>
)
const renderFile = (source: string, wrapperClass: string) => (
<div class={`relative overflow-hidden ${wrapperClass}`}>
<Dynamic
component={fileComponent}
mode="text"
file={{
name: path() ?? "",
contents: source,
cacheKey: cacheKey(),
}}
enableLineSelection
selectedLines={selectedLines()}
enableHoverUtility
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
requestAnimationFrame(restoreScroll)
requestAnimationFrame(scheduleComments)
}}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
renderHoverUtility={commentsUi.renderHoverUtility}
onLineSelected={(range: SelectedLineRange | null) => {
const p = path()
if (!p) return
file.setSelectedLines(p, range)
if (!range) setCommenting(null)
commentsUi.onLineSelected(range)
}}
onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
onLineSelectionEnd={(range: SelectedLineRange | null) => {
if (!range) {
setCommenting(null)
return
}
setNote("openedComment", null)
setCommenting(range)
commentsUi.onLineSelectionEnd(range)
}}
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
if (svgToast.shown) return
svgToast.shown = true
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
})
},
renderImage: (args: { src: string; onLoad: () => void }) => (
<div class="px-6 py-4 pb-40">
<img src={args.src} alt={path()} class="max-w-full" onLoad={args.onLoad} />
</div>
),
renderSvg: (args: { src?: string; source: string; onLoad: () => void }) => (
<div class="flex flex-col gap-4 px-6 py-4">
{renderText(args.source, "", sampledChecksum(args.source))}
<Show when={args.src}>
{(src) => (
<div class="flex justify-center pb-40">
<img src={src()} alt={path()} class="max-w-full max-h-96" onLoad={args.onLoad} />
</div>
)}
</Show>
</div>
),
renderBinaryPlaceholder: () => (
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="flex flex-col gap-2 max-w-md">
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
<div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
</div>
</div>
),
}}
/>
<For each={fileComments()}>
{(comment) => (
<LineCommentView
id={comment.id}
top={note.positions[comment.id]}
open={note.openedComment === comment.id}
comment={comment.comment}
selection={formatCommentLabel(comment.selection)}
onMouseEnter={() => {
const p = path()
if (!p) return
file.setSelectedLines(p, comment.selection)
}}
onClick={() => {
const p = path()
if (!p) return
setCommenting(null)
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
file.setSelectedLines(p, comment.selection)
}}
/>
)}
</For>
<Show when={note.commenting}>
{(range) => (
<Show when={note.draftTop !== undefined}>
<LineCommentEditor
top={note.draftTop}
value={note.draft}
selection={formatCommentLabel(range())}
onInput={(value) => setNote("draft", value)}
onCancel={cancelCommenting}
onSubmit={(value) => {
const p = path()
if (!p) return
addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" })
setCommenting(null)
}}
onPopoverFocusOut={(e: FocusEvent) => {
const current = e.currentTarget as HTMLDivElement
const target = e.relatedTarget
if (target instanceof Node && current.contains(target)) return
setTimeout(() => {
if (!document.activeElement || !current.contains(document.activeElement)) {
cancelCommenting()
}
}, 0)
}}
/>
</Show>
)}
</Show>
</div>
)
@@ -526,36 +431,7 @@ export function FileTabContent(props: { tab: string }) {
onScroll={handleScroll as any}
>
<Switch>
<Match when={state()?.loaded && isImage()}>
<div class="px-6 py-4 pb-40">
<img
src={imageDataUrl()}
alt={path()}
class="max-w-full"
onLoad={() => requestAnimationFrame(restoreScroll)}
/>
</div>
</Match>
<Match when={state()?.loaded && isSvg()}>
<div class="flex flex-col gap-4 px-6 py-4">
{renderCode(svgContent() ?? "", "")}
<Show when={svgPreviewUrl()}>
<div class="flex justify-center pb-40">
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
</div>
</Show>
</div>
</Match>
<Match when={state()?.loaded && isBinary()}>
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="flex flex-col gap-2 max-w-md">
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
<div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
</div>
</div>
</Match>
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
<Match when={state()?.loaded}>{renderFile(contents(), "pb-40")}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
</Match>

View File

@@ -2,8 +2,7 @@ import { FileDiff, Message, Model, Part, Session, SessionStatus, UserMessage } f
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { DataProvider } from "@opencode-ai/ui/context"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
import { createAsync, query, useParams } from "@solidjs/router"
import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
@@ -22,14 +21,12 @@ import NotFound from "../[...404]"
import { Tabs } from "@opencode-ai/ui/tabs"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { Diff as SSRDiff } from "@opencode-ai/ui/diff-ssr"
import { FileSSR } from "@opencode-ai/ui/file-ssr"
import { clientOnly } from "@solidjs/start"
import { type IconName } from "@opencode-ai/ui/icons/provider"
import { Meta, Title } from "@solidjs/meta"
import { Base64 } from "js-base64"
const ClientOnlyDiff = clientOnly(() => import("@opencode-ai/ui/diff").then((m) => ({ default: m.Diff })))
const ClientOnlyCode = clientOnly(() => import("@opencode-ai/ui/code").then((m) => ({ default: m.Code })))
const ClientOnlyWorkerPoolProvider = clientOnly(() =>
import("@opencode-ai/ui/pierre/worker").then((m) => ({
default: (props: { children: any }) => (
@@ -218,252 +215,244 @@ export default function () {
<Meta property="og:image" content={ogImage()} />
<Meta name="twitter:image" content={ogImage()} />
<ClientOnlyWorkerPoolProvider>
<DiffComponentProvider component={ClientOnlyDiff}>
<CodeComponentProvider component={ClientOnlyCode}>
<DataProvider data={data()} directory={info().directory}>
{iife(() => {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
})
const messages = createMemo(() =>
data().sessionID
? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
(a, b) => a.time.created - b.time.created,
)
: [],
)
const firstUserMessage = createMemo(() => messages().at(0))
const activeMessage = createMemo(
() => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
)
function setActiveMessage(message: UserMessage | undefined) {
if (message) {
setStore("messageId", message.id)
} else {
setStore("messageId", undefined)
}
<FileComponentProvider component={FileSSR}>
<DataProvider data={data()} directory={info().directory}>
{iife(() => {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
})
const messages = createMemo(() =>
data().sessionID
? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
(a, b) => a.time.created - b.time.created,
)
: [],
)
const firstUserMessage = createMemo(() => messages().at(0))
const activeMessage = createMemo(
() => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
)
function setActiveMessage(message: UserMessage | undefined) {
if (message) {
setStore("messageId", message.id)
} else {
setStore("messageId", undefined)
}
const provider = createMemo(() => activeMessage()?.model?.providerID)
const modelID = createMemo(() => activeMessage()?.model?.modelID)
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
const diffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const splitDiffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
}
const provider = createMemo(() => activeMessage()?.model?.providerID)
const modelID = createMemo(() => activeMessage()?.model?.modelID)
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
const diffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const splitDiffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const title = () => (
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2 sm:flex-row sm:gap-4 sm:items-center sm:h-8 justify-start self-stretch">
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base w-fit">
<Mark class="shrink-0 w-3 my-0.5" />
<div class="text-12-mono text-text-base">v{info().version}</div>
</div>
<div class="flex gap-4 items-center">
<div class="flex gap-2 items-center">
<ProviderIcon
id={provider() as IconName}
class="size-3.5 shrink-0 text-icon-strong-base"
/>
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
</div>
<div class="text-12-regular text-text-weaker">
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
</div>
</div>
const title = () => (
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2 sm:flex-row sm:gap-4 sm:items-center sm:h-8 justify-start self-stretch">
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base w-fit">
<Mark class="shrink-0 w-3 my-0.5" />
<div class="text-12-mono text-text-base">v{info().version}</div>
</div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
)
const turns = () => (
<div class="relative mt-2 pb-8 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
<div class="px-4 py-6">{title()}</div>
<div class="flex flex-col gap-15 items-start justify-start mt-4">
<For each={messages()}>
{(message) => (
<SessionTurn
sessionID={data().sessionID}
messageID={message.id}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
container: "px-4",
}}
/>
)}
</For>
</div>
<div class="px-4 flex items-center justify-center pt-20 pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
<div class="flex gap-4 items-center">
<div class="flex gap-2 items-center">
<ProviderIcon
id={provider() as IconName}
class="size-3.5 shrink-0 text-icon-strong-base"
/>
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
</div>
<div class="text-12-regular text-text-weaker">
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
</div>
</div>
</div>
)
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
)
const wide = createMemo(() => diffs().length === 0)
const turns = () => (
<div class="relative mt-2 pb-8 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
<div class="px-4 py-6">{title()}</div>
<div class="flex flex-col gap-15 items-start justify-start mt-4">
<For each={messages()}>
{(message) => (
<SessionTurn
sessionID={data().sessionID}
messageID={message.id}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
container: "px-4",
}}
/>
)}
</For>
</div>
<div class="px-4 flex items-center justify-center pt-20 pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
</div>
</div>
)
return (
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
<div class="">
<a href="https://opencode.ai">
<Mark />
</a>
</div>
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/anomalyco/opencode"
target="_blank"
icon="github"
variant="ghost"
/>
<IconButton
as={"a"}
href="https://opencode.ai/discord"
target="_blank"
icon="discord"
variant="ghost"
/>
</div>
</header>
<div class="select-text flex flex-col flex-1 min-h-0">
const wide = createMemo(() => diffs().length === 0)
return (
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
<div class="">
<a href="https://opencode.ai">
<Mark />
</a>
</div>
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/anomalyco/opencode"
target="_blank"
icon="github"
variant="ghost"
/>
<IconButton
as={"a"}
href="https://opencode.ai/discord"
target="_blank"
icon="discord"
variant="ghost"
/>
</div>
</header>
<div class="select-text flex flex-col flex-1 min-h-0">
<div
classList={{
"hidden w-full flex-1 min-h-0": true,
"md:flex": wide(),
"lg:flex": !wide(),
}}
>
<div
classList={{
"hidden w-full flex-1 min-h-0": true,
"md:flex": wide(),
"lg:flex": !wide(),
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
}}
>
<div
classList={{
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
"w-full flex justify-start items-start min-w-0 px-6": true,
}}
>
<div
classList={{
"w-full flex justify-start items-start min-w-0 px-6": true,
{title()}
</div>
<div class="flex items-start justify-start h-full min-h-0">
<Show when={messages().length > 1}>
<MessageNav
class="sticky top-0 shrink-0 py-2 pl-4"
messages={messages()}
current={activeMessage()}
size="compact"
onMessageSelect={setActiveMessage}
/>
</Show>
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
classes={{
root: "grow",
content: "flex flex-col justify-between",
container: "w-full pb-20 px-6",
}}
>
{title()}
</div>
<div class="flex items-start justify-start h-full min-h-0">
<Show when={messages().length > 1}>
<MessageNav
class="sticky top-0 shrink-0 py-2 pl-4"
messages={messages()}
current={activeMessage()}
size="compact"
onMessageSelect={setActiveMessage}
/>
</Show>
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
classes={{
root: "grow",
content: "flex flex-col justify-between",
container: "w-full pb-20 px-6",
}}
>
<div
classList={{ "w-full flex items-center justify-center pb-8 shrink-0": true }}
>
<Logo class="w-58.5 opacity-12" />
</div>
</SessionTurn>
</div>
<div classList={{ "w-full flex items-center justify-center pb-8 shrink-0": true }}>
<Logo class="w-58.5 opacity-12" />
</div>
</SessionTurn>
</div>
<Show when={diffs().length > 0}>
<DiffComponentProvider component={SSRDiff}>
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
</div>
<Show when={diffs().length > 0}>
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
<SessionReview
class="@4xl:hidden"
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
<SessionReview
split
class="hidden @4xl:flex"
diffs={splitDiffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
</div>
</Show>
</div>
<Switch>
<Match when={diffs().length > 0}>
<Tabs classList={{ "md:hidden": wide(), "lg:hidden": !wide() }}>
<Tabs.List>
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
Session
</Tabs.Trigger>
<Tabs.Trigger
value="review"
class="w-1/2 !border-r-0"
classes={{ button: "w-full" }}
>
{diffs().length} Files Changed
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="session" class="!overflow-hidden">
{turns()}
</Tabs.Content>
<Tabs.Content
forceMount
value="review"
class="!overflow-hidden hidden data-[selected]:block"
>
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
<SessionReview
class="@4xl:hidden"
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
<SessionReview
split
class="hidden @4xl:flex"
diffs={splitDiffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
header: "px-4",
container: "px-4",
}}
/>
</div>
</DiffComponentProvider>
</Show>
</div>
<Switch>
<Match when={diffs().length > 0}>
<Tabs classList={{ "md:hidden": wide(), "lg:hidden": !wide() }}>
<Tabs.List>
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
Session
</Tabs.Trigger>
<Tabs.Trigger
value="review"
class="w-1/2 !border-r-0"
classes={{ button: "w-full" }}
>
{diffs().length} Files Changed
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="session" class="!overflow-hidden">
{turns()}
</Tabs.Content>
<Tabs.Content
forceMount
value="review"
class="!overflow-hidden hidden data-[selected]:block"
>
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
<DiffComponentProvider component={SSRDiff}>
<SessionReview
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-4",
container: "px-4",
}}
/>
</DiffComponentProvider>
</div>
</Tabs.Content>
</Tabs>
</Match>
<Match when={true}>
<div
classList={{ "!overflow-hidden": true, "md:hidden": wide(), "lg:hidden": !wide() }}
>
{turns()}
</div>
</Match>
</Switch>
</div>
</Tabs.Content>
</Tabs>
</Match>
<Match when={true}>
<div
classList={{ "!overflow-hidden": true, "md:hidden": wide(), "lg:hidden": !wide() }}
>
{turns()}
</div>
</Match>
</Switch>
</div>
)
})}
</DataProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</div>
)
})}
</DataProvider>
</FileComponentProvider>
</ClientOnlyWorkerPoolProvider>
</>
)

View File

@@ -1,4 +0,0 @@
[data-component="code"] {
content-visibility: auto;
overflow: hidden;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,317 +0,0 @@
import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Dynamic, isServer } from "solid-js/web"
import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre"
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { useWorkerPool } from "../context/worker-pool"
export type SSRDiffProps<T = {}> = DiffProps<T> & {
preloadedDiff: PreloadMultiFileDiffResult<T>
}
export function Diff<T>(props: SSRDiffProps<T>) {
let container!: HTMLDivElement
let fileDiffRef!: HTMLElement
const [local, others] = splitProps(props, [
"before",
"after",
"class",
"classList",
"annotations",
"selectedLines",
"commentedLines",
])
const workerPool = useWorkerPool(props.diffStyle)
let fileDiffInstance: FileDiff<T> | undefined
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
const cleanupFunctions: Array<() => void> = []
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
const getVirtualizer = () => {
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
const result = acquireVirtualizer(container)
if (!result) return
sharedVirtualizer = result
return result.virtualizer
}
const applyScheme = () => {
const scheme = document.documentElement.dataset.colorScheme
if (scheme === "dark" || scheme === "light") {
fileDiffRef.dataset.colorScheme = scheme
return
}
fileDiffRef.removeAttribute("data-color-scheme")
}
const lineIndex = (split: boolean, element: HTMLElement) => {
const raw = element.dataset.lineIndex
if (!raw) return
const values = raw
.split(",")
.map((value) => parseInt(value, 10))
.filter((value) => !Number.isNaN(value))
if (values.length === 0) return
if (!split) return values[0]
if (values.length === 2) return values[1]
return values[0]
}
const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: "additions" | "deletions" | undefined) => {
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (nodes.length === 0) return
const targetSide = side ?? "additions"
for (const node of nodes) {
if (findSide(node) === targetSide) return lineIndex(split, node)
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
}
}
const fixSelection = (range: SelectedLineRange | null) => {
if (!range) return range
const root = getRoot()
if (!root) return
const diffs = root.querySelector("[data-diff]")
if (!(diffs instanceof HTMLElement)) return
const split = diffs.dataset.diffType === "split"
const start = rowIndex(root, split, range.start, range.side)
const end = rowIndex(root, split, range.end, range.endSide ?? range.side)
if (start === undefined || end === undefined) {
if (root.querySelector("[data-line], [data-alt-line]") == null) return
return null
}
if (start <= end) return range
const side = range.endSide ?? range.side
const swapped: SelectedLineRange = {
start: range.end,
end: range.start,
}
if (side) swapped.side = side
if (range.endSide && range.side) swapped.endSide = range.side
return swapped
}
const setSelectedLines = (range: SelectedLineRange | null, attempt = 0) => {
const diff = fileDiffInstance
if (!diff) return
const fixed = fixSelection(range)
if (fixed === undefined) {
if (attempt >= 120) return
requestAnimationFrame(() => setSelectedLines(range, attempt + 1))
return
}
diff.setSelectedLines(fixed)
}
const findSide = (element: HTMLElement): "additions" | "deletions" => {
const line = element.closest("[data-line], [data-alt-line]")
if (line instanceof HTMLElement) {
const type = line.dataset.lineType
if (type === "change-deletion") return "deletions"
if (type === "change-addition" || type === "change-additions") return "additions"
}
const code = element.closest("[data-code]")
if (!(code instanceof HTMLElement)) return "additions"
return code.hasAttribute("data-deletions") ? "deletions" : "additions"
}
const applyCommentedLines = (ranges: SelectedLineRange[]) => {
const root = getRoot()
if (!root) return
const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
for (const node of existing) {
if (!(node instanceof HTMLElement)) continue
node.removeAttribute("data-comment-selected")
}
const diffs = root.querySelector("[data-diff]")
if (!(diffs instanceof HTMLElement)) return
const split = diffs.dataset.diffType === "split"
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (rows.length === 0) return
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
const lineIndex = (element: HTMLElement) => {
const raw = element.dataset.lineIndex
if (!raw) return
const values = raw
.split(",")
.map((value) => parseInt(value, 10))
.filter((value) => !Number.isNaN(value))
if (values.length === 0) return
if (!split) return values[0]
if (values.length === 2) return values[1]
return values[0]
}
const rowIndex = (line: number, side: "additions" | "deletions" | undefined) => {
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (nodes.length === 0) return
const targetSide = side ?? "additions"
for (const node of nodes) {
if (findSide(node) === targetSide) return lineIndex(node)
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(node)
}
}
for (const range of ranges) {
const start = rowIndex(range.start, range.side)
if (start === undefined) continue
const end = (() => {
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
if (same) return start
return rowIndex(range.end, range.endSide ?? range.side)
})()
if (end === undefined) continue
const first = Math.min(start, end)
const last = Math.max(start, end)
for (const row of rows) {
const idx = lineIndex(row)
if (idx === undefined) continue
if (idx < first || idx > last) continue
row.setAttribute("data-comment-selected", "")
}
for (const annotation of annotations) {
const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
if (Number.isNaN(idx)) continue
if (idx < first || idx > last) continue
annotation.setAttribute("data-comment-selected", "")
}
}
}
onMount(() => {
if (isServer || !props.preloadedDiff) return
applyScheme()
if (typeof MutationObserver !== "undefined") {
const root = document.documentElement
const monitor = new MutationObserver(() => applyScheme())
monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
onCleanup(() => monitor.disconnect())
}
const virtualizer = getVirtualizer()
fileDiffInstance = virtualizer
? new VirtualizedFileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...props.preloadedDiff,
},
virtualizer,
virtualMetrics,
workerPool,
)
: new FileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...props.preloadedDiff,
},
workerPool,
)
// @ts-expect-error - fileContainer is private but needed for SSR hydration
fileDiffInstance.fileContainer = fileDiffRef
fileDiffInstance.hydrate({
oldFile: local.before,
newFile: local.after,
lineAnnotations: local.annotations,
fileContainer: fileDiffRef,
containerWrapper: container,
})
setSelectedLines(local.selectedLines ?? null)
createEffect(() => {
fileDiffInstance?.setLineAnnotations(local.annotations ?? [])
})
createEffect(() => {
setSelectedLines(local.selectedLines ?? null)
})
createEffect(() => {
const ranges = local.commentedLines ?? []
requestAnimationFrame(() => applyCommentedLines(ranges))
})
// Hydrate annotation slots with interactive SolidJS components
// if (props.annotations.length > 0 && props.renderAnnotation != null) {
// for (const annotation of props.annotations) {
// const slotName = `annotation-${annotation.side}-${annotation.lineNumber}`;
// const slotElement = fileDiffRef.querySelector(
// `[slot="${slotName}"]`
// ) as HTMLElement;
//
// if (slotElement != null) {
// // Clear the static server-rendered content from the slot
// slotElement.innerHTML = '';
//
// // Mount a fresh SolidJS component into this slot using render().
// // This enables full SolidJS reactivity (signals, effects, etc.)
// const dispose = render(
// () => props.renderAnnotation!(annotation),
// slotElement
// );
// cleanupFunctions.push(dispose);
// }
// }
// }
})
onCleanup(() => {
// Clean up FileDiff event handlers and dispose SolidJS components
fileDiffInstance?.cleanUp()
cleanupFunctions.forEach((dispose) => dispose())
sharedVirtualizer?.release()
sharedVirtualizer = undefined
})
return (
<div data-component="diff" style={styleVariables} ref={container}>
<Dynamic component={DIFFS_TAG_NAME} ref={fileDiffRef} id="ssr-diff">
<Show when={isServer}>
<template shadowrootmode="open" innerHTML={props.preloadedDiff.prerenderedHTML} />
</Show>
</Dynamic>
</div>
)
}

View File

@@ -1,652 +0,0 @@
import { sampledChecksum } from "@opencode-ai/util/encode"
import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
import { createMediaQuery } from "@solid-primitives/media"
import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { getWorkerPool } from "../pierre/worker"
type SelectionSide = "additions" | "deletions"
function findElement(node: Node | null): HTMLElement | undefined {
if (!node) return
if (node instanceof HTMLElement) return node
return node.parentElement ?? undefined
}
function findLineNumber(node: Node | null): number | undefined {
const element = findElement(node)
if (!element) return
const line = element.closest("[data-line], [data-alt-line]")
if (!(line instanceof HTMLElement)) return
const value = (() => {
const primary = parseInt(line.dataset.line ?? "", 10)
if (!Number.isNaN(primary)) return primary
const alt = parseInt(line.dataset.altLine ?? "", 10)
if (!Number.isNaN(alt)) return alt
})()
return value
}
function findSide(node: Node | null): SelectionSide | undefined {
const element = findElement(node)
if (!element) return
const line = element.closest("[data-line], [data-alt-line]")
if (line instanceof HTMLElement) {
const type = line.dataset.lineType
if (type === "change-deletion") return "deletions"
if (type === "change-addition" || type === "change-additions") return "additions"
}
const code = element.closest("[data-code]")
if (!(code instanceof HTMLElement)) return
if (code.hasAttribute("data-deletions")) return "deletions"
return "additions"
}
export function Diff<T>(props: DiffProps<T>) {
let container!: HTMLDivElement
let observer: MutationObserver | undefined
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
let renderToken = 0
let selectionFrame: number | undefined
let dragFrame: number | undefined
let dragStart: number | undefined
let dragEnd: number | undefined
let dragSide: SelectionSide | undefined
let dragEndSide: SelectionSide | undefined
let dragMoved = false
let lastSelection: SelectedLineRange | null = null
let pendingSelectionEnd = false
const [local, others] = splitProps(props, [
"before",
"after",
"class",
"classList",
"annotations",
"selectedLines",
"commentedLines",
"onRendered",
])
const mobile = createMediaQuery("(max-width: 640px)")
const large = createMemo(() => {
const before = typeof local.before?.contents === "string" ? local.before.contents : ""
const after = typeof local.after?.contents === "string" ? local.after.contents : ""
return Math.max(before.length, after.length) > 500_000
})
const largeOptions = {
lineDiffType: "none",
maxLineDiffLength: 0,
tokenizeMaxLineLength: 1,
} satisfies Pick<FileDiffOptions<T>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
const options = createMemo<FileDiffOptions<T>>(() => {
const base = {
...createDefaultOptions(props.diffStyle),
...others,
}
const perf = large() ? { ...base, ...largeOptions } : base
if (!mobile()) return perf
return {
...perf,
disableLineNumbers: true,
}
})
let instance: FileDiff<T> | undefined
const [current, setCurrent] = createSignal<FileDiff<T> | undefined>(undefined)
const [rendered, setRendered] = createSignal(0)
const getVirtualizer = () => {
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
const result = acquireVirtualizer(container)
if (!result) return
sharedVirtualizer = result
return result.virtualizer
}
const getRoot = () => {
const host = container.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return
const root = host.shadowRoot
if (!root) return
return root
}
const applyScheme = () => {
const host = container.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return
const scheme = document.documentElement.dataset.colorScheme
if (scheme === "dark" || scheme === "light") {
host.dataset.colorScheme = scheme
return
}
host.removeAttribute("data-color-scheme")
}
const lineIndex = (split: boolean, element: HTMLElement) => {
const raw = element.dataset.lineIndex
if (!raw) return
const values = raw
.split(",")
.map((value) => parseInt(value, 10))
.filter((value) => !Number.isNaN(value))
if (values.length === 0) return
if (!split) return values[0]
if (values.length === 2) return values[1]
return values[0]
}
const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: SelectionSide | undefined) => {
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (nodes.length === 0) return
const targetSide = side ?? "additions"
for (const node of nodes) {
if (findSide(node) === targetSide) return lineIndex(split, node)
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
}
}
const fixSelection = (range: SelectedLineRange | null) => {
if (!range) return range
const root = getRoot()
if (!root) return
const diffs = root.querySelector("[data-diff]")
if (!(diffs instanceof HTMLElement)) return
const split = diffs.dataset.diffType === "split"
const start = rowIndex(root, split, range.start, range.side)
const end = rowIndex(root, split, range.end, range.endSide ?? range.side)
if (start === undefined || end === undefined) {
if (root.querySelector("[data-line], [data-alt-line]") == null) return
return null
}
if (start <= end) return range
const side = range.endSide ?? range.side
const swapped: SelectedLineRange = {
start: range.end,
end: range.start,
}
if (side) swapped.side = side
if (range.endSide && range.side) swapped.endSide = range.side
return swapped
}
const notifyRendered = () => {
observer?.disconnect()
observer = undefined
renderToken++
const token = renderToken
let settle = 0
const isReady = (root: ShadowRoot) => root.querySelector("[data-line]") != null
const notify = () => {
if (token !== renderToken) return
observer?.disconnect()
observer = undefined
requestAnimationFrame(() => {
if (token !== renderToken) return
setSelectedLines(lastSelection)
local.onRendered?.()
})
}
const schedule = () => {
settle++
const current = settle
requestAnimationFrame(() => {
if (token !== renderToken) return
if (current !== settle) return
requestAnimationFrame(() => {
if (token !== renderToken) return
if (current !== settle) return
notify()
})
})
}
const observeRoot = (root: ShadowRoot) => {
observer?.disconnect()
observer = new MutationObserver(() => {
if (token !== renderToken) return
if (!isReady(root)) return
schedule()
})
observer.observe(root, { childList: true, subtree: true })
if (!isReady(root)) return
schedule()
}
const root = getRoot()
if (typeof MutationObserver === "undefined") {
if (!root || !isReady(root)) return
setSelectedLines(lastSelection)
local.onRendered?.()
return
}
if (root) {
observeRoot(root)
return
}
observer = new MutationObserver(() => {
if (token !== renderToken) return
const root = getRoot()
if (!root) return
observeRoot(root)
})
observer.observe(container, { childList: true, subtree: true })
}
const applyCommentedLines = (ranges: SelectedLineRange[]) => {
const root = getRoot()
if (!root) return
const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
for (const node of existing) {
if (!(node instanceof HTMLElement)) continue
node.removeAttribute("data-comment-selected")
}
const diffs = root.querySelector("[data-diff]")
if (!(diffs instanceof HTMLElement)) return
const split = diffs.dataset.diffType === "split"
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (rows.length === 0) return
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
for (const range of ranges) {
const start = rowIndex(root, split, range.start, range.side)
if (start === undefined) continue
const end = (() => {
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
if (same) return start
return rowIndex(root, split, range.end, range.endSide ?? range.side)
})()
if (end === undefined) continue
const first = Math.min(start, end)
const last = Math.max(start, end)
for (const row of rows) {
const idx = lineIndex(split, row)
if (idx === undefined) continue
if (idx < first || idx > last) continue
row.setAttribute("data-comment-selected", "")
}
for (const annotation of annotations) {
const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
if (Number.isNaN(idx)) continue
if (idx < first || idx > last) continue
annotation.setAttribute("data-comment-selected", "")
}
}
}
const setSelectedLines = (range: SelectedLineRange | null) => {
const active = current()
if (!active) return
const fixed = fixSelection(range)
if (fixed === undefined) {
lastSelection = range
return
}
lastSelection = fixed
active.setSelectedLines(fixed)
}
const updateSelection = () => {
const root = getRoot()
if (!root) return
const selection =
(root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
if (!selection || selection.isCollapsed) return
const domRange =
(
selection as unknown as {
getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[]
}
).getComposedRanges?.({ shadowRoots: [root] })?.[0] ??
(selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
const startNode = domRange?.startContainer ?? selection.anchorNode
const endNode = domRange?.endContainer ?? selection.focusNode
if (!startNode || !endNode) return
if (!root.contains(startNode) || !root.contains(endNode)) return
const start = findLineNumber(startNode)
const end = findLineNumber(endNode)
if (start === undefined || end === undefined) return
const startSide = findSide(startNode)
const endSide = findSide(endNode)
const side = startSide ?? endSide
const selected: SelectedLineRange = {
start,
end,
}
if (side) selected.side = side
if (endSide && side && endSide !== side) selected.endSide = endSide
setSelectedLines(selected)
}
const scheduleSelectionUpdate = () => {
if (selectionFrame !== undefined) return
selectionFrame = requestAnimationFrame(() => {
selectionFrame = undefined
updateSelection()
if (!pendingSelectionEnd) return
pendingSelectionEnd = false
props.onLineSelectionEnd?.(lastSelection)
})
}
const updateDragSelection = () => {
if (dragStart === undefined || dragEnd === undefined) return
const selected: SelectedLineRange = {
start: dragStart,
end: dragEnd,
}
if (dragSide) selected.side = dragSide
if (dragEndSide && dragSide && dragEndSide !== dragSide) selected.endSide = dragEndSide
setSelectedLines(selected)
}
const scheduleDragUpdate = () => {
if (dragFrame !== undefined) return
dragFrame = requestAnimationFrame(() => {
dragFrame = undefined
updateDragSelection()
})
}
const lineFromMouseEvent = (event: MouseEvent) => {
const path = event.composedPath()
let numberColumn = false
let line: number | undefined
let side: SelectionSide | undefined
for (const item of path) {
if (!(item instanceof HTMLElement)) continue
numberColumn = numberColumn || item.dataset.columnNumber != null
if (side === undefined) {
const type = item.dataset.lineType
if (type === "change-deletion") side = "deletions"
if (type === "change-addition" || type === "change-additions") side = "additions"
}
if (side === undefined && item.dataset.code != null) {
side = item.hasAttribute("data-deletions") ? "deletions" : "additions"
}
if (line === undefined) {
const primary = item.dataset.line ? parseInt(item.dataset.line, 10) : Number.NaN
if (!Number.isNaN(primary)) {
line = primary
} else {
const alt = item.dataset.altLine ? parseInt(item.dataset.altLine, 10) : Number.NaN
if (!Number.isNaN(alt)) line = alt
}
}
if (numberColumn && line !== undefined && side !== undefined) break
}
return { line, numberColumn, side }
}
const handleMouseDown = (event: MouseEvent) => {
if (props.enableLineSelection !== true) return
if (event.button !== 0) return
const { line, numberColumn, side } = lineFromMouseEvent(event)
if (numberColumn) return
if (line === undefined) return
dragStart = line
dragEnd = line
dragSide = side
dragEndSide = side
dragMoved = false
}
const handleMouseMove = (event: MouseEvent) => {
if (props.enableLineSelection !== true) return
if (dragStart === undefined) return
if ((event.buttons & 1) === 0) {
dragStart = undefined
dragEnd = undefined
dragSide = undefined
dragEndSide = undefined
dragMoved = false
return
}
const { line, side } = lineFromMouseEvent(event)
if (line === undefined) return
dragEnd = line
dragEndSide = side
dragMoved = true
scheduleDragUpdate()
}
const handleMouseUp = () => {
if (props.enableLineSelection !== true) return
if (dragStart === undefined) return
if (!dragMoved) {
pendingSelectionEnd = false
const line = dragStart
const selected: SelectedLineRange = {
start: line,
end: line,
}
if (dragSide) selected.side = dragSide
setSelectedLines(selected)
props.onLineSelectionEnd?.(lastSelection)
dragStart = undefined
dragEnd = undefined
dragSide = undefined
dragEndSide = undefined
dragMoved = false
return
}
pendingSelectionEnd = true
scheduleDragUpdate()
scheduleSelectionUpdate()
dragStart = undefined
dragEnd = undefined
dragSide = undefined
dragEndSide = undefined
dragMoved = false
}
const handleSelectionChange = () => {
if (props.enableLineSelection !== true) return
if (dragStart === undefined) return
const selection = window.getSelection()
if (!selection || selection.isCollapsed) return
scheduleSelectionUpdate()
}
createEffect(() => {
const opts = options()
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
const virtualizer = getVirtualizer()
const annotations = local.annotations
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
const cacheKey = (contents: string) => {
if (!large()) return sampledChecksum(contents, contents.length)
return sampledChecksum(contents)
}
instance?.cleanUp()
instance = virtualizer
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
: new FileDiff<T>(opts, workerPool)
setCurrent(instance)
container.innerHTML = ""
instance.render({
oldFile: {
...local.before,
contents: beforeContents,
cacheKey: cacheKey(beforeContents),
},
newFile: {
...local.after,
contents: afterContents,
cacheKey: cacheKey(afterContents),
},
lineAnnotations: annotations,
containerWrapper: container,
})
applyScheme()
setRendered((value) => value + 1)
notifyRendered()
})
createEffect(() => {
if (typeof document === "undefined") return
if (typeof MutationObserver === "undefined") return
const root = document.documentElement
const monitor = new MutationObserver(() => applyScheme())
monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
applyScheme()
onCleanup(() => monitor.disconnect())
})
createEffect(() => {
rendered()
const ranges = local.commentedLines ?? []
requestAnimationFrame(() => applyCommentedLines(ranges))
})
createEffect(() => {
const selected = local.selectedLines ?? null
setSelectedLines(selected)
})
createEffect(() => {
if (props.enableLineSelection !== true) return
container.addEventListener("mousedown", handleMouseDown)
container.addEventListener("mousemove", handleMouseMove)
window.addEventListener("mouseup", handleMouseUp)
document.addEventListener("selectionchange", handleSelectionChange)
onCleanup(() => {
container.removeEventListener("mousedown", handleMouseDown)
container.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
document.removeEventListener("selectionchange", handleSelectionChange)
})
})
onCleanup(() => {
observer?.disconnect()
if (selectionFrame !== undefined) {
cancelAnimationFrame(selectionFrame)
selectionFrame = undefined
}
if (dragFrame !== undefined) {
cancelAnimationFrame(dragFrame)
dragFrame = undefined
}
dragStart = undefined
dragEnd = undefined
dragSide = undefined
dragEndSide = undefined
dragMoved = false
lastSelection = null
pendingSelectionEnd = false
instance?.cleanUp()
setCurrent(undefined)
sharedVirtualizer?.release()
sharedVirtualizer = undefined
})
return <div data-component="diff" style={styleVariables} ref={container} />
}

View File

@@ -0,0 +1,243 @@
import type { FileContent } from "@opencode-ai/sdk/v2"
import { createEffect, createMemo, createSignal, Match, Show, Switch, type JSX } from "solid-js"
import {
dataUrlFromMediaValue,
hasMediaValue,
isBinaryContent,
mediaKindFromPath,
normalizeMimeType,
svgTextFromValue,
} from "../pierre/media"
export type FileMediaOptions = {
mode?: "auto" | "off"
path?: string
current?: unknown
before?: unknown
after?: unknown
readFile?: (path: string) => Promise<FileContent | undefined>
renderImage?: (ctx: { src: string; path?: string; onLoad: () => void }) => JSX.Element
renderAudio?: (ctx: { src: string; path?: string; mime?: string; onLoad: () => void }) => JSX.Element
renderSvg?: (ctx: { src?: string; source: string; path?: string; onLoad: () => void }) => JSX.Element
renderRemoved?: (ctx: { kind: "image" | "audio" }) => JSX.Element
renderPlaceholder?: (ctx: { kind: "image" | "audio" }) => JSX.Element
renderLoading?: (ctx: { kind: "image" | "audio" }) => JSX.Element
renderError?: (ctx: { kind: "image" | "audio" | "svg" }) => JSX.Element
renderBinaryPlaceholder?: (ctx: { path?: string }) => JSX.Element
onLoad?: () => void
onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void
}
function defaultImage(ctx: { src: string; path?: string; onLoad: () => void }) {
return (
<div class="px-6 py-4">
<img src={ctx.src} alt={ctx.path} class="max-w-full" onLoad={ctx.onLoad} />
</div>
)
}
function defaultAudio(ctx: { src: string; mime?: string; onLoad: () => void }) {
return (
<div class="px-6 py-4">
<audio controls preload="metadata" onLoadedMetadata={ctx.onLoad}>
<source src={ctx.src} type={ctx.mime} />
</audio>
</div>
)
}
function defaultSvg(ctx: { src?: string; source: string; path?: string; onLoad: () => void }) {
return (
<div class="flex flex-col gap-4 px-6 py-4">
<pre class="overflow-auto rounded border border-border-weak-base bg-background-base p-3 text-12-mono">
{ctx.source}
</pre>
<Show when={ctx.src}>
{(src) => (
<div class="flex justify-center">
<img src={src()} alt={ctx.path} class="max-w-full max-h-96" onLoad={ctx.onLoad} />
</div>
)}
</Show>
</div>
)
}
function defaultBinary(path: string | undefined) {
return <div class="px-6 py-4 text-text-weak">{path ? `${path} is binary.` : "Binary content"}</div>
}
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 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 [src, setSrc] = createSignal<string | undefined>(undefined)
const [status, setStatus] = createSignal<"idle" | "loading" | "error">("idle")
const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined)
let svgError = false
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)
})
createEffect(() => {
cfg()?.path
cfg()?.current
svgError = false
})
createEffect(() => {
const media = cfg()
const k = kind()
if (!media || !k) {
setSrc(undefined)
setStatus("idle")
setAudioMime(undefined)
return
}
if (k === "svg") {
setSrc(undefined)
setStatus("idle")
setAudioMime(undefined)
return
}
setSrc(dataUrlFromMediaValue(mediaValue(media, k), k))
setStatus("idle")
setAudioMime(undefined)
})
createEffect(() => {
const media = cfg()
const k = kind()
if (!media || !k || k === "svg") return
if (media.current !== undefined) return
if (src()) return
if (status() !== "idle") return
if (deleted()) return
if (!media.path) return
if (!media.readFile) return
setStatus("loading")
media
.readFile(media.path)
.then((result) => {
const next = dataUrlFromMediaValue(result as any, k)
if (!next) {
setStatus("error")
media.onError?.({ kind: k })
return
}
setSrc(next)
setStatus("idle")
if (k === "audio") setAudioMime(normalizeMimeType(result?.mimeType))
})
.catch(() => {
setStatus("error")
media.onError?.({ kind: k })
})
})
const svgSource = createMemo(() => {
const media = cfg()
if (!media) return
return svgTextFromValue(media.current as any)
})
const svgSrc = createMemo(() => {
const media = cfg()
if (!media) return
return dataUrlFromMediaValue(media.current as any, "svg")
})
createEffect(() => {
if (kind() !== "svg") return
const media = cfg()
if (!media) return
if (svgSource() !== undefined) return
if (svgError) return
if (!hasMediaValue(media.current as any)) return
svgError = true
media.onError?.({ kind: "svg" })
})
return (
<Switch>
<Match when={kind() === "image" || kind() === "audio"}>
<Show
when={src()}
fallback={(() => {
const media = cfg()
const k = kind()
if (!media || (k !== "image" && k !== "audio")) return props.fallback()
if (deleted()) {
return media.renderRemoved?.({ kind: k }) ?? <div class="px-6 py-4 text-text-weak">Removed {k} file.</div>
}
if (status() === "loading") {
return media.renderLoading?.({ kind: k }) ?? <div class="px-6 py-4 text-text-weak">Loading {k}...</div>
}
if (status() === "error") {
return media.renderError?.({ kind: k }) ?? <div class="px-6 py-4 text-text-weak">Unable to load {k}.</div>
}
return (
media.renderPlaceholder?.({ kind: k }) ?? (
<div class="px-6 py-4 text-text-weak">{k} preview unavailable.</div>
)
)
})()}
>
{(value) => {
const media = cfg()
const k = kind()
if (!media || (k !== "image" && k !== "audio")) return props.fallback()
if (k === "image") {
return (media.renderImage ?? defaultImage)({ src: value(), path: media.path, onLoad })
}
return (media.renderAudio ?? defaultAudio)({ src: value(), path: media.path, mime: audioMime(), onLoad })
}}
</Show>
</Match>
<Match when={kind() === "svg"}>
{(() => {
const media = cfg()
if (!media) return props.fallback()
if (!media.renderSvg && svgSource() === undefined && svgSrc() == null) return props.fallback()
return (media.renderSvg ?? defaultSvg)({
src: svgSrc(),
source: svgSource() ?? "",
path: media.path,
onLoad,
})
})()}
</Match>
<Match when={isBinary()}>
{cfg()?.renderBinaryPlaceholder?.({ path: cfg()?.path }) ?? defaultBinary(cfg()?.path)}
</Match>
<Match when={true}>{props.fallback()}</Match>
</Switch>
)
}

View File

@@ -0,0 +1,69 @@
import { Portal } from "solid-js/web"
import { Icon } from "./icon"
export function FileSearchBar(props: {
pos: () => { top: number; right: number }
query: () => string
index: () => number
count: () => number
setInput: (el: HTMLInputElement) => void
onInput: (value: string) => void
onKeyDown: (event: KeyboardEvent) => void
onClose: () => void
onPrev: () => void
onNext: () => void
}) {
return (
<Portal>
<div
class="fixed z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md"
style={{
top: `${props.pos().top}px`,
right: `${props.pos().right}px`,
}}
onPointerDown={(e) => e.stopPropagation()}
>
<Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" />
<input
ref={props.setInput}
placeholder="Find"
value={props.query()}
class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak"
onInput={(e) => props.onInput(e.currentTarget.value)}
onKeyDown={(e) => props.onKeyDown(e as KeyboardEvent)}
/>
<div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}>
{props.count() ? `${props.index() + 1}/${props.count()}` : "0/0"}
</div>
<div class="flex items-center">
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
disabled={props.count() === 0}
aria-label="Previous match"
onClick={props.onPrev}
>
<Icon name="chevron-down" size="small" class="rotate-180" />
</button>
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
disabled={props.count() === 0}
aria-label="Next match"
onClick={props.onNext}
>
<Icon name="chevron-down" size="small" />
</button>
</div>
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
aria-label="Close search"
onClick={props.onClose}
>
<Icon name="close-small" size="small" />
</button>
</div>
</Portal>
)
}

View File

@@ -0,0 +1,178 @@
import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs"
import { type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Dynamic, isServer } from "solid-js/web"
import { useWorkerPool } from "../context/worker-pool"
import { createDefaultOptions, styleVariables } from "../pierre"
import { markCommentedDiffLines } from "../pierre/commented-lines"
import { fixDiffSelection } from "../pierre/diff-selection"
import {
applyViewerScheme,
clearReadyWatcher,
createReadyWatcher,
notifyShadowReady,
observeViewerScheme,
} from "../pierre/file-runtime"
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { File, type DiffFileProps, type FileProps } from "./file"
type SSRDiffFileProps<T> = DiffFileProps<T> & {
preloadedDiff: PreloadMultiFileDiffResult<T>
}
function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
let container!: HTMLDivElement
let fileDiffRef!: HTMLElement
let fileDiffInstance: FileDiff<T> | undefined
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
const ready = createReadyWatcher()
const workerPool = useWorkerPool(props.diffStyle)
const [local, others] = splitProps(props, [
"mode",
"media",
"before",
"after",
"class",
"classList",
"annotations",
"selectedLines",
"commentedLines",
"onLineSelected",
"onLineSelectionEnd",
"onLineNumberSelectionEnd",
"onRendered",
"preloadedDiff",
])
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
const getVirtualizer = () => {
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
const result = acquireVirtualizer(container)
if (!result) return
sharedVirtualizer = result
return result.virtualizer
}
const setSelectedLines = (range: DiffFileProps<T>["selectedLines"], attempt = 0) => {
const diff = fileDiffInstance
if (!diff) return
const fixed = fixDiffSelection(getRoot(), range ?? null)
if (fixed === undefined) {
if (attempt >= 120) return
requestAnimationFrame(() => setSelectedLines(range ?? null, attempt + 1))
return
}
diff.setSelectedLines(fixed)
}
const notifyRendered = () => {
notifyShadowReady({
state: ready,
container,
getRoot,
isReady: (root) => root.querySelector("[data-line]") != null,
settleFrames: 1,
onReady: () => {
setSelectedLines(local.selectedLines ?? null)
local.onRendered?.()
},
})
}
onMount(() => {
if (isServer) return
onCleanup(observeViewerScheme(() => fileDiffRef))
const virtualizer = getVirtualizer()
fileDiffInstance = virtualizer
? new VirtualizedFileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...local.preloadedDiff,
},
virtualizer,
virtualMetrics,
workerPool,
)
: new FileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...local.preloadedDiff,
},
workerPool,
)
applyViewerScheme(fileDiffRef)
// @ts-expect-error private field required for hydration
fileDiffInstance.fileContainer = fileDiffRef
fileDiffInstance.hydrate({
oldFile: local.before,
newFile: local.after,
lineAnnotations: local.annotations ?? [],
fileContainer: fileDiffRef,
containerWrapper: container,
})
notifyRendered()
})
createEffect(() => {
const diff = fileDiffInstance
if (!diff) return
diff.setLineAnnotations(local.annotations ?? [])
diff.rerender()
})
createEffect(() => {
setSelectedLines(local.selectedLines ?? null)
})
createEffect(() => {
const ranges = local.commentedLines ?? []
requestAnimationFrame(() => {
const root = getRoot()
if (!root) return
markCommentedDiffLines(root, ranges)
})
})
onCleanup(() => {
clearReadyWatcher(ready)
fileDiffInstance?.cleanUp()
sharedVirtualizer?.release()
sharedVirtualizer = undefined
})
return (
<div
data-component="file"
data-mode="diff"
style={styleVariables}
class={local.class}
classList={local.classList}
ref={container}
>
<Dynamic component={DIFFS_TAG_NAME} ref={fileDiffRef} id="ssr-diff">
<Show when={isServer}>
<template shadowrootmode="open" innerHTML={local.preloadedDiff.prerenderedHTML} />
</Show>
</Dynamic>
</div>
)
}
export type FileSSRProps<T = {}> = FileProps<T>
export function FileSSR<T>(props: FileSSRProps<T>) {
if (props.mode !== "diff" || !props.preloadedDiff) return File(props)
return DiffSSRViewer(props as SSRDiffFileProps<T>)
}

View File

@@ -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);

View File

@@ -0,0 +1,955 @@
import { sampledChecksum } from "@opencode-ai/util/encode"
import {
DEFAULT_VIRTUAL_FILE_METRICS,
type DiffLineAnnotation,
type FileContents,
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 } from "../pierre/file-find"
import {
applyViewerScheme,
clearReadyWatcher,
createReadyWatcher,
getViewerHost,
getViewerRoot,
notifyShadowReady,
observeViewerScheme,
} from "../pierre/file-runtime"
import {
findCodeSelectionSide,
findDiffLineNumber,
findElement,
findFileLineNumber,
readShadowLineSelection,
} from "../pierre/file-selection"
import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { getWorkerPool } from "../pierre/worker"
import { FileMedia, type FileMediaOptions } from "./file-media"
import { FileSearchBar } from "./file-search"
const VIRTUALIZE_BYTES = 500_000
const codeMetrics = {
...DEFAULT_VIRTUAL_FILE_METRICS,
lineHeight: 24,
fileGap: 0,
} satisfies Partial<VirtualFileMetrics>
type SharedProps<T> = {
annotations?: LineAnnotation<T>[] | DiffLineAnnotation<T>[]
selectedLines?: SelectedLineRange | null
commentedLines?: SelectedLineRange[]
onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
onRendered?: () => void
class?: string
classList?: ComponentProps<"div">["classList"]
media?: FileMediaOptions
}
export type TextFileProps<T = {}> = FileOptions<T> &
SharedProps<T> & {
mode: "text"
file: FileContents
annotations?: LineAnnotation<T>[]
preloadedDiff?: PreloadMultiFileDiffResult<T>
}
export type DiffFileProps<T = {}> = FileDiffOptions<T> &
SharedProps<T> & {
mode: "diff"
before: FileContents
after: FileContents
annotations?: DiffLineAnnotation<T>[]
preloadedDiff?: PreloadMultiFileDiffResult<T>
}
export type FileProps<T = {}> = TextFileProps<T> | DiffFileProps<T>
function TextViewer<T>(props: TextFileProps<T>) {
let wrapper!: HTMLDivElement
let container!: HTMLDivElement
let overlay!: HTMLDivElement
let instance: PierreFile<T> | VirtualizedFile<T> | undefined
let virtualizer: Virtualizer | undefined
let virtualRoot: Document | HTMLElement | undefined
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 [local, others] = splitProps(props, [
"mode",
"media",
"file",
"class",
"classList",
"annotations",
"selectedLines",
"commentedLines",
"onLineSelected",
"onLineSelectionEnd",
"onLineNumberSelectionEnd",
"onRendered",
"preloadedDiff",
])
const [rendered, setRendered] = createSignal(0)
const getRoot = () => getViewerRoot(container)
const getHost = () => getViewerHost(container)
const find = createFileFind({
wrapper: () => wrapper,
overlay: () => overlay,
getRoot,
})
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 options = createMemo(() => ({
...createDefaultOptions<T>("unified"),
...others,
onLineSelected: (range: SelectedLineRange | null) => {
lastSelection = range
local.onLineSelected?.(range)
},
onLineSelectionEnd: (range: SelectedLineRange | null) => {
lastSelection = range
local.onLineSelectionEnd?.(range)
if (!bridge.consume(range)) return
requestAnimationFrame(() => local.onLineNumberSelectionEnd?.(range))
},
}))
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 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 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 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) => {
lastSelection = range
applySelection(range)
}
const notifyRendered = () => {
notifyShadowReady({
state: ready,
container,
getRoot,
isReady: (root) => {
if (virtual()) return root.querySelector("[data-line]") != null
return root.querySelectorAll("[data-line]").length >= lineCount()
},
onReady: () => {
applySelection(lastSelection)
find.refresh({ reset: true })
local.onRendered?.()
},
})
}
const updateSelection = (preserveTextSelection = false) => {
const root = 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)
}
const scheduleSelectionUpdate = () => {
if (selectionFrame !== undefined) return
selectionFrame = requestAnimationFrame(() => {
selectionFrame = undefined
const finishing = pendingSelectionEnd
updateSelection(finishing)
if (!pendingSelectionEnd) return
pendingSelectionEnd = false
local.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) {
bridge.begin(true, line)
return
}
if (line === undefined) return
bridge.begin(false, line)
dragStart = line
dragEnd = line
dragMoved = false
}
const handleMouseMove = (event: MouseEvent) => {
if (props.enableLineSelection !== true) return
const next = lineFromMouseEvent(event)
if (bridge.track(event.buttons, next.line)) return
if (dragStart === undefined) return
if ((event.buttons & 1) === 0) {
dragStart = undefined
dragEnd = undefined
dragMoved = false
bridge.finish()
return
}
if (next.line === undefined) return
dragEnd = next.line
dragMoved = true
scheduleDragUpdate()
}
const handleMouseUp = () => {
if (props.enableLineSelection !== true) return
if (bridge.finish() === "numbers") return
if (dragStart === undefined) return
if (!dragMoved) {
pendingSelectionEnd = false
setSelectedLines({ start: dragStart, end: dragStart })
local.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()
}
onMount(() => {
onCleanup(observeViewerScheme(getHost))
})
createEffect(() => {
const opts = options()
const workerPool = getWorkerPool("unified")
const isVirtual = virtual()
clearReadyWatcher(ready)
instance?.cleanUp()
instance = undefined
if (!isVirtual && virtualizer) {
virtualizer.cleanUp()
virtualizer = undefined
virtualRoot = undefined
}
const v = (() => {
if (!isVirtual) return
if (typeof document === "undefined") return
const root = getScrollParent(wrapper) ?? document
if (virtualizer && virtualRoot === root) return virtualizer
virtualizer?.cleanUp()
virtualizer = new Virtualizer()
virtualRoot = root
virtualizer.setup(root, root instanceof Document ? undefined : wrapper)
return virtualizer
})()
instance =
isVirtual && v ? new VirtualizedFile<T>(opts, v, codeMetrics, workerPool) : new PierreFile<T>(opts, workerPool)
container.innerHTML = ""
const value = text()
instance.render({
file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents: value },
lineAnnotations: [],
containerWrapper: container,
})
applyViewerScheme(getHost())
setRendered((value) => value + 1)
notifyRendered()
})
createEffect(() => {
rendered()
const active = instance
if (!active) return
active.setLineAnnotations((local.annotations as LineAnnotation<T>[] | undefined) ?? [])
active.rerender()
requestAnimationFrame(() => find.refresh({ reset: true }))
})
createEffect(() => {
rendered()
const ranges = local.commentedLines ?? []
requestAnimationFrame(() => {
const root = getRoot()
if (!root) return
markCommentedFileLines(root, 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(() => {
clearReadyWatcher(ready)
instance?.cleanUp()
instance = undefined
virtualizer?.cleanUp()
virtualizer = undefined
virtualRoot = undefined
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 (
<div
data-component="file"
data-mode="text"
style={styleVariables}
class="relative outline-none"
classList={{
...(local.classList || {}),
[local.class ?? ""]: !!local.class,
}}
ref={wrapper}
tabIndex={0}
onPointerDown={find.onPointerDown}
onFocus={find.onFocus}
>
<Show when={find.open()}>
<FileSearchBar
pos={find.pos}
query={find.query}
count={find.count}
index={find.index}
setInput={find.setInput}
onInput={find.setQuery}
onKeyDown={find.onInputKeyDown}
onClose={find.close}
onPrev={() => find.next(-1)}
onNext={() => find.next(1)}
/>
</Show>
<div ref={container} />
<div ref={overlay} class="pointer-events-none absolute inset-0 z-0" />
</div>
)
}
function DiffViewer<T>(props: DiffFileProps<T>) {
let wrapper!: HTMLDivElement
let container!: HTMLDivElement
let overlay!: HTMLDivElement
let instance: FileDiff<T> | undefined
let selectionFrame: number | undefined
let dragFrame: number | undefined
let dragStart: number | undefined
let dragEnd: number | undefined
let dragSide: DiffSelectionSide | undefined
let dragEndSide: DiffSelectionSide | undefined
let dragMoved = false
let lastSelection: SelectedLineRange | null = null
let pendingSelectionEnd = false
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
const ready = createReadyWatcher()
const bridge = createLineNumberSelectionBridge()
const [local, others] = splitProps(props, [
"mode",
"media",
"before",
"after",
"class",
"classList",
"annotations",
"selectedLines",
"commentedLines",
"onLineSelected",
"onLineSelectionEnd",
"onLineNumberSelectionEnd",
"onRendered",
"preloadedDiff",
])
const mobile = createMediaQuery("(max-width: 640px)")
const [current, setCurrent] = createSignal<FileDiff<T> | undefined>(undefined)
const [rendered, setRendered] = createSignal(0)
const getRoot = () => getViewerRoot(container)
const getHost = () => getViewerHost(container)
const find = createFileFind({
wrapper: () => wrapper,
overlay: () => overlay,
getRoot,
})
const large = createMemo(() => {
const before = typeof local.before?.contents === "string" ? local.before.contents : ""
const after = typeof local.after?.contents === "string" ? local.after.contents : ""
return Math.max(before.length, after.length) > 500_000
})
const largeOptions = {
lineDiffType: "none",
maxLineDiffLength: 0,
tokenizeMaxLineLength: 1,
} satisfies Pick<FileDiffOptions<T>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
const options = createMemo<FileDiffOptions<T>>(() => {
const base = {
...createDefaultOptions(props.diffStyle),
...others,
onLineSelected: (range: SelectedLineRange | null) => {
const fixed = fixDiffSelection(getRoot(), range)
const next = fixed === undefined ? range : fixed
lastSelection = next
local.onLineSelected?.(next)
},
onLineSelectionEnd: (range: SelectedLineRange | null) => {
const fixed = fixDiffSelection(getRoot(), range)
const next = fixed === undefined ? range : fixed
lastSelection = next
local.onLineSelectionEnd?.(next)
if (!bridge.consume(next)) return
requestAnimationFrame(() => local.onLineNumberSelectionEnd?.(next))
},
}
const perf = large() ? { ...base, ...largeOptions } : base
if (!mobile()) return perf
return { ...perf, disableLineNumbers: true }
})
const getVirtualizer = () => {
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
const result = acquireVirtualizer(container)
if (!result) return
sharedVirtualizer = result
return result.virtualizer
}
const setSelectedLines = (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => {
const active = current()
if (!active) return
const fixed = fixDiffSelection(getRoot(), range)
if (fixed === undefined) {
lastSelection = range
return
}
lastSelection = fixed
active.setSelectedLines(fixed)
restoreShadowTextSelection(preserve?.root, preserve?.text)
}
const notifyRendered = () => {
notifyShadowReady({
state: ready,
container,
getRoot,
isReady: (root) => root.querySelector("[data-line]") != null,
settleFrames: 1,
onReady: () => {
setSelectedLines(lastSelection)
find.refresh({ reset: true })
local.onRendered?.()
},
})
}
const updateSelection = (preserveTextSelection = false) => {
const root = getRoot()
if (!root) return
const selected = readShadowLineSelection({
root,
lineForNode: findDiffLineNumber,
sideForNode: (node) => {
const el = findElement(node)
if (!el) return
return findDiffSide(el)
},
preserveTextSelection,
})
if (!selected) return
if (selected.text) {
setSelectedLines(selected.range, { root, text: selected.text })
return
}
setSelectedLines(selected.range)
}
const scheduleSelectionUpdate = () => {
if (selectionFrame !== undefined) return
selectionFrame = requestAnimationFrame(() => {
selectionFrame = undefined
const finishing = pendingSelectionEnd
updateSelection(finishing)
if (!pendingSelectionEnd) return
pendingSelectionEnd = false
local.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: DiffSelectionSide | 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) line = findDiffLineNumber(item)
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 next = lineFromMouseEvent(event)
if (next.numberColumn) {
bridge.begin(true, next.line)
return
}
if (next.line === undefined) return
bridge.begin(false, next.line)
dragStart = next.line
dragEnd = next.line
dragSide = next.side
dragEndSide = next.side
dragMoved = false
}
const handleMouseMove = (event: MouseEvent) => {
if (props.enableLineSelection !== true) return
const next = lineFromMouseEvent(event)
if (bridge.track(event.buttons, next.line)) return
if (dragStart === undefined) return
if ((event.buttons & 1) === 0) {
dragStart = undefined
dragEnd = undefined
dragSide = undefined
dragEndSide = undefined
dragMoved = false
bridge.finish()
return
}
if (next.line === undefined) return
dragEnd = next.line
dragEndSide = next.side
dragMoved = true
scheduleDragUpdate()
}
const handleMouseUp = () => {
if (props.enableLineSelection !== true) return
if (bridge.finish() === "numbers") return
if (dragStart === undefined) return
if (!dragMoved) {
pendingSelectionEnd = false
const selected: SelectedLineRange = { start: dragStart, end: dragStart }
if (dragSide) selected.side = dragSide
setSelectedLines(selected)
local.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()
}
onMount(() => {
onCleanup(observeViewerScheme(getHost))
})
createEffect(() => {
const opts = options()
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
const virtualizer = getVirtualizer()
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)
}
clearReadyWatcher(ready)
instance?.cleanUp()
instance = virtualizer
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
: new FileDiff<T>(opts, workerPool)
setCurrent(instance)
container.innerHTML = ""
instance.render({
oldFile: { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) },
newFile: { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) },
lineAnnotations: [],
containerWrapper: container,
})
applyViewerScheme(getHost())
setRendered((value) => value + 1)
notifyRendered()
})
createEffect(() => {
rendered()
const active = current()
if (!active) return
active.setLineAnnotations((local.annotations as DiffLineAnnotation<T>[] | undefined) ?? [])
active.rerender()
requestAnimationFrame(() => find.refresh({ reset: true }))
})
createEffect(() => {
rendered()
const ranges = local.commentedLines ?? []
requestAnimationFrame(() => {
const root = getRoot()
if (!root) return
markCommentedDiffLines(root, 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(() => {
clearReadyWatcher(ready)
if (selectionFrame !== undefined) cancelAnimationFrame(selectionFrame)
if (dragFrame !== undefined) cancelAnimationFrame(dragFrame)
selectionFrame = undefined
dragFrame = undefined
dragStart = undefined
dragEnd = undefined
dragSide = undefined
dragEndSide = undefined
dragMoved = false
bridge.reset()
lastSelection = null
pendingSelectionEnd = false
instance?.cleanUp()
setCurrent(undefined)
sharedVirtualizer?.release()
sharedVirtualizer = undefined
})
return (
<div
data-component="file"
data-mode="diff"
style={styleVariables}
class="relative outline-none"
classList={{
...(local.classList || {}),
[local.class ?? ""]: !!local.class,
}}
ref={wrapper}
tabIndex={0}
onPointerDown={find.onPointerDown}
onFocus={find.onFocus}
>
<Show when={find.open()}>
<FileSearchBar
pos={find.pos}
query={find.query}
count={find.count}
index={find.index}
setInput={find.setInput}
onInput={find.setQuery}
onKeyDown={find.onInputKeyDown}
onClose={find.close}
onPrev={() => find.next(-1)}
onNext={() => find.next(1)}
/>
</Show>
<div ref={container} />
<div ref={overlay} class="pointer-events-none absolute inset-0 z-0" />
</div>
)
}
export function File<T>(props: FileProps<T>) {
if (props.mode === "text") {
return <FileMedia media={props.media} fallback={() => TextViewer(props)} />
}
return <FileMedia media={props.media} fallback={() => DiffViewer(props)} />
}

View File

@@ -0,0 +1,508 @@
import { type DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs"
import { createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX } from "solid-js"
import { render as renderSolid } from "solid-js/web"
import { createHoverCommentUtility } from "../pierre/comment-hover"
import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge"
import { LineComment, LineCommentEditor } from "./line-comment"
export type LineCommentAnnotationMeta<T> =
| { kind: "comment"; key: string; comment: T }
| { kind: "draft"; key: string; range: SelectedLineRange }
export type LineCommentAnnotation<T> = {
lineNumber: number
side?: "additions" | "deletions"
metadata: LineCommentAnnotationMeta<T>
}
type LineCommentAnnotationsProps<T> = {
comments: Accessor<T[]>
getCommentId: (comment: T) => string
getCommentSelection: (comment: T) => SelectedLineRange
draftRange: Accessor<SelectedLineRange | null>
draftKey: Accessor<string>
}
type LineCommentAnnotationsWithSideProps<T> = LineCommentAnnotationsProps<T> & {
getSide: (range: SelectedLineRange) => "additions" | "deletions"
}
type HoverCommentLine = {
lineNumber: number
side?: "additions" | "deletions"
}
type LineCommentStateProps<T> = {
opened: Accessor<T | null>
setOpened: (id: T | null) => void
selected: Accessor<SelectedLineRange | null>
setSelected: (range: SelectedLineRange | null) => void
commenting: Accessor<SelectedLineRange | null>
setCommenting: (range: SelectedLineRange | null) => void
syncSelected?: (range: SelectedLineRange | null) => void
hoverSelected?: (range: SelectedLineRange) => void
}
type LineCommentShape = {
id: string
selection: SelectedLineRange
comment: string
}
type LineCommentControllerProps<T extends LineCommentShape> = {
comments: Accessor<T[]>
draftKey: Accessor<string>
label: string
state: LineCommentStateProps<string>
onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
onDraftPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
getHoverSelectedRange?: Accessor<SelectedLineRange | null>
cancelDraftOnCommentToggle?: boolean
clearSelectionOnSelectionEndNull?: boolean
}
type LineCommentControllerWithSideProps<T extends LineCommentShape> = LineCommentControllerProps<T> & {
getSide: (range: SelectedLineRange) => "additions" | "deletions"
}
type CommentProps = {
id?: string
open: boolean
comment: JSX.Element
selection: JSX.Element
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
}
type DraftProps = {
value: string
selection: JSX.Element
onInput: (value: string) => void
onCancel: VoidFunction
onSubmit: (value: string) => void
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
}
export function createLineCommentAnnotationRenderer<T>(props: {
renderComment: (comment: T) => CommentProps
renderDraft: (range: SelectedLineRange) => DraftProps
}) {
const nodes = new Map<
string,
{
host: HTMLDivElement
dispose: VoidFunction
setMeta: (meta: LineCommentAnnotationMeta<T>) => void
}
>()
const mount = (meta: LineCommentAnnotationMeta<T>) => {
if (typeof document === "undefined") return
const host = document.createElement("div")
host.setAttribute("data-prevent-autofocus", "")
const [current, setCurrent] = createSignal(meta)
const dispose = renderSolid(() => {
const active = current()
if (active.kind === "comment") {
const view = createMemo(() => {
const next = current()
if (next.kind !== "comment") return props.renderComment(active.comment)
return props.renderComment(next.comment)
})
return (
<LineComment
inline
id={view().id}
open={view().open}
comment={view().comment}
selection={view().selection}
onClick={view().onClick}
onMouseEnter={view().onMouseEnter}
/>
)
}
const view = createMemo(() => {
const next = current()
if (next.kind !== "draft") return props.renderDraft(active.range)
return props.renderDraft(next.range)
})
return (
<LineCommentEditor
inline
value={view().value}
selection={view().selection}
onInput={view().onInput}
onCancel={view().onCancel}
onSubmit={view().onSubmit}
onPopoverFocusOut={view().onPopoverFocusOut}
/>
)
}, host)
const node = { host, dispose, setMeta: setCurrent }
nodes.set(meta.key, node)
return node
}
const render = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotation: A) => {
const meta = annotation.metadata
const node = nodes.get(meta.key) ?? mount(meta)
if (!node) return
node.setMeta(meta)
return node.host
}
const reconcile = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotations: A[]) => {
const next = new Set(annotations.map((annotation) => annotation.metadata.key))
for (const [key, node] of nodes) {
if (next.has(key)) continue
node.dispose()
nodes.delete(key)
}
}
const cleanup = () => {
for (const [, node] of nodes) node.dispose()
nodes.clear()
}
return { render, reconcile, cleanup }
}
export function createLineCommentState<T>(props: LineCommentStateProps<T>) {
const [draft, setDraft] = createSignal("")
const 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("")
setCommenting(null)
}
const reset = () => {
setDraft("")
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("")
closeComment()
setSelected(next)
setCommenting(next)
}
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,
opened: props.opened,
selected: props.selected,
commenting: props.commenting,
isOpen: (id: T) => props.opened() === id,
closeComment,
openComment,
toggleComment,
openDraft,
hoverComment,
cancelDraft,
finishSelection,
select: setSelected,
reset,
}
}
export function createLineCommentController<T extends LineCommentShape>(
props: LineCommentControllerWithSideProps<T>,
): {
note: ReturnType<typeof createLineCommentState<string>>
annotations: Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
renderHoverUtility: ReturnType<typeof createLineCommentHoverRenderer>
onLineSelected: (range: SelectedLineRange | null) => void
onLineSelectionEnd: (range: SelectedLineRange | null) => void
onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
}
export function createLineCommentController<T extends LineCommentShape>(
props: LineCommentControllerProps<T>,
): {
note: ReturnType<typeof createLineCommentState<string>>
annotations: Accessor<LineCommentAnnotation<T>[]>
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
renderHoverUtility: ReturnType<typeof createLineCommentHoverRenderer>
onLineSelected: (range: SelectedLineRange | null) => void
onLineSelectionEnd: (range: SelectedLineRange | null) => void
onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
}
export function createLineCommentController<T extends LineCommentShape>(
props: LineCommentControllerProps<T> | LineCommentControllerWithSideProps<T>,
) {
const note = createLineCommentState<string>(props.state)
const annotations =
"getSide" in props
? createLineCommentAnnotations({
comments: props.comments,
getCommentId: (comment) => comment.id,
getCommentSelection: (comment) => comment.selection,
draftRange: note.commenting,
draftKey: props.draftKey,
getSide: props.getSide,
})
: createLineCommentAnnotations({
comments: props.comments,
getCommentId: (comment) => comment.id,
getCommentSelection: (comment) => comment.selection,
draftRange: note.commenting,
draftKey: props.draftKey,
})
const { renderAnnotation } = createManagedLineCommentAnnotationRenderer<T>({
annotations,
renderComment: (comment) => ({
id: comment.id,
open: note.isOpen(comment.id),
comment: comment.comment,
selection: formatSelectedLineLabel(comment.selection),
onMouseEnter: () => note.hoverComment(comment.selection),
onClick: () =>
note.toggleComment(comment.id, comment.selection, { cancelDraft: props.cancelDraftOnCommentToggle }),
}),
renderDraft: (range) => ({
get value() {
return note.draft()
},
selection: formatSelectedLineLabel(range),
onInput: note.setDraft,
onCancel: note.cancelDraft,
onSubmit: (comment) => {
props.onSubmit({ comment, selection: cloneSelectedLineRange(range) })
note.cancelDraft()
},
onPopoverFocusOut: props.onDraftPopoverFocusOut,
}),
})
const renderHoverUtility = createLineCommentHoverRenderer({
label: props.label,
getSelectedRange: () => {
if (note.opened()) return null
return props.getHoverSelectedRange?.() ?? note.selected()
},
onOpenDraft: note.openDraft,
})
const onLineSelected = (range: SelectedLineRange | null) => {
if (!range) {
note.select(null)
note.cancelDraft()
return
}
note.select(range)
}
const onLineSelectionEnd = (range: SelectedLineRange | null) => {
if (!range) {
if (props.clearSelectionOnSelectionEndNull) note.select(null)
note.cancelDraft()
return
}
note.finishSelection(range)
}
const onLineNumberSelectionEnd = (range: SelectedLineRange | null) => {
if (!range) return
note.openDraft(range)
}
return {
note,
annotations,
renderAnnotation,
renderHoverUtility,
onLineSelected,
onLineSelectionEnd,
onLineNumberSelectionEnd,
}
}
export function createLineCommentAnnotations<T>(
props: LineCommentAnnotationsWithSideProps<T>,
): Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
export function createLineCommentAnnotations<T>(
props: LineCommentAnnotationsProps<T>,
): Accessor<LineCommentAnnotation<T>[]>
export function createLineCommentAnnotations<T>(
props: LineCommentAnnotationsProps<T> | LineCommentAnnotationsWithSideProps<T>,
) {
const line = (range: SelectedLineRange) => Math.max(range.start, range.end)
if ("getSide" in props) {
return createMemo<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>(() => {
const list = props.comments().map((comment) => {
const range = props.getCommentSelection(comment)
return {
side: props.getSide(range),
lineNumber: line(range),
metadata: {
kind: "comment",
key: `comment:${props.getCommentId(comment)}`,
comment,
} satisfies LineCommentAnnotationMeta<T>,
}
})
const range = props.draftRange()
if (!range) return list
return [
...list,
{
side: props.getSide(range),
lineNumber: line(range),
metadata: {
kind: "draft",
key: `draft:${props.draftKey()}`,
range,
} satisfies LineCommentAnnotationMeta<T>,
},
]
})
}
return createMemo<LineCommentAnnotation<T>[]>(() => {
const list = props.comments().map((comment) => {
const range = props.getCommentSelection(comment)
const entry: LineCommentAnnotation<T> = {
lineNumber: line(range),
metadata: {
kind: "comment",
key: `comment:${props.getCommentId(comment)}`,
comment,
},
}
return entry
})
const range = props.draftRange()
if (!range) return list
const draft: LineCommentAnnotation<T> = {
lineNumber: line(range),
metadata: {
kind: "draft",
key: `draft:${props.draftKey()}`,
range,
},
}
return [...list, draft]
})
}
export function createManagedLineCommentAnnotationRenderer<T>(props: {
annotations: Accessor<LineCommentAnnotation<T>[]>
renderComment: (comment: T) => CommentProps
renderDraft: (range: SelectedLineRange) => DraftProps
}) {
const renderer = createLineCommentAnnotationRenderer<T>({
renderComment: props.renderComment,
renderDraft: props.renderDraft,
})
createEffect(() => {
renderer.reconcile(props.annotations())
})
onCleanup(() => {
renderer.cleanup()
})
return {
renderAnnotation: renderer.render,
}
}
export function createLineCommentHoverRenderer(props: {
label: string
getSelectedRange: Accessor<SelectedLineRange | null>
onOpenDraft: (range: SelectedLineRange) => void
}) {
return (getHoveredLine: () => HoverCommentLine | undefined) =>
createHoverCommentUtility({
label: props.label,
getHoveredLine,
onSelect: (hovered) => {
const current = props.getSelectedRange()
if (current && lineInSelectedRange(current, hovered.lineNumber, hovered.side)) {
props.onOpenDraft(cloneSelectedLineRange(current))
return
}
const range: SelectedLineRange = {
start: hovered.lineNumber,
end: hovered.lineNumber,
}
if (hovered.side) range.side = hovered.side
props.onOpenDraft(range)
},
})
}

View File

@@ -1,9 +1,17 @@
export const lineCommentStyles = `
[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: inline-flex;
align-items: flex-start;
}
[data-component="line-comment"][data-open] {
z-index: var(--line-comment-open-z, 100);
}
@@ -21,10 +29,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;
}
@@ -46,6 +64,21 @@
padding: 12px;
}
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"] {
position: relative;
top: auto;
right: auto;
margin-left: 8px;
}
[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));
@@ -113,3 +146,50 @@
[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
}

View File

@@ -1,52 +1,121 @@
import { onMount, Show, splitProps, type JSX } from "solid-js"
import { createEffect, createSignal, onMount, Show, splitProps, type JSX } from "solid-js"
import { Button } from "./button"
import { Icon } from "./icon"
import { installLineCommentStyles } from "./line-comment-styles"
import { useI18n } from "../context/i18n"
export type LineCommentVariant = "default" | "editor"
installLineCommentStyles()
export type LineCommentVariant = "default" | "editor" | "add"
function InlineGlyph(props: { icon: "comment" | "plus" }) {
return (
<svg data-slot="line-comment-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<Show
when={props.icon === "comment"}
fallback={
<path
d="M10 5.41699V10.0003M10 10.0003V14.5837M10 10.0003H5.4165M10 10.0003H14.5832"
stroke="currentColor"
stroke-linecap="square"
/>
}
>
<path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square" />
</Show>
</svg>
)
}
export type LineCommentAnchorProps = {
id?: string
top?: number
inline?: boolean
hideButton?: boolean
open: boolean
variant?: LineCommentVariant
icon?: "comment" | "plus"
buttonLabel?: string
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
class?: string
popoverClass?: string
children: JSX.Element
children?: JSX.Element
}
export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
const hidden = () => props.top === undefined
const hidden = () => !props.inline && props.top === undefined
const variant = () => props.variant ?? "default"
const icon = () => props.icon ?? "comment"
const inlineBody = () => props.inline && props.hideButton
return (
<div
data-component="line-comment"
data-prevent-autofocus=""
data-variant={variant()}
data-comment-id={props.id}
data-open={props.open ? "" : undefined}
data-inline={props.inline ? "" : undefined}
classList={{
[props.class ?? ""]: !!props.class,
}}
style={{
top: `${props.top ?? 0}px`,
opacity: hidden() ? 0 : 1,
"pointer-events": hidden() ? "none" : "auto",
}}
style={
props.inline
? undefined
: {
top: `${props.top ?? 0}px`,
opacity: hidden() ? 0 : 1,
"pointer-events": hidden() ? "none" : "auto",
}
}
>
<button type="button" data-slot="line-comment-button" onClick={props.onClick} onMouseEnter={props.onMouseEnter}>
<Icon name="comment" size="small" />
</button>
<Show when={props.open}>
<Show
when={inlineBody()}
fallback={
<>
<button
type="button"
aria-label={props.buttonLabel}
data-slot="line-comment-button"
on:mousedown={(e) => e.stopPropagation()}
on:mouseup={(e) => e.stopPropagation()}
on:click={props.onClick as any}
on:mouseenter={props.onMouseEnter as any}
>
<Show
when={props.inline}
fallback={<Icon name={icon() === "plus" ? "plus-small" : "comment"} size="small" />}
>
<InlineGlyph icon={icon()} />
</Show>
</button>
<Show when={props.open}>
<div
data-slot="line-comment-popover"
classList={{
[props.popoverClass ?? ""]: !!props.popoverClass,
}}
on:mousedown={(e) => e.stopPropagation()}
on:focusout={props.onPopoverFocusOut as any}
>
{props.children}
</div>
</Show>
</>
}
>
<div
data-slot="line-comment-popover"
data-inline-body=""
classList={{
[props.popoverClass ?? ""]: !!props.popoverClass,
}}
onFocusOut={props.onPopoverFocusOut}
on:mousedown={(e) => e.stopPropagation()}
on:click={props.onClick as any}
on:mouseenter={props.onMouseEnter as any}
on:focusout={props.onPopoverFocusOut as any}
>
{props.children}
</div>
@@ -65,7 +134,7 @@ export const LineComment = (props: LineCommentProps) => {
const [split, rest] = splitProps(props, ["comment", "selection"])
return (
<LineCommentAnchor {...rest} variant="default">
<LineCommentAnchor {...rest} variant="default" hideButton={props.inline}>
<div data-slot="line-comment-content">
<div data-slot="line-comment-text">{split.comment}</div>
<div data-slot="line-comment-label">
@@ -78,6 +147,25 @@ export const LineComment = (props: LineCommentProps) => {
)
}
export type LineCommentAddProps = Omit<LineCommentAnchorProps, "children" | "variant" | "open" | "icon"> & {
label?: string
}
export const LineCommentAdd = (props: LineCommentAddProps) => {
const [split, rest] = splitProps(props, ["label"])
const i18n = useI18n()
return (
<LineCommentAnchor
{...rest}
open={false}
variant="add"
icon="plus"
buttonLabel={split.label ?? i18n.t("ui.lineComment.submit")}
/>
)
}
export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
value: string
selection: JSX.Element
@@ -109,11 +197,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 +217,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
})
return (
<LineCommentAnchor {...rest} open={true} variant="editor" onClick={() => focus()}>
<LineCommentAnchor {...rest} open={true} variant="editor" hideButton={props.inline} onClick={() => focus()}>
<div data-slot="line-comment-editor">
<textarea
ref={(el) => {
@@ -133,19 +226,23 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
data-slot="line-comment-textarea"
rows={split.rows ?? 3}
placeholder={split.placeholder ?? i18n.t("ui.lineComment.placeholder")}
value={split.value}
onInput={(e) => split.onInput(e.currentTarget.value)}
onKeyDown={(e) => {
value={text()}
on:input={(e) => {
const value = (e.currentTarget as HTMLTextAreaElement).value
setText(value)
split.onInput(value)
}}
on:keydown={(e) => {
const event = e as KeyboardEvent
event.stopPropagation()
if (e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
event.preventDefault()
split.onCancel()
return
}
if (e.key !== "Enter") return
if (e.shiftKey) return
e.preventDefault()
e.stopPropagation()
event.preventDefault()
submit()
}}
/>
@@ -155,12 +252,37 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
{split.selection}
{i18n.t("ui.lineComment.editorLabel.suffix")}
</div>
<Button size="small" variant="ghost" onClick={split.onCancel}>
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
</Button>
<Button size="small" variant="primary" disabled={split.value.trim().length === 0} onClick={submit}>
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
</Button>
<Show
when={!props.inline}
fallback={
<>
<button
type="button"
data-slot="line-comment-action"
data-variant="ghost"
on:click={split.onCancel as any}
>
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
</button>
<button
type="button"
data-slot="line-comment-action"
data-variant="primary"
disabled={text().trim().length === 0}
on:click={submit as any}
>
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
</button>
</>
}
>
<Button size="small" variant="ghost" onClick={split.onCancel}>
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
</Button>
<Button size="small" variant="primary" disabled={text().trim().length === 0} onClick={submit}>
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
</Button>
</Show>
</div>
</div>
</LineCommentAnchor>

View File

@@ -29,8 +29,7 @@ import {
} from "@opencode-ai/sdk/v2"
import { createStore } from "solid-js/store"
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"
@@ -1526,7 +1525,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 ?? "")
@@ -1573,7 +1572,8 @@ ToolRegistry.register({
>
<div data-component="edit-content">
<Dynamic
component={diffComponent}
component={fileComponent}
mode="diff"
before={{
name: props.metadata?.filediff?.file || props.input.filePath,
contents: props.metadata?.filediff?.before || props.input.oldString,
@@ -1597,7 +1597,7 @@ ToolRegistry.register({
name: "write",
render(props) {
const i18n = useI18n()
const codeComponent = useCodeComponent()
const fileComponent = useFileComponent()
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
const path = createMemo(() => props.input.filePath || "")
const filename = () => getFilename(props.input.filePath ?? "")
@@ -1635,7 +1635,8 @@ ToolRegistry.register({
<ToolFileAccordion path={path()}>
<div data-component="write-content">
<Dynamic
component={codeComponent}
component={fileComponent}
mode="text"
file={{
name: props.input.filePath,
contents: props.input.content,
@@ -1669,7 +1670,7 @@ ToolRegistry.register({
name: "apply_patch",
render(props) {
const i18n = useI18n()
const diffComponent = useDiffComponent()
const fileComponent = useFileComponent()
const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
const pending = createMemo(() => props.status === "pending" || props.status === "running")
const single = createMemo(() => {
@@ -1777,7 +1778,8 @@ ToolRegistry.register({
<Show when={visible()}>
<div data-component="apply-patch-file-diff">
<Dynamic
component={diffComponent}
component={fileComponent}
mode="diff"
before={{ name: file.filePath, contents: file.before }}
after={{ name: file.movePath ?? file.filePath, contents: file.after }}
/>
@@ -1854,7 +1856,8 @@ ToolRegistry.register({
>
<div data-component="apply-patch-file-diff">
<Dynamic
component={diffComponent}
component={fileComponent}
mode="diff"
before={{ name: file().filePath, contents: file().before }}
after={{ name: file().movePath ?? file().filePath, contents: file().after }}
/>

View File

@@ -4,20 +4,22 @@ 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 { StickyAccordionHeader } from "./sticky-accordion-header"
import { Tooltip } from "./tooltip"
import { ScrollView } from "./scroll-view"
import { useDiffComponent } from "../context/diff"
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
@@ -64,68 +66,6 @@ export interface SessionReviewProps {
readFile?: (path: string) => Promise<FileContent | undefined>
}
const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"])
const audioExtensions = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"])
function normalizeMimeType(type: string | undefined): string | undefined {
if (!type) return
const mime = type.split(";", 1)[0]?.trim().toLowerCase()
if (!mime) return
if (mime === "audio/x-aac") return "audio/aac"
if (mime === "audio/x-m4a") return "audio/mp4"
return mime
}
function getExtension(file: string): string {
const idx = file.lastIndexOf(".")
if (idx === -1) return ""
return file.slice(idx + 1).toLowerCase()
}
function isImageFile(file: string): boolean {
return imageExtensions.has(getExtension(file))
}
function isAudioFile(file: string): boolean {
return audioExtensions.has(getExtension(file))
}
function dataUrl(content: FileContent | undefined): string | undefined {
if (!content) return
if (content.encoding !== "base64") return
const mime = normalizeMimeType(content.mimeType)
if (!mime) return
if (!mime.startsWith("image/") && !mime.startsWith("audio/")) return
return `data:${mime};base64,${content.content}`
}
function dataUrlFromValue(value: unknown): string | undefined {
if (typeof value === "string") {
if (value.startsWith("data:image/")) return value
if (value.startsWith("data:audio/x-aac;")) return value.replace("data:audio/x-aac;", "data:audio/aac;")
if (value.startsWith("data:audio/x-m4a;")) return value.replace("data:audio/x-m4a;", "data:audio/mp4;")
if (value.startsWith("data:audio/")) return value
return
}
if (!value || typeof value !== "object") return
const content = (value as { content?: unknown }).content
const encoding = (value as { encoding?: unknown }).encoding
const mimeType = (value as { mimeType?: unknown }).mimeType
if (typeof content !== "string") return
if (encoding !== "base64") return
if (typeof mimeType !== "string") return
const mime = normalizeMimeType(mimeType)
if (!mime) return
if (!mime.startsWith("image/") && !mime.startsWith("audio/")) return
return `data:${mime};base64,${content}`
}
function diffId(file: string): string | undefined {
const sum = checksum(file)
if (!sum) return
@@ -137,48 +77,11 @@ 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 focusToken = 0
const i18n = useI18n()
const diffComponent = useDiffComponent()
const fileComponent = useFileComponent()
const anchors = new Map<string, HTMLElement>()
const [store, setStore] = createStore({
open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
@@ -205,13 +108,6 @@ 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 selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
const selectionPreview = (diff: FileDiff, range: SelectedLineRange) => {
@@ -219,11 +115,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 +128,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 +141,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))
@@ -340,28 +232,18 @@ export const SessionReview = (props: SessionReviewProps) => {
const beforeText = () => (typeof item().before === "string" ? item().before : "")
const afterText = () => (typeof item().after === "string" ? item().after : "")
const changedLines = () => item().additions + item().deletions
const mediaKind = createMemo(() => mediaKindFromPath(file))
const tooLarge = createMemo(() => {
if (!expanded()) return false
if (force()) return false
if (isImageFile(file)) return false
if (mediaKind()) return false
return changedLines() > MAX_DIFF_CHANGED_LINES
})
const isAdded = () => item().status === "added" || (beforeText().length === 0 && afterText().length > 0)
const isDeleted = () =>
item().status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
const isImage = () => isImageFile(file)
const isAudio = () => isAudioFile(file)
const diffImageSrc = createMemo(() => dataUrlFromValue(item().after) ?? dataUrlFromValue(item().before))
const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc())
const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
const diffAudioSrc = createMemo(() => dataUrlFromValue(item().after) ?? dataUrlFromValue(item().before))
const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc())
const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined)
const selectedLines = createMemo(() => {
const current = selection()
@@ -375,164 +257,46 @@ export const SessionReview = (props: SessionReviewProps) => {
return current.range
})
const [draft, setDraft] = createSignal("")
const [positions, setPositions] = createSignal<Record<string, number>>({})
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
const getRoot = () => {
const el = wrapper
if (!el) return
const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return
return host.shadowRoot ?? undefined
}
const updateAnchors = () => {
const el = wrapper
if (!el) return
const root = getRoot()
if (!root) return
const next: Record<string, number> = {}
for (const item of comments()) {
const marker = findMarker(root, item.selection)
if (!marker) continue
next[item.id] = markerTop(el, marker)
}
setPositions(next)
const range = draftRange()
if (!range) {
setDraftTop(undefined)
return
}
const marker = findMarker(root, range)
if (!marker) {
setDraftTop(undefined)
return
}
setDraftTop(markerTop(el, marker))
}
const scheduleAnchors = () => {
requestAnimationFrame(updateAnchors)
}
createEffect(() => {
if (!isImage()) return
const src = diffImageSrc()
setImageSrc(src)
setImageStatus("idle")
})
createEffect(() => {
if (!isAudio()) return
const src = diffAudioSrc()
setAudioSrc(src)
setAudioStatus("idle")
setAudioMime(undefined)
})
createEffect(() => {
comments()
scheduleAnchors()
})
createEffect(() => {
const range = draftRange()
if (!range) return
setDraft("")
scheduleAnchors()
})
createEffect(() => {
if (!open().includes(file)) return
if (!isImage()) return
if (imageSrc()) return
if (imageStatus() !== "idle") return
if (isDeleted()) return
const reader = props.readFile
if (!reader) return
setImageStatus("loading")
reader(file)
.then((result) => {
const src = dataUrl(result)
if (!src) {
setImageStatus("error")
return
}
setImageSrc(src)
setImageStatus("idle")
})
.catch(() => {
setImageStatus("error")
const commentsUi = createLineCommentController<SessionReviewComment>({
comments,
label: i18n.t("ui.lineComment.submit"),
draftKey: () => file,
state: {
opened: () => {
const current = opened()
if (!current || current.file !== file) return null
return current.id
},
setOpened: (id) => setOpened(id ? { file, id } : null),
selected: selectedLines,
setSelected: (range) => setSelection(range ? { file, range } : null),
commenting: draftRange,
setCommenting: (range) => setCommenting(range ? { file, range } : null),
},
getSide: selectionSide,
clearSelectionOnSelectionEndNull: false,
onSubmit: ({ comment, selection }) => {
props.onLineComment?.({
file,
selection,
comment,
preview: selectionPreview(item(), selection),
})
},
})
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)
})
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 +349,7 @@ export const SessionReview = (props: SessionReviewProps) => {
{i18n.t("ui.sessionReview.change.removed")}
</span>
</Match>
<Match when={isImage()}>
<Match when={!!mediaKind()}>
<span data-slot="session-review-change" data-type="modified">
{i18n.t("ui.sessionReview.change.modified")}
</span>
@@ -607,33 +371,11 @@ export const SessionReview = (props: SessionReviewProps) => {
ref={(el) => {
wrapper = el
anchors.set(file, el)
scheduleAnchors()
}}
>
<Show when={expanded()}>
<Switch>
<Match when={isImage() && imageSrc()}>
<div data-slot="session-review-image-container">
<img data-slot="session-review-image" src={imageSrc()} alt={file} />
</div>
</Match>
<Match when={isImage() && isDeleted()}>
<div data-slot="session-review-image-container" data-removed>
<span data-slot="session-review-image-placeholder">
{i18n.t("ui.sessionReview.change.removed")}
</span>
</div>
</Match>
<Match when={isImage() && !imageSrc()}>
<div data-slot="session-review-image-container">
<span data-slot="session-review-image-placeholder">
{imageStatus() === "loading"
? i18n.t("ui.sessionReview.image.loading")
: i18n.t("ui.sessionReview.image.placeholder")}
</span>
</div>
</Match>
<Match when={!isImage() && tooLarge()}>
<Match when={tooLarge()}>
<div data-slot="session-review-large-diff">
<div data-slot="session-review-large-diff-title">
{i18n.t("ui.sessionReview.largeDiff.title")}
@@ -651,18 +393,23 @@ export const SessionReview = (props: SessionReviewProps) => {
</div>
</div>
</Match>
<Match when={!isImage()}>
<Match when={true}>
<Dynamic
component={diffComponent}
component={fileComponent}
mode="diff"
preloadedDiff={item().preloaded}
diffStyle={diffStyle()}
onRendered={() => {
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()}
before={{
@@ -673,53 +420,53 @@ 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,
renderImage: (args: { src: string }) => (
<div data-slot="session-review-image-container">
<img data-slot="session-review-image" src={args.src} alt={file} />
</div>
),
renderRemoved: (args: { kind: "image" | "audio" }) =>
args.kind === "image" ? (
<div data-slot="session-review-image-container" data-removed>
<span data-slot="session-review-image-placeholder">
{i18n.t("ui.sessionReview.change.removed")}
</span>
</div>
) : undefined,
renderPlaceholder: (args: { kind: "image" | "audio" }) =>
args.kind === "image" ? (
<div data-slot="session-review-image-container">
<span data-slot="session-review-image-placeholder">
{i18n.t("ui.sessionReview.image.placeholder")}
</span>
</div>
) : undefined,
renderLoading: (args: { kind: "image" | "audio" }) =>
args.kind === "image" ? (
<div data-slot="session-review-image-container">
<span data-slot="session-review-image-placeholder">
{i18n.t("ui.sessionReview.image.loading")}
</span>
</div>
) : undefined,
renderError: (args: { kind: "image" | "audio" | "svg" }) =>
args.kind === "image" ? (
<div data-slot="session-review-image-container">
<span data-slot="session-review-image-placeholder">
{i18n.t("ui.sessionReview.image.placeholder")}
</span>
</div>
) : undefined,
}}
/>
</Match>
</Switch>
<For each={comments()}>
{(comment) => (
<LineComment
id={comment.id}
top={positions()[comment.id]}
onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })}
onClick={() => {
if (isCommentOpen(comment)) {
setOpened(null)
return
}
openComment(comment)
}}
open={isCommentOpen(comment)}
comment={comment.comment}
selection={selectionLabel(comment.selection)}
/>
)}
</For>
<Show when={draftRange()}>
{(range) => (
<Show when={draftTop() !== undefined}>
<LineCommentEditor
top={draftTop()}
value={draft()}
selection={selectionLabel(range())}
onInput={setDraft}
onCancel={() => setCommenting(null)}
onSubmit={(comment) => {
props.onLineComment?.({
file,
selection: range(),
comment,
preview: selectionPreview(item(), range()),
})
setCommenting(null)
}}
/>
</Show>
)}
</Show>
</Show>
</div>
</Accordion.Content>

View File

@@ -1,6 +1,6 @@
import { AssistantMessage, type FileDiff, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client"
import { useData } from "../context"
import { useDiffComponent } from "../context/diff"
import { useFileComponent } from "../context/file"
import { Binary } from "@opencode-ai/util/binary"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
@@ -152,7 +152,7 @@ export function SessionTurn(
) {
const data = useData()
const i18n = useI18n()
const diffComponent = useDiffComponent()
const fileComponent = useFileComponent()
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
@@ -465,7 +465,8 @@ export function SessionTurn(
<Show when={visible()}>
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic
component={diffComponent}
component={fileComponent}
mode="diff"
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
/>

View File

@@ -1,10 +0,0 @@
import type { ValidComponent } from "solid-js"
import { createSimpleContext } from "./helper"
const ctx = createSimpleContext<ValidComponent, { component: ValidComponent }>({
name: "DiffComponent",
init: (props) => props.component,
})
export const DiffComponentProvider = ctx.provider
export const useDiffComponent = ctx.use

View File

@@ -2,9 +2,9 @@ import type { ValidComponent } from "solid-js"
import { createSimpleContext } from "./helper"
const ctx = createSimpleContext<ValidComponent, { component: ValidComponent }>({
name: "CodeComponent",
name: "FileComponent",
init: (props) => props.component,
})
export const CodeComponentProvider = ctx.provider
export const useCodeComponent = ctx.use
export const FileComponentProvider = ctx.provider
export const useFileComponent = ctx.use

View File

@@ -1,5 +1,5 @@
export * from "./helper"
export * from "./data"
export * from "./diff"
export * from "./file"
export * from "./dialog"
export * from "./i18n"

View File

@@ -0,0 +1,73 @@
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(--syntax-diff-add)"
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 = "22px"
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
}

View File

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

View File

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

View File

@@ -0,0 +1,474 @@
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
}
const hosts = new Set<FindHost>()
let target: FindHost | undefined
let current: FindHost | undefined
let installed = false
function isEditable(node: unknown): boolean {
if (!(node instanceof HTMLElement)) return false
if (node.closest("[data-prevent-autofocus]")) return true
if (node.isContentEditable) return true
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName)
}
function hostForNode(node: unknown) {
if (!(node instanceof Node)) return
for (const host of hosts) {
const el = host.element()
if (el && el.isConnected && el.contains(node)) return host
}
}
function installShortcuts() {
if (installed) return
if (typeof window === "undefined") return
installed = true
window.addEventListener(
"keydown",
(event) => {
if (event.defaultPrevented) return
if (isEditable(event.target)) return
const mod = event.metaKey || event.ctrlKey
if (!mod) return
const key = event.key.toLowerCase()
if (key === "g") {
const host = current
if (!host || !host.isOpen()) return
event.preventDefault()
event.stopPropagation()
host.next(event.shiftKey ? -1 : 1)
return
}
if (key !== "f") return
const active = current
if (active && active.isOpen()) {
event.preventDefault()
event.stopPropagation()
active.open()
return
}
const host = hostForNode(document.activeElement) ?? hostForNode(event.target) ?? target ?? Array.from(hosts)[0]
if (!host) return
event.preventDefault()
event.stopPropagation()
host.open()
},
{ capture: true },
)
}
function clearHighlightFind() {
const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights
if (!api) return
api.delete("opencode-find")
api.delete("opencode-find-current")
}
function supportsHighlights() {
const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown }
return typeof g.Highlight === "function" && g.CSS?.highlights != null
}
function scrollParent(el: HTMLElement): HTMLElement | undefined {
let parent = el.parentElement
while (parent) {
const style = getComputedStyle(parent)
if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
parent = parent.parentElement
}
}
type CreateFileFindOptions = {
wrapper: () => HTMLElement | undefined
overlay: () => HTMLDivElement | undefined
getRoot: () => ShadowRoot | undefined
}
export function createFileFind(opts: CreateFileFindOptions) {
let input: HTMLInputElement | undefined
let overlayFrame: number | undefined
let overlayScroll: HTMLElement[] = []
let mode: "highlights" | "overlay" = "overlay"
let hits: Range[] = []
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]
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: 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 at = hay.indexOf(needle)
if (at === -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 = (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)
at = hay.indexOf(needle, at + value.length)
}
}
return ranges
}
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[], 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]
if (active) api.set("opencode-find-current", new Highlight(active))
const rest = ranges.filter((_, i) => i !== currentIndex)
if (rest.length > 0) api.set("opencode-find", new Highlight(...rest))
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]
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)
clearFind()
if (current === host) current = undefined
}
const next = (dir: 1 | -1) => {
if (!open()) return
const total = count()
if (total <= 0) return
const currentIndex = (index() + dir + total) % total
setIndex(currentIndex)
const active = hits[currentIndex]
if (!active) return
if (mode === "highlights") {
if (!setHighlights(hits, currentIndex)) {
mode = "overlay"
apply({ reset: true, scroll: true })
return
}
scrollToRange(active)
return
}
clearHighlightFind()
syncOverlayScroll()
scrollToRange(active)
scheduleOverlay()
}
const host: FindHost = {
element: opts.wrapper,
isOpen: () => open(),
next,
open: () => {
if (current && current !== host) current.close()
current = host
target = host
if (!open()) setOpen(true)
requestAnimationFrame(() => {
apply({ scroll: true })
input?.focus()
input?.select()
})
},
close,
}
onMount(() => {
mode = supportsHighlights() ? "highlights" : "overlay"
installShortcuts()
hosts.add(host)
if (!target) target = host
onCleanup(() => {
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) => {
setQuery(value)
setIndex(0)
apply({ reset: true, scroll: true })
},
close,
next,
refresh: (args?: { reset?: boolean; scroll?: boolean }) => apply(args),
onPointerDown: () => {
target = host
opts.wrapper()?.focus({ preventScroll: true })
},
onFocus: () => {
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)
},
}
}

View File

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

View File

@@ -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,
}
}

View File

@@ -1,5 +1,6 @@
import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs"
import { ComponentProps } from "solid-js"
import { lineCommentStyles } from "../components/line-comment-styles"
export type DiffProps<T = {}> = FileDiffOptions<T> & {
before: FileContents
@@ -7,13 +8,15 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
annotations?: DiffLineAnnotation<T>[]
selectedLines?: SelectedLineRange | null
commentedLines?: SelectedLineRange[]
onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
onRendered?: () => void
class?: string
classList?: ComponentProps<"div">["classList"]
}
const unsafeCSS = `
[data-diff] {
[data-diff],
[data-file] {
--diffs-bg: light-dark(var(--diffs-light-bg), var(--diffs-dark-bg));
--diffs-bg-buffer: var(--diffs-bg-buffer-override, light-dark( color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer))));
--diffs-bg-hover: var(--diffs-bg-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 97%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-mixer))));
@@ -44,7 +47,8 @@ const unsafeCSS = `
--diffs-bg-selection-text: rgb(from var(--surface-warning-strong) r g b / 0.2);
}
:host([data-color-scheme='dark']) [data-diff] {
:host([data-color-scheme='dark']) [data-diff],
:host([data-color-scheme='dark']) [data-file] {
--diffs-selection-number-fg: #fdfbfb;
--diffs-bg-selection: var(--diffs-bg-selection-override, rgb(from var(--solaris-dark-6) r g b / 0.65));
--diffs-bg-selection-number: var(
@@ -53,7 +57,8 @@ const unsafeCSS = `
);
}
[data-diff] ::selection {
[data-diff] ::selection,
[data-file] ::selection {
background-color: var(--diffs-bg-selection-text);
}
@@ -69,25 +74,48 @@ const unsafeCSS = `
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
}
[data-file] [data-line][data-comment-selected]:not([data-selected-line]) {
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
}
[data-diff] [data-column-number][data-comment-selected]:not([data-selected-line]) {
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
color: var(--diffs-selection-number-fg);
}
[data-file] [data-column-number][data-comment-selected]:not([data-selected-line]) {
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
color: var(--diffs-selection-number-fg);
}
[data-diff] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] {
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
}
[data-file] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] {
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
}
[data-diff] [data-line][data-selected-line] {
background-color: var(--diffs-bg-selection);
box-shadow: inset 2px 0 0 var(--diffs-selection-border);
}
[data-file] [data-line][data-selected-line] {
background-color: var(--diffs-bg-selection);
box-shadow: inset 2px 0 0 var(--diffs-selection-border);
}
[data-diff] [data-column-number][data-selected-line] {
background-color: var(--diffs-bg-selection-number);
color: var(--diffs-selection-number-fg);
}
[data-file] [data-column-number][data-selected-line] {
background-color: var(--diffs-bg-selection-number);
color: var(--diffs-selection-number-fg);
}
[data-diff] [data-column-number][data-line-type='context'][data-selected-line],
[data-diff] [data-column-number][data-line-type='context-expanded'][data-selected-line],
[data-diff] [data-column-number][data-line-type='change-addition'][data-selected-line],
@@ -125,7 +153,11 @@ const unsafeCSS = `
overflow-x: auto !important;
overflow-y: hidden !important;
}
}`
}
${lineCommentStyles}
`
export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) {
return {

View File

@@ -0,0 +1,110 @@
import type { FileContent } from "@opencode-ai/sdk/v2"
export type MediaKind = "image" | "audio" | "svg"
const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"])
const audioExtensions = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"])
type MediaValue = unknown
function mediaRecord(value: unknown) {
if (!value || typeof value !== "object") return
return value as Partial<FileContent> & {
content?: unknown
encoding?: unknown
mimeType?: unknown
type?: unknown
}
}
export function normalizeMimeType(type: string | undefined) {
if (!type) return
const mime = type.split(";", 1)[0]?.trim().toLowerCase()
if (!mime) return
if (mime === "audio/x-aac") return "audio/aac"
if (mime === "audio/x-m4a") return "audio/mp4"
return mime
}
export function fileExtension(path: string | undefined) {
if (!path) return ""
const idx = path.lastIndexOf(".")
if (idx === -1) return ""
return path.slice(idx + 1).toLowerCase()
}
export function mediaKindFromPath(path: string | undefined): MediaKind | undefined {
const ext = fileExtension(path)
if (ext === "svg") return "svg"
if (imageExtensions.has(ext)) return "image"
if (audioExtensions.has(ext)) return "audio"
}
export function isBinaryContent(value: MediaValue) {
return mediaRecord(value)?.type === "binary"
}
function validDataUrl(value: string, kind: MediaKind) {
if (kind === "svg") return value.startsWith("data:image/svg+xml") ? value : undefined
if (kind === "image") return value.startsWith("data:image/") ? value : undefined
if (value.startsWith("data:audio/x-aac;")) return value.replace("data:audio/x-aac;", "data:audio/aac;")
if (value.startsWith("data:audio/x-m4a;")) return value.replace("data:audio/x-m4a;", "data:audio/mp4;")
if (value.startsWith("data:audio/")) return value
}
export function dataUrlFromMediaValue(value: MediaValue, kind: MediaKind) {
if (!value) return
if (typeof value === "string") {
return validDataUrl(value, kind)
}
const record = mediaRecord(value)
if (!record) return
if (typeof record.content !== "string") return
const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined)
if (!mime) return
if (kind === "svg") {
if (mime !== "image/svg+xml") return
if (record.encoding === "base64") return `data:image/svg+xml;base64,${record.content}`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(record.content)}`
}
if (kind === "image" && !mime.startsWith("image/")) return
if (kind === "audio" && !mime.startsWith("audio/")) return
if (record.encoding !== "base64") return
return `data:${mime};base64,${record.content}`
}
function decodeBase64Utf8(value: string) {
if (typeof atob !== "function") return
try {
const raw = atob(value)
const bytes = Uint8Array.from(raw, (x) => x.charCodeAt(0))
if (typeof TextDecoder === "function") return new TextDecoder().decode(bytes)
return raw
} catch {}
}
export function svgTextFromValue(value: MediaValue) {
const record = mediaRecord(value)
if (!record) return
if (typeof record.content !== "string") return
const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined)
if (mime !== "image/svg+xml") return
if (record.encoding === "base64") return decodeBase64Utf8(record.content)
return record.content
}
export function hasMediaValue(value: MediaValue) {
if (typeof value === "string") return value.length > 0
const record = mediaRecord(value)
if (!record) return false
return typeof record.content === "string" && record.content.length > 0
}

View File

@@ -0,0 +1,129 @@
import { type SelectedLineRange } from "@pierre/diffs"
type PointerMode = "none" | "text" | "numbers"
type Side = SelectedLineRange["side"]
type LineSpan = Pick<SelectedLineRange, "start" | "end">
export function formatSelectedLineLabel(range: LineSpan) {
const start = Math.min(range.start, range.end)
const end = Math.max(range.start, range.end)
if (start === end) return `line ${start}`
return `lines ${start}-${end}`
}
export function previewSelectedLines(source: string, range: LineSpan) {
const start = Math.max(1, Math.min(range.start, range.end))
const end = Math.max(range.start, range.end)
const lines = source.split("\n").slice(start - 1, end)
if (lines.length === 0) return
return lines.slice(0, 2).join("\n")
}
export function cloneSelectedLineRange(range: SelectedLineRange): SelectedLineRange {
const next: SelectedLineRange = {
start: range.start,
end: range.end,
}
if (range.side) next.side = range.side
if (range.endSide) next.endSide = range.endSide
return next
}
export function lineInSelectedRange(range: SelectedLineRange | null | undefined, line: number, side?: Side) {
if (!range) return false
const start = Math.min(range.start, range.end)
const end = Math.max(range.start, range.end)
if (line < start || line > end) return false
if (!side) return true
const first = range.side
const last = range.endSide ?? first
if (!first && !last) return true
if (!first || !last) return (first ?? last) === side
if (first === last) return first === side
if (line === start) return first === side
if (line === end) return last === side
return true
}
export function isSingleLineSelection(range: SelectedLineRange | null) {
if (!range) return false
return range.start === range.end && (range.endSide == null || range.endSide === range.side)
}
export function toRange(source: Range | StaticRange): Range {
if (source instanceof Range) return source
const range = new Range()
range.setStart(source.startContainer, source.startOffset)
range.setEnd(source.endContainer, source.endOffset)
return range
}
export function restoreShadowTextSelection(root: ShadowRoot | undefined, range: Range | undefined) {
if (!root || !range) return
requestAnimationFrame(() => {
const selection =
(root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
if (!selection) return
try {
selection.removeAllRanges()
selection.addRange(range)
} catch {}
})
}
export function createLineNumberSelectionBridge() {
let mode: PointerMode = "none"
let line: number | undefined
let moved = false
let pending = false
const clear = () => {
mode = "none"
line = undefined
moved = false
}
return {
begin(numberColumn: boolean, next: number | undefined) {
if (!numberColumn) {
mode = "text"
return
}
mode = "numbers"
line = next
moved = false
},
track(buttons: number, next: number | undefined) {
if (mode !== "numbers") return false
if ((buttons & 1) === 0) {
clear()
return true
}
if (next !== undefined && line !== undefined && next !== line) moved = true
return true
},
finish() {
const current = mode
pending = current === "numbers" && moved
clear()
return current
},
consume(range: SelectedLineRange | null) {
const result = pending && !isSingleLineSelection(range)
pending = false
return result
},
reset() {
pending = false
clear()
},
}
}

View File

@@ -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);

View File

@@ -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.