feat(app): better diff/code comments (#14621)

Co-authored-by: adamelmore <2363879+adamdottv@users.noreply.github.com>
Co-authored-by: David Hill <iamdavidhill@gmail.com>
This commit is contained in:
Adam
2026-02-26 18:23:04 -06:00
committed by GitHub
parent 9a6bfeb782
commit fc52e4b2d3
70 changed files with 6454 additions and 3151 deletions

View File

@@ -43,7 +43,7 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession
await tab.click() await tab.click()
await expect(tab).toHaveAttribute("aria-selected", "true") await expect(tab).toHaveAttribute("aria-selected", "true")
const code = page.locator('[data-component="code"]').first() const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(code).toBeVisible() await expect(viewer).toBeVisible()
await expect(code).toContainText("export default function FileTree") await expect(viewer).toContainText("export default function FileTree")
}) })

View File

@@ -1,5 +1,6 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { modKey } from "../utils"
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => { test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
await gotoSession() await gotoSession()
@@ -43,7 +44,60 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
await expect(tab).toBeVisible() await expect(tab).toBeVisible()
await tab.click() await tab.click()
const code = page.locator('[data-component="code"]').first() const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(code).toBeVisible() await expect(viewer).toBeVisible()
await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible() await expect(viewer.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
})
test("cmd+f opens text viewer search while prompt is focused", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
let index = -1
await expect
.poll(
async () => {
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
return index >= 0
},
{ timeout: 30_000 },
)
.toBe(true)
const item = items.nth(index)
await expect(item).toBeVisible()
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" })
await expect(tab).toBeVisible()
await tab.click()
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
await expect(viewer).toBeVisible()
await page.locator(promptSelector).click()
await page.keyboard.press(`${modKey}+f`)
const findInput = page.getByPlaceholder("Find")
await expect(findInput).toBeVisible()
await expect(findInput).toBeFocused()
}) })

View File

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

View File

@@ -3,7 +3,7 @@ import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element" import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { useFile } from "@/context/file" import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
import { import {
ContentPart, ContentPart,
DEFAULT_PROMPT, DEFAULT_PROMPT,
@@ -43,6 +43,9 @@ import {
canNavigateHistoryAtCursor, canNavigateHistoryAtCursor,
navigatePromptHistory, navigatePromptHistory,
prependHistoryEntry, prependHistoryEntry,
type PromptHistoryComment,
type PromptHistoryEntry,
type PromptHistoryStoredEntry,
promptLength, promptLength,
} from "./prompt-input/history" } from "./prompt-input/history"
import { createPromptSubmit } from "./prompt-input/submit" import { createPromptSubmit } from "./prompt-input/submit"
@@ -170,12 +173,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const focus = { file: item.path, id: item.commentID } const focus = { file: item.path, id: item.commentID }
comments.setActive(focus) comments.setActive(focus)
const queueCommentFocus = (attempts = 6) => {
const schedule = (left: number) => {
requestAnimationFrame(() => {
comments.setFocus({ ...focus })
if (left <= 0) return
requestAnimationFrame(() => {
const current = comments.focus()
if (!current) return
if (current.file !== focus.file || current.id !== focus.id) return
schedule(left - 1)
})
})
}
schedule(attempts)
}
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
if (wantsReview) { if (wantsReview) {
if (!view().reviewPanel.opened()) view().reviewPanel.open() if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("changes") layout.fileTree.setTab("changes")
tabs().setActive("review") tabs().setActive("review")
requestAnimationFrame(() => comments.setFocus(focus)) queueCommentFocus()
return return
} }
@@ -183,8 +203,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
layout.fileTree.setTab("all") layout.fileTree.setTab("all")
const tab = files.tab(item.path) const tab = files.tab(item.path)
tabs().open(tab) tabs().open(tab)
files.load(item.path) tabs().setActive(tab)
requestAnimationFrame(() => comments.setFocus(focus)) Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus())
} }
const recent = createMemo(() => { const recent = createMemo(() => {
@@ -219,7 +239,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
popover: "at" | "slash" | null popover: "at" | "slash" | null
historyIndex: number historyIndex: number
savedPrompt: Prompt | null savedPrompt: PromptHistoryEntry | null
placeholder: number placeholder: number
draggingType: "image" | "@mention" | null draggingType: "image" | "@mention" | null
mode: "normal" | "shell" mode: "normal" | "shell"
@@ -227,7 +247,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}>({ }>({
popover: null, popover: null,
historyIndex: -1, historyIndex: -1,
savedPrompt: null, savedPrompt: null as PromptHistoryEntry | null,
placeholder: Math.floor(Math.random() * EXAMPLES.length), placeholder: Math.floor(Math.random() * EXAMPLES.length),
draggingType: null, draggingType: null,
mode: "normal", mode: "normal",
@@ -256,7 +276,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [history, setHistory] = persisted( const [history, setHistory] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]), Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<{ createStore<{
entries: Prompt[] entries: PromptHistoryStoredEntry[]
}>({ }>({
entries: [], entries: [],
}), }),
@@ -264,7 +284,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [shellHistory, setShellHistory] = persisted( const [shellHistory, setShellHistory] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<{ createStore<{
entries: Prompt[] entries: PromptHistoryStoredEntry[]
}>({ }>({
entries: [], entries: [],
}), }),
@@ -282,9 +302,66 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}), }),
) )
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
return prompt.context.items().flatMap((item) => {
if (item.type !== "file") return []
const comment = item.comment?.trim()
if (!comment) return []
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
const nextSelection =
selection ??
(item.selection
? ({
start: item.selection.startLine,
end: item.selection.endLine,
} satisfies SelectedLineRange)
: undefined)
if (!nextSelection) return []
return [
{
id: item.commentID ?? item.key,
path: item.path,
selection: { ...nextSelection },
comment,
time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
origin: item.commentOrigin,
preview: item.preview,
} satisfies PromptHistoryComment,
]
})
}
const applyHistoryComments = (items: PromptHistoryComment[]) => {
comments.replace(
items.map((item) => ({
id: item.id,
file: item.path,
selection: { ...item.selection },
comment: item.comment,
time: item.time,
})),
)
prompt.context.replaceComments(
items.map((item) => ({
type: "file" as const,
path: item.path,
selection: selectionFromLines(item.selection),
comment: item.comment,
commentID: item.id,
commentOrigin: item.origin,
preview: item.preview,
})),
)
}
const applyHistoryPrompt = (entry: PromptHistoryEntry, position: "start" | "end") => {
const p = entry.prompt
const length = position === "start" ? 0 : promptLength(p) const length = position === "start" ? 0 : promptLength(p)
setStore("applyingHistory", true) setStore("applyingHistory", true)
applyHistoryComments(entry.comments)
prompt.set(p, length) prompt.set(p, length)
requestAnimationFrame(() => { requestAnimationFrame(() => {
editorRef.focus() editorRef.focus()
@@ -846,7 +923,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
const currentHistory = mode === "shell" ? shellHistory : history const currentHistory = mode === "shell" ? shellHistory : history
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
const next = prependHistoryEntry(currentHistory.entries, prompt) const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments())
if (next === currentHistory.entries) return if (next === currentHistory.entries) return
setCurrentHistory("entries", next) setCurrentHistory("entries", next)
} }
@@ -857,12 +934,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
entries: store.mode === "shell" ? shellHistory.entries : history.entries, entries: store.mode === "shell" ? shellHistory.entries : history.entries,
historyIndex: store.historyIndex, historyIndex: store.historyIndex,
currentPrompt: prompt.current(), currentPrompt: prompt.current(),
currentComments: historyComments(),
savedPrompt: store.savedPrompt, savedPrompt: store.savedPrompt,
}) })
if (!result.handled) return false if (!result.handled) return false
setStore("historyIndex", result.historyIndex) setStore("historyIndex", result.historyIndex)
setStore("savedPrompt", result.savedPrompt) setStore("savedPrompt", result.savedPrompt)
applyHistoryPrompt(result.prompt, result.cursor) applyHistoryPrompt(result.entry, result.cursor)
return true return true
} }

View File

@@ -35,6 +35,15 @@ describe("buildRequestParts", () => {
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")), result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
).toBe(true) ).toBe(true)
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true) expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
expect(
result.requestParts.some(
(part) =>
part.type === "text" &&
part.synthetic &&
part.metadata?.opencodeComment &&
(part.metadata.opencodeComment as { comment?: string }).comment === "check this",
),
).toBe(true)
expect(result.optimisticParts).toHaveLength(result.requestParts.length) expect(result.optimisticParts).toHaveLength(result.requestParts.length)
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true) expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)

View File

@@ -4,6 +4,7 @@ import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path" import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string } type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
@@ -41,18 +42,6 @@ const fileQuery = (selection: FileSelection | undefined) =>
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file" const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent" const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
const range =
start === undefined || end === undefined
? "this file"
: start === end
? `line ${start}`
: `lines ${start} through ${end}`
return `The user made the following comment regarding ${range} of ${path}: ${comment}`
}
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => { const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
if (part.type === "text") { if (part.type === "text") {
return { return {
@@ -153,8 +142,15 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
{ {
id: Identifier.ascending("part"), id: Identifier.ascending("part"),
type: "text", type: "text",
text: commentNote(item.path, item.selection, comment), text: formatCommentNote({ path: item.path, selection: item.selection, comment }),
synthetic: true, synthetic: true,
metadata: createCommentMetadata({
path: item.path,
selection: item.selection,
comment,
preview: item.preview,
origin: item.commentOrigin,
}),
} satisfies PromptRequestPart, } satisfies PromptRequestPart,
filePart, filePart,
] ]

View File

@@ -3,25 +3,42 @@ import type { Prompt } from "@/context/prompt"
import { import {
canNavigateHistoryAtCursor, canNavigateHistoryAtCursor,
clonePromptParts, clonePromptParts,
normalizePromptHistoryEntry,
navigatePromptHistory, navigatePromptHistory,
prependHistoryEntry, prependHistoryEntry,
promptLength, promptLength,
type PromptHistoryComment,
} from "./history" } from "./history"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }] const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
const comment = (id: string, value = "note"): PromptHistoryComment => ({
id,
path: "src/a.ts",
selection: { start: 2, end: 4 },
comment: value,
time: 1,
origin: "review",
preview: "const a = 1",
})
describe("prompt-input history", () => { describe("prompt-input history", () => {
test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => { test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
const first = prependHistoryEntry([], DEFAULT_PROMPT) const first = prependHistoryEntry([], DEFAULT_PROMPT)
expect(first).toEqual([]) expect(first).toEqual([])
const commentsOnly = prependHistoryEntry([], DEFAULT_PROMPT, [comment("c1")])
expect(commentsOnly).toHaveLength(1)
const withOne = prependHistoryEntry([], text("hello")) const withOne = prependHistoryEntry([], text("hello"))
expect(withOne).toHaveLength(1) expect(withOne).toHaveLength(1)
const deduped = prependHistoryEntry(withOne, text("hello")) const deduped = prependHistoryEntry(withOne, text("hello"))
expect(deduped).toBe(withOne) expect(deduped).toBe(withOne)
const dedupedComments = prependHistoryEntry(commentsOnly, DEFAULT_PROMPT, [comment("c1")])
expect(dedupedComments).toBe(commentsOnly)
}) })
test("navigatePromptHistory restores saved prompt when moving down from newest", () => { test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
@@ -31,24 +48,57 @@ describe("prompt-input history", () => {
entries, entries,
historyIndex: -1, historyIndex: -1,
currentPrompt: text("draft"), currentPrompt: text("draft"),
currentComments: [comment("draft")],
savedPrompt: null, savedPrompt: null,
}) })
expect(up.handled).toBe(true) expect(up.handled).toBe(true)
if (!up.handled) throw new Error("expected handled") if (!up.handled) throw new Error("expected handled")
expect(up.historyIndex).toBe(0) expect(up.historyIndex).toBe(0)
expect(up.cursor).toBe("start") expect(up.cursor).toBe("start")
expect(up.entry.comments).toEqual([])
const down = navigatePromptHistory({ const down = navigatePromptHistory({
direction: "down", direction: "down",
entries, entries,
historyIndex: up.historyIndex, historyIndex: up.historyIndex,
currentPrompt: text("ignored"), currentPrompt: text("ignored"),
currentComments: [],
savedPrompt: up.savedPrompt, savedPrompt: up.savedPrompt,
}) })
expect(down.handled).toBe(true) expect(down.handled).toBe(true)
if (!down.handled) throw new Error("expected handled") if (!down.handled) throw new Error("expected handled")
expect(down.historyIndex).toBe(-1) expect(down.historyIndex).toBe(-1)
expect(down.prompt[0]?.type === "text" ? down.prompt[0].content : "").toBe("draft") expect(down.entry.prompt[0]?.type === "text" ? down.entry.prompt[0].content : "").toBe("draft")
expect(down.entry.comments).toEqual([comment("draft")])
})
test("navigatePromptHistory keeps entry comments when moving through history", () => {
const entries = [
{
prompt: text("with comment"),
comments: [comment("c1")],
},
]
const up = navigatePromptHistory({
direction: "up",
entries,
historyIndex: -1,
currentPrompt: text("draft"),
currentComments: [],
savedPrompt: null,
})
expect(up.handled).toBe(true)
if (!up.handled) throw new Error("expected handled")
expect(up.entry.prompt[0]?.type === "text" ? up.entry.prompt[0].content : "").toBe("with comment")
expect(up.entry.comments).toEqual([comment("c1")])
})
test("normalizePromptHistoryEntry supports legacy prompt arrays", () => {
const entry = normalizePromptHistoryEntry(text("legacy"))
expect(entry.prompt[0]?.type === "text" ? entry.prompt[0].content : "").toBe("legacy")
expect(entry.comments).toEqual([])
}) })
test("helpers clone prompt and count text content length", () => { test("helpers clone prompt and count text content length", () => {

View File

@@ -1,9 +1,27 @@
import type { Prompt } from "@/context/prompt" import type { Prompt } from "@/context/prompt"
import type { SelectedLineRange } from "@/context/file"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export const MAX_HISTORY = 100 export const MAX_HISTORY = 100
export type PromptHistoryComment = {
id: string
path: string
selection: SelectedLineRange
comment: string
time: number
origin?: "review" | "file"
preview?: string
}
export type PromptHistoryEntry = {
prompt: Prompt
comments: PromptHistoryComment[]
}
export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) { export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
const position = Math.max(0, Math.min(cursor, text.length)) const position = Math.max(0, Math.min(cursor, text.length))
const atStart = position === 0 const atStart = position === 0
@@ -25,29 +43,82 @@ export function clonePromptParts(prompt: Prompt): Prompt {
}) })
} }
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
return {
start: selection.start,
end: selection.end,
...(selection.side ? { side: selection.side } : {}),
...(selection.endSide ? { endSide: selection.endSide } : {}),
}
}
export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
return comments.map((comment) => ({
...comment,
selection: cloneSelection(comment.selection),
}))
}
export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
if (Array.isArray(entry)) {
return {
prompt: clonePromptParts(entry),
comments: [],
}
}
return {
prompt: clonePromptParts(entry.prompt),
comments: clonePromptHistoryComments(entry.comments),
}
}
export function promptLength(prompt: Prompt) { export function promptLength(prompt: Prompt) {
return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0) return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
} }
export function prependHistoryEntry(entries: Prompt[], prompt: Prompt, max = MAX_HISTORY) { export function prependHistoryEntry(
entries: PromptHistoryStoredEntry[],
prompt: Prompt,
comments: PromptHistoryComment[] = [],
max = MAX_HISTORY,
) {
const text = prompt const text = prompt
.map((part) => ("content" in part ? part.content : "")) .map((part) => ("content" in part ? part.content : ""))
.join("") .join("")
.trim() .trim()
const hasImages = prompt.some((part) => part.type === "image") const hasImages = prompt.some((part) => part.type === "image")
if (!text && !hasImages) return entries const hasComments = comments.some((comment) => !!comment.comment.trim())
if (!text && !hasImages && !hasComments) return entries
const entry = clonePromptParts(prompt) const entry = {
prompt: clonePromptParts(prompt),
comments: clonePromptHistoryComments(comments),
} satisfies PromptHistoryEntry
const last = entries[0] const last = entries[0]
if (last && isPromptEqual(last, entry)) return entries if (last && isPromptEqual(last, entry)) return entries
return [entry, ...entries].slice(0, max) return [entry, ...entries].slice(0, max)
} }
function isPromptEqual(promptA: Prompt, promptB: Prompt) { function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) {
if (promptA.length !== promptB.length) return false return (
for (let i = 0; i < promptA.length; i++) { commentA.path === commentB.path &&
const partA = promptA[i] commentA.comment === commentB.comment &&
const partB = promptB[i] commentA.origin === commentB.origin &&
commentA.preview === commentB.preview &&
commentA.selection.start === commentB.selection.start &&
commentA.selection.end === commentB.selection.end &&
commentA.selection.side === commentB.selection.side &&
commentA.selection.endSide === commentB.selection.endSide
)
}
function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) {
const entryA = normalizePromptHistoryEntry(promptA)
const entryB = normalizePromptHistoryEntry(promptB)
if (entryA.prompt.length !== entryB.prompt.length) return false
for (let i = 0; i < entryA.prompt.length; i++) {
const partA = entryA.prompt[i]
const partB = entryB.prompt[i]
if (partA.type !== partB.type) return false if (partA.type !== partB.type) return false
if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false
if (partA.type === "file") { if (partA.type === "file") {
@@ -67,28 +138,35 @@ function isPromptEqual(promptA: Prompt, promptB: Prompt) {
if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
} }
if (entryA.comments.length !== entryB.comments.length) return false
for (let i = 0; i < entryA.comments.length; i++) {
const commentA = entryA.comments[i]
const commentB = entryB.comments[i]
if (!commentA || !commentB || !isCommentEqual(commentA, commentB)) return false
}
return true return true
} }
type HistoryNavInput = { type HistoryNavInput = {
direction: "up" | "down" direction: "up" | "down"
entries: Prompt[] entries: PromptHistoryStoredEntry[]
historyIndex: number historyIndex: number
currentPrompt: Prompt currentPrompt: Prompt
savedPrompt: Prompt | null currentComments: PromptHistoryComment[]
savedPrompt: PromptHistoryEntry | null
} }
type HistoryNavResult = type HistoryNavResult =
| { | {
handled: false handled: false
historyIndex: number historyIndex: number
savedPrompt: Prompt | null savedPrompt: PromptHistoryEntry | null
} }
| { | {
handled: true handled: true
historyIndex: number historyIndex: number
savedPrompt: Prompt | null savedPrompt: PromptHistoryEntry | null
prompt: Prompt entry: PromptHistoryEntry
cursor: "start" | "end" cursor: "start" | "end"
} }
@@ -103,22 +181,27 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
} }
if (input.historyIndex === -1) { if (input.historyIndex === -1) {
const entry = normalizePromptHistoryEntry(input.entries[0])
return { return {
handled: true, handled: true,
historyIndex: 0, historyIndex: 0,
savedPrompt: clonePromptParts(input.currentPrompt), savedPrompt: {
prompt: input.entries[0], prompt: clonePromptParts(input.currentPrompt),
comments: clonePromptHistoryComments(input.currentComments),
},
entry,
cursor: "start", cursor: "start",
} }
} }
if (input.historyIndex < input.entries.length - 1) { if (input.historyIndex < input.entries.length - 1) {
const next = input.historyIndex + 1 const next = input.historyIndex + 1
const entry = normalizePromptHistoryEntry(input.entries[next])
return { return {
handled: true, handled: true,
historyIndex: next, historyIndex: next,
savedPrompt: input.savedPrompt, savedPrompt: input.savedPrompt,
prompt: input.entries[next], entry,
cursor: "start", cursor: "start",
} }
} }
@@ -132,11 +215,12 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
if (input.historyIndex > 0) { if (input.historyIndex > 0) {
const next = input.historyIndex - 1 const next = input.historyIndex - 1
const entry = normalizePromptHistoryEntry(input.entries[next])
return { return {
handled: true, handled: true,
historyIndex: next, historyIndex: next,
savedPrompt: input.savedPrompt, savedPrompt: input.savedPrompt,
prompt: input.entries[next], entry,
cursor: "end", cursor: "end",
} }
} }
@@ -147,7 +231,7 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
handled: true, handled: true,
historyIndex: -1, historyIndex: -1,
savedPrompt: null, savedPrompt: null,
prompt: input.savedPrompt, entry: input.savedPrompt,
cursor: "end", cursor: "end",
} }
} }
@@ -156,7 +240,10 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
handled: true, handled: true,
historyIndex: -1, historyIndex: -1,
savedPrompt: null, savedPrompt: null,
prompt: DEFAULT_PROMPT, entry: {
prompt: DEFAULT_PROMPT,
comments: [],
},
cursor: "end", cursor: "end",
} }
} }

View File

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

View File

@@ -150,4 +150,37 @@ describe("comments session indexing", () => {
dispose() dispose()
}) })
}) })
test("update changes only the targeted comment body", () => {
createRoot((dispose) => {
const comments = createCommentSessionForTest({
"a.ts": [line("a.ts", "a1", 10), line("a.ts", "a2", 20)],
})
comments.update("a.ts", "a2", "edited")
expect(comments.list("a.ts").map((item) => item.comment)).toEqual(["a1", "edited"])
dispose()
})
})
test("replace swaps comment state and clears focus state", () => {
createRoot((dispose) => {
const comments = createCommentSessionForTest({
"a.ts": [line("a.ts", "a1", 10)],
})
comments.setFocus({ file: "a.ts", id: "a1" })
comments.setActive({ file: "a.ts", id: "a1" })
comments.replace([line("b.ts", "b1", 30)])
expect(comments.list("a.ts")).toEqual([])
expect(comments.list("b.ts").map((item) => item.id)).toEqual(["b1"])
expect(comments.focus()).toBeNull()
expect(comments.active()).toBeNull()
dispose()
})
})
}) })

View File

@@ -44,6 +44,37 @@ function aggregate(comments: Record<string, LineComment[]>) {
.sort((a, b) => a.time - b.time) .sort((a, b) => a.time - b.time)
} }
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
const next: SelectedLineRange = {
start: selection.start,
end: selection.end,
}
if (selection.side) next.side = selection.side
if (selection.endSide) next.endSide = selection.endSide
return next
}
function cloneComment(comment: LineComment): LineComment {
return {
...comment,
selection: cloneSelection(comment.selection),
}
}
function group(comments: LineComment[]) {
return comments.reduce<Record<string, LineComment[]>>((acc, comment) => {
const list = acc[comment.file]
const next = cloneComment(comment)
if (list) {
list.push(next)
return acc
}
acc[comment.file] = [next]
return acc
}, {})
}
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) { function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
const [state, setState] = createStore({ const [state, setState] = createStore({
focus: null as CommentFocus | null, focus: null as CommentFocus | null,
@@ -70,6 +101,7 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
id: uuid(), id: uuid(),
time: Date.now(), time: Date.now(),
...input, ...input,
selection: cloneSelection(input.selection),
} }
batch(() => { batch(() => {
@@ -87,6 +119,23 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
}) })
} }
const update = (file: string, id: string, comment: string) => {
setStore("comments", file, (items) =>
(items ?? []).map((item) => {
if (item.id !== id) return item
return { ...item, comment }
}),
)
}
const replace = (comments: LineComment[]) => {
batch(() => {
setStore("comments", reconcile(group(comments)))
setFocus(null)
setActive(null)
})
}
const clear = () => { const clear = () => {
batch(() => { batch(() => {
setStore("comments", reconcile({})) setStore("comments", reconcile({}))
@@ -100,6 +149,8 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
all, all,
add, add,
remove, remove,
update,
replace,
clear, clear,
focus: () => state.focus, focus: () => state.focus,
setFocus, setFocus,
@@ -132,6 +183,8 @@ function createCommentSession(dir: string, id: string | undefined) {
all: session.all, all: session.all,
add: session.add, add: session.add,
remove: session.remove, remove: session.remove,
update: session.update,
replace: session.replace,
clear: session.clear, clear: session.clear,
focus: session.focus, focus: session.focus,
setFocus: session.setFocus, setFocus: session.setFocus,
@@ -176,6 +229,8 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
all: () => session().all(), all: () => session().all(),
add: (input: Omit<LineComment, "id" | "time">) => session().add(input), add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
remove: (file: string, id: string) => session().remove(file, id), remove: (file: string, id: string) => session().remove(file, id),
update: (file: string, id: string, comment: string) => session().update(file, id, comment),
replace: (comments: LineComment[]) => session().replace(comments),
clear: () => session().clear(), clear: () => session().clear(),
focus: () => session().focus(), focus: () => session().focus(),
setFocus: (focus: CommentFocus | null) => session().setFocus(focus), setFocus: (focus: CommentFocus | null) => session().setFocus(focus),

View File

@@ -9,7 +9,7 @@ const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500 const MAX_VIEW_FILES = 500
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange { function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
if (range.start <= range.end) return range if (range.start <= range.end) return { ...range }
const startSide = range.side const startSide = range.side
const endSide = range.endSide ?? startSide const endSide = range.endSide ?? startSide

View File

@@ -41,4 +41,24 @@ describe("createScrollPersistence", () => {
vi.useRealTimers() vi.useRealTimers()
} }
}) })
test("reseeds empty cache after persisted snapshot loads", () => {
const snapshot = {
session: {},
} as Record<string, Record<string, { x: number; y: number }>>
const scroll = createScrollPersistence({
getSnapshot: (sessionKey) => snapshot[sessionKey],
onFlush: () => {},
})
expect(scroll.scroll("session", "review")).toBeUndefined()
snapshot.session = {
review: { x: 12, y: 34 },
}
expect(scroll.scroll("session", "review")).toEqual({ x: 12, y: 34 })
scroll.dispose()
})
}) })

View File

@@ -33,8 +33,16 @@ export function createScrollPersistence(opts: Options) {
} }
function seed(sessionKey: string) { function seed(sessionKey: string) {
if (cache[sessionKey]) return const next = clone(opts.getSnapshot(sessionKey))
setCache(sessionKey, clone(opts.getSnapshot(sessionKey))) const current = cache[sessionKey]
if (!current) {
setCache(sessionKey, next)
return
}
if (Object.keys(current).length > 0) return
if (Object.keys(next).length === 0) return
setCache(sessionKey, next)
} }
function scroll(sessionKey: string, tab: string) { function scroll(sessionKey: string, tab: string) {

View File

@@ -116,6 +116,10 @@ function contextItemKey(item: ContextItem) {
return `${key}:c=${digest.slice(0, 8)}` return `${key}:c=${digest.slice(0, 8)}`
} }
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
return item.type === "file" && !!item.comment?.trim()
}
function createPromptActions( function createPromptActions(
setStore: SetStoreFunction<{ setStore: SetStoreFunction<{
prompt: Prompt prompt: Prompt
@@ -189,6 +193,26 @@ function createPromptSession(dir: string, id: string | undefined) {
remove(key: string) { remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key)) setStore("context", "items", (items) => items.filter((x) => x.key !== key))
}, },
removeComment(path: string, commentID: string) {
setStore("context", "items", (items) =>
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
)
},
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
setStore("context", "items", (items) =>
items.map((item) => {
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
const value = { ...item, ...next }
return { ...value, key: contextItemKey(value) }
}),
)
},
replaceComments(items: FileContextItem[]) {
setStore("context", "items", (current) => [
...current.filter((item) => !isCommentItem(item)),
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
])
},
}, },
set: actions.set, set: actions.set,
reset: actions.reset, reset: actions.reset,
@@ -251,6 +275,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
items: () => session().context.items(), items: () => session().context.items(),
add: (item: ContextItem) => session().context.add(item), add: (item: ContextItem) => session().context.add(item),
remove: (key: string) => session().context.remove(key), remove: (key: string) => session().context.remove(key),
removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID),
updateComment: (path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) =>
session().context.updateComment(path, commentID, next),
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
}, },
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition), set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
reset: () => session().reset(), reset: () => session().reset(),

View File

@@ -379,11 +379,58 @@ export default function Page() {
}) })
} }
const updateCommentInContext = (input: {
id: string
file: string
selection: SelectedLineRange
comment: string
preview?: string
}) => {
comments.update(input.file, input.id, input.comment)
prompt.context.updateComment(input.file, input.id, {
comment: input.comment,
...(input.preview ? { preview: input.preview } : {}),
})
}
const removeCommentFromContext = (input: { id: string; file: string }) => {
comments.remove(input.file, input.id)
prompt.context.removeComment(input.file, input.id)
}
const reviewCommentActions = createMemo(() => ({
moreLabel: language.t("common.moreOptions"),
editLabel: language.t("common.edit"),
deleteLabel: language.t("common.delete"),
saveLabel: language.t("common.save"),
}))
const isEditableTarget = (target: EventTarget | null | undefined) => {
if (!(target instanceof HTMLElement)) return false
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
}
const deepActiveElement = () => {
let current: Element | null = document.activeElement
while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
current = current.shadowRoot.activeElement
}
return current instanceof HTMLElement ? current : undefined
}
const handleKeyDown = (event: KeyboardEvent) => { const 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) { if (activeElement) {
const isProtected = activeElement.closest("[data-prevent-autofocus]") 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 (isProtected || isInput) return
} }
if (dialog.active) return if (dialog.active) return
@@ -500,6 +547,9 @@ export default function Page() {
onScrollRef={(el) => setTree("reviewScroll", el)} onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff} focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()} comments={comments.all()}
focusedComment={comments.focus()} focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus} onFocusedCommentChange={comments.setFocus}
@@ -521,6 +571,9 @@ export default function Page() {
onScrollRef={(el) => setTree("reviewScroll", el)} onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff} focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()} comments={comments.all()}
focusedComment={comments.focus()} focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus} onFocusedCommentChange={comments.setFocus}
@@ -549,6 +602,9 @@ export default function Page() {
onScrollRef={(el) => setTree("reviewScroll", el)} onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff} focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()}
comments={comments.all()} comments={comments.all()}
focusedComment={comments.focus()} focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus} onFocusedCommentChange={comments.setFocus}

View File

@@ -1,15 +1,17 @@
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js" import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { useCodeComponent } from "@opencode-ai/ui/context/code" import type { FileSearchHandle } from "@opencode-ai/ui/file"
import { useFileComponent } from "@opencode-ai/ui/context/file"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { createLineCommentController } from "@opencode-ai/ui/line-comment-annotations"
import { sampledChecksum } from "@opencode-ai/util/encode" import { sampledChecksum } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { showToast } from "@opencode-ai/ui/toast" import { IconButton } from "@opencode-ai/ui/icon-button"
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 { Tabs } from "@opencode-ai/ui/tabs"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { showToast } from "@opencode-ai/ui/toast"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
@@ -17,11 +19,37 @@ import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { getSessionHandoff } from "@/pages/session/handoff" import { getSessionHandoff } from "@/pages/session/handoff"
const formatCommentLabel = (range: SelectedLineRange) => { function FileCommentMenu(props: {
const start = Math.min(range.start, range.end) moreLabel: string
const end = Math.max(range.start, range.end) editLabel: string
if (start === end) return `line ${start}` deleteLabel: string
return `lines ${start}-${end}` onEdit: VoidFunction
onDelete: VoidFunction
}) {
return (
<div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
<DropdownMenu gutter={4} placement="bottom-end">
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
size="small"
class="size-6 rounded-md"
aria-label={props.moreLabel}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={props.onEdit}>
<DropdownMenu.ItemLabel>{props.editLabel}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={props.onDelete}>
<DropdownMenu.ItemLabel>{props.deleteLabel}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)
} }
export function FileTabContent(props: { tab: string }) { export function FileTabContent(props: { tab: string }) {
@@ -31,7 +59,7 @@ export function FileTabContent(props: { tab: string }) {
const comments = useComments() const comments = useComments()
const language = useLanguage() const language = useLanguage()
const prompt = usePrompt() const prompt = usePrompt()
const codeComponent = useCodeComponent() const fileComponent = useFileComponent()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey)) const tabs = createMemo(() => layout.tabs(sessionKey))
@@ -41,6 +69,13 @@ export function FileTabContent(props: { tab: string }) {
let scrollFrame: number | undefined let scrollFrame: number | undefined
let pending: { x: number; y: number } | undefined let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = [] let codeScroll: HTMLElement[] = []
let find: FileSearchHandle | null = null
const search = {
register: (handle: FileSearchHandle | null) => {
find = handle
},
}
const path = createMemo(() => file.pathFromTab(props.tab)) const path = createMemo(() => file.pathFromTab(props.tab))
const state = createMemo(() => { const state = createMemo(() => {
@@ -50,66 +85,18 @@ export function FileTabContent(props: { tab: string }) {
}) })
const contents = createMemo(() => state()?.content?.content ?? "") const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => sampledChecksum(contents())) const cacheKey = createMemo(() => sampledChecksum(contents()))
const isImage = createMemo(() => { const selectedLines = createMemo<SelectedLineRange | null>(() => {
const c = state()?.content
return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
})
const isSvg = createMemo(() => {
const c = state()?.content
return c?.mimeType === "image/svg+xml"
})
const isBinary = createMemo(() => state()?.content?.type === "binary")
const svgContent = createMemo(() => {
if (!isSvg()) return
const c = state()?.content
if (!c) return
if (c.encoding !== "base64") return c.content
return decode64(c.content)
})
const svgDecodeFailed = createMemo(() => {
if (!isSvg()) return false
const c = state()?.content
if (!c) return false
if (c.encoding !== "base64") return false
return svgContent() === undefined
})
const svgToast = { shown: false }
createEffect(() => {
if (!svgDecodeFailed()) return
if (svgToast.shown) return
svgToast.shown = true
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
})
})
const svgPreviewUrl = createMemo(() => {
if (!isSvg()) return
const c = state()?.content
if (!c) return
if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
})
const imageDataUrl = createMemo(() => {
if (!isImage()) return
const c = state()?.content
return `data:${c?.mimeType};base64,${c?.content}`
})
const selectedLines = createMemo(() => {
const p = path() const p = path()
if (!p) return null if (!p) return null
if (file.ready()) return file.selectedLines(p) ?? null if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
return getSessionHandoff(sessionKey())?.files[p] ?? null return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
}) })
const selectionPreview = (source: string, selection: FileSelection) => { const selectionPreview = (source: string, selection: FileSelection) => {
const start = Math.max(1, Math.min(selection.startLine, selection.endLine)) return previewSelectedLines(source, {
const end = Math.max(selection.startLine, selection.endLine) start: selection.startLine,
const lines = source.split("\n").slice(start - 1, end) end: selection.endLine,
if (lines.length === 0) return undefined })
return lines.slice(0, 2).join("\n")
} }
const addCommentToContext = (input: { const addCommentToContext = (input: {
@@ -145,7 +132,25 @@ export function FileTabContent(props: { tab: string }) {
}) })
} }
let wrap: HTMLDivElement | undefined const updateCommentInContext = (input: {
id: string
file: string
selection: SelectedLineRange
comment: string
}) => {
comments.update(input.file, input.id, input.comment)
const preview =
input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined
prompt.context.updateComment(input.file, input.id, {
comment: input.comment,
...(preview ? { preview } : {}),
})
}
const removeCommentFromContext = (input: { id: string; file: string }) => {
comments.remove(input.file, input.id)
prompt.context.removeComment(input.file, input.id)
}
const fileComments = createMemo(() => { const fileComments = createMemo(() => {
const p = path() const p = path()
@@ -153,121 +158,105 @@ export function FileTabContent(props: { tab: string }) {
return comments.list(p) 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 commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
const [note, setNote] = createStore({ const [note, setNote] = createStore({
openedComment: null as string | null, openedComment: null as string | null,
commenting: null as SelectedLineRange | null, commenting: null as SelectedLineRange | null,
draft: "", selected: null as SelectedLineRange | null,
positions: {} as Record<string, number>,
draftTop: undefined as number | undefined,
}) })
const setCommenting = (range: SelectedLineRange | null) => { const syncSelected = (range: SelectedLineRange | null) => {
setNote("commenting", range) const p = path()
scheduleComments() if (!p) return
if (!range) return file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null)
setNote("draft", "")
} }
const getRoot = () => { const activeSelection = () => note.selected ?? selectedLines()
const el = wrap
if (!el) return
const host = el.querySelector("diffs-container") const commentsUi = createLineCommentController({
if (!(host instanceof HTMLElement)) return comments: fileComments,
label: language.t("ui.lineComment.submit"),
draftKey: () => path() ?? props.tab,
state: {
opened: () => note.openedComment,
setOpened: (id) => setNote("openedComment", id),
selected: () => note.selected,
setSelected: (range) => setNote("selected", range),
commenting: () => note.commenting,
setCommenting: (range) => setNote("commenting", range),
syncSelected,
hoverSelected: syncSelected,
},
getHoverSelectedRange: activeSelection,
cancelDraftOnCommentToggle: true,
clearSelectionOnSelectionEndNull: true,
onSubmit: ({ comment, selection }) => {
const p = path()
if (!p) return
addCommentToContext({ file: p, selection, comment, origin: "file" })
},
onUpdate: ({ id, comment, selection }) => {
const p = path()
if (!p) return
updateCommentInContext({ id, file: p, selection, comment })
},
onDelete: (comment) => {
const p = path()
if (!p) return
removeCommentFromContext({ id: comment.id, file: p })
},
editSubmitLabel: language.t("common.save"),
renderCommentActions: (_, controls) => (
<FileCommentMenu
moreLabel={language.t("common.moreOptions")}
editLabel={language.t("common.edit")}
deleteLabel={language.t("common.delete")}
onEdit={controls.edit}
onDelete={controls.remove}
/>
),
onDraftPopoverFocusOut: (e: FocusEvent) => {
const current = e.currentTarget as HTMLDivElement
const target = e.relatedTarget
if (target instanceof Node && current.contains(target)) return
const root = host.shadowRoot setTimeout(() => {
if (!root) return if (!document.activeElement || !current.contains(document.activeElement)) {
setNote("commenting", null)
return root }
} }, 0)
},
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(() => { createEffect(() => {
commentLayout() if (typeof window === "undefined") return
scheduleComments()
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return
if (tabs().active() !== props.tab) return
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return
if (event.key.toLowerCase() !== "f") return
event.preventDefault()
event.stopPropagation()
find?.focus()
}
window.addEventListener("keydown", onKeyDown, { capture: true })
onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true }))
}) })
createEffect(
on(
path,
() => {
commentsUi.note.reset()
},
{ defer: true },
),
)
createEffect(() => { createEffect(() => {
const focus = comments.focus() const focus = comments.focus()
const p = path() const p = path()
@@ -278,9 +267,7 @@ export function FileTabContent(props: { tab: string }) {
const target = fileComments().find((comment) => comment.id === focus.id) const target = fileComments().find((comment) => comment.id === focus.id)
if (!target) return if (!target) return
setNote("openedComment", target.id) commentsUi.note.openComment(target.id, target.selection, { cancelDraft: true })
setCommenting(null)
file.setSelectedLines(p, target.selection)
requestAnimationFrame(() => comments.clearFocus()) requestAnimationFrame(() => comments.clearFocus())
}) })
@@ -419,99 +406,50 @@ export function FileTabContent(props: { tab: string }) {
cancelAnimationFrame(scrollFrame) cancelAnimationFrame(scrollFrame)
}) })
const renderCode = (source: string, wrapperClass: string) => ( const renderFile = (source: string) => (
<div <div class="relative overflow-hidden pb-40">
ref={(el) => {
wrap = el
scheduleComments()
}}
class={`relative overflow-hidden ${wrapperClass}`}
>
<Dynamic <Dynamic
component={codeComponent} component={fileComponent}
mode="text"
file={{ file={{
name: path() ?? "", name: path() ?? "",
contents: source, contents: source,
cacheKey: cacheKey(), cacheKey: cacheKey(),
}} }}
enableLineSelection enableLineSelection
selectedLines={selectedLines()} enableHoverUtility
selectedLines={activeSelection()}
commentedLines={commentedLines()} commentedLines={commentedLines()}
onRendered={() => { onRendered={() => {
requestAnimationFrame(restoreScroll) requestAnimationFrame(restoreScroll)
requestAnimationFrame(scheduleComments)
}} }}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
renderHoverUtility={commentsUi.renderHoverUtility}
onLineSelected={(range: SelectedLineRange | null) => { onLineSelected={(range: SelectedLineRange | null) => {
const p = path() commentsUi.onLineSelected(range)
if (!p) return
file.setSelectedLines(p, range)
if (!range) setCommenting(null)
}} }}
onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
onLineSelectionEnd={(range: SelectedLineRange | null) => { onLineSelectionEnd={(range: SelectedLineRange | null) => {
if (!range) { commentsUi.onLineSelectionEnd(range)
setCommenting(null)
return
}
setNote("openedComment", null)
setCommenting(range)
}} }}
search={search}
overflow="scroll" overflow="scroll"
class="select-text" class="select-text"
media={{
mode: "auto",
path: path(),
current: state()?.content,
onLoad: () => requestAnimationFrame(restoreScroll),
onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
})
},
}}
/> />
<For each={fileComments()}>
{(comment) => (
<LineCommentView
id={comment.id}
top={note.positions[comment.id]}
open={note.openedComment === comment.id}
comment={comment.comment}
selection={formatCommentLabel(comment.selection)}
onMouseEnter={() => {
const p = path()
if (!p) return
file.setSelectedLines(p, comment.selection)
}}
onClick={() => {
const p = path()
if (!p) return
setCommenting(null)
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
file.setSelectedLines(p, comment.selection)
}}
/>
)}
</For>
<Show when={note.commenting}>
{(range) => (
<Show when={note.draftTop !== undefined}>
<LineCommentEditor
top={note.draftTop}
value={note.draft}
selection={formatCommentLabel(range())}
onInput={(value) => setNote("draft", value)}
onCancel={cancelCommenting}
onSubmit={(value) => {
const p = path()
if (!p) return
addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" })
setCommenting(null)
}}
onPopoverFocusOut={(e: FocusEvent) => {
const current = e.currentTarget as HTMLDivElement
const target = e.relatedTarget
if (target instanceof Node && current.contains(target)) return
setTimeout(() => {
if (!document.activeElement || !current.contains(document.activeElement)) {
cancelCommenting()
}
}, 0)
}}
/>
</Show>
)}
</Show>
</div> </div>
) )
@@ -526,36 +464,7 @@ export function FileTabContent(props: { tab: string }) {
onScroll={handleScroll as any} onScroll={handleScroll as any}
> >
<Switch> <Switch>
<Match when={state()?.loaded && isImage()}> <Match when={state()?.loaded}>{renderFile(contents())}</Match>
<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()?.loading}> <Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div> <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
</Match> </Match>

View File

@@ -2,6 +2,7 @@ import { For, createEffect, createMemo, on, onCleanup, Show, type JSX } from "so
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
@@ -9,8 +10,9 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input" import { InlineInput } from "@opencode-ai/ui/inline-input"
import { SessionTurn } from "@opencode-ai/ui/session-turn" import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { UserMessage } from "@opencode-ai/sdk/v2" import type { Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -18,6 +20,35 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
type MessageComment = {
path: string
comment: string
selection?: {
startLine: number
endLine: number
}
}
const messageComments = (parts: Part[]): MessageComment[] =>
parts.flatMap((part) => {
if (part.type !== "text" || !(part as TextPart).synthetic) return []
const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text)
if (!next) return []
return [
{
path: next.path,
comment: next.comment,
selection: next.selection
? {
startLine: next.selection.startLine,
endLine: next.selection.endLine,
}
: undefined,
},
]
})
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
const current = target instanceof Element ? target : undefined const current = target instanceof Element ? target : undefined
@@ -522,34 +553,67 @@ export function MessageTimeline(props: {
</div> </div>
</Show> </Show>
<For each={props.renderedUserMessages}> <For each={props.renderedUserMessages}>
{(message) => ( {(message) => {
<div const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? []))
id={props.anchor(message.id)} return (
data-message-id={message.id} <div
ref={(el) => { id={props.anchor(message.id)}
props.onRegisterMessage(el, message.id) data-message-id={message.id}
onCleanup(() => props.onUnregisterMessage(message.id)) ref={(el) => {
}} props.onRegisterMessage(el, message.id)
classList={{ onCleanup(() => props.onUnregisterMessage(message.id))
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
}}
>
<SessionTurn
sessionID={sessionID() ?? ""}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
showReasoningSummaries={settings.general.showReasoningSummaries()}
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
container: "w-full px-4 md:px-5",
}} }}
/> classList={{
</div> "min-w-0 w-full max-w-full": true,
)} "md:max-w-200 2xl:max-w-[1000px]": props.centered,
}}
>
<Show when={comments().length > 0}>
<div class="w-full px-4 md:px-5 pb-2">
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2">
<For each={comments()}>
{(comment) => (
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
<FileIcon node={{ path: comment.path, type: "file" }} class="size-3.5 shrink-0" />
<span class="truncate">{getFilename(comment.path)}</span>
<Show when={comment.selection}>
{(selection) => (
<span class="shrink-0 text-text-weak">
{selection().startLine === selection().endLine
? `:${selection().startLine}`
: `:${selection().startLine}-${selection().endLine}`}
</span>
)}
</Show>
</div>
<div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
{comment.comment}
</div>
</div>
)}
</For>
</div>
</div>
</div>
</Show>
<SessionTurn
sessionID={sessionID() ?? ""}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
showReasoningSummaries={settings.general.showReasoningSummaries()}
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
container: "w-full px-4 md:px-5",
}}
/>
</div>
)
}}
</For> </For>
</div> </div>
</ScrollView> </ScrollView>

View File

@@ -1,6 +1,11 @@
import { createEffect, on, onCleanup, type JSX } from "solid-js" import { createEffect, on, onCleanup, type JSX } from "solid-js"
import type { FileDiff } from "@opencode-ai/sdk/v2" import type { FileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/ui/session-review" import { SessionReview } from "@opencode-ai/ui/session-review"
import type {
SessionReviewCommentActions,
SessionReviewCommentDelete,
SessionReviewCommentUpdate,
} from "@opencode-ai/ui/session-review"
import type { SelectedLineRange } from "@/context/file" import type { SelectedLineRange } from "@/context/file"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
@@ -17,6 +22,9 @@ export interface SessionReviewTabProps {
onDiffStyleChange?: (style: DiffStyle) => void onDiffStyleChange?: (style: DiffStyle) => void
onViewFile?: (file: string) => void onViewFile?: (file: string) => void
onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
lineCommentActions?: SessionReviewCommentActions
comments?: LineComment[] comments?: LineComment[]
focusedComment?: { file: string; id: string } | null focusedComment?: { file: string; id: string } | null
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
@@ -39,10 +47,11 @@ export function StickyAddButton(props: { children: JSX.Element }) {
export function SessionReviewTab(props: SessionReviewTabProps) { export function SessionReviewTab(props: SessionReviewTabProps) {
let scroll: HTMLDivElement | undefined let scroll: HTMLDivElement | undefined
let frame: number | undefined let restoreFrame: number | undefined
let pending: { x: number; y: number } | undefined let userInteracted = false
const sdk = useSDK() const sdk = useSDK()
const layout = useLayout()
const readFile = async (path: string) => { const readFile = async (path: string) => {
return sdk.client.file return sdk.client.file
@@ -54,48 +63,81 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
}) })
} }
const restoreScroll = () => { const handleInteraction = () => {
userInteracted = true
}
const doRestore = () => {
restoreFrame = undefined
const el = scroll const el = scroll
if (!el) return if (!el || !layout.ready() || userInteracted) return
if (el.clientHeight === 0 || el.clientWidth === 0) return
const s = props.view().scroll("review") const s = props.view().scroll("review")
if (!s) return if (!s || (s.x === 0 && s.y === 0)) return
if (el.scrollTop !== s.y) el.scrollTop = s.y const maxY = Math.max(0, el.scrollHeight - el.clientHeight)
if (el.scrollLeft !== s.x) el.scrollLeft = s.x const maxX = Math.max(0, el.scrollWidth - el.clientWidth)
const targetY = Math.min(s.y, maxY)
const targetX = Math.min(s.x, maxX)
if (el.scrollTop !== targetY) el.scrollTop = targetY
if (el.scrollLeft !== targetX) el.scrollLeft = targetX
}
const queueRestore = () => {
if (userInteracted || restoreFrame !== undefined) return
restoreFrame = requestAnimationFrame(doRestore)
} }
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
pending = { if (!layout.ready() || !userInteracted) return
x: event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
}
if (frame !== undefined) return
frame = requestAnimationFrame(() => { const el = event.currentTarget
frame = undefined if (el.clientHeight === 0 || el.clientWidth === 0) return
const next = pending props.view().setScroll("review", {
pending = undefined x: el.scrollLeft,
if (!next) return y: el.scrollTop,
props.view().setScroll("review", next)
}) })
} }
createEffect( createEffect(
on( on(
() => props.diffs().length, () => props.diffs().length,
() => { () => queueRestore(),
requestAnimationFrame(restoreScroll) { defer: true },
),
)
createEffect(
on(
() => props.diffStyle,
() => queueRestore(),
{ defer: true },
),
)
createEffect(
on(
() => layout.ready(),
(ready) => {
if (!ready) return
queueRestore()
}, },
{ defer: true }, { defer: true },
), ),
) )
onCleanup(() => { onCleanup(() => {
if (frame === undefined) return if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
cancelAnimationFrame(frame) if (scroll) {
scroll.removeEventListener("wheel", handleInteraction)
scroll.removeEventListener("pointerdown", handleInteraction)
scroll.removeEventListener("touchstart", handleInteraction)
scroll.removeEventListener("keydown", handleInteraction)
}
}) })
return ( return (
@@ -104,11 +146,15 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
empty={props.empty} empty={props.empty}
scrollRef={(el) => { scrollRef={(el) => {
scroll = el scroll = el
el.addEventListener("wheel", handleInteraction, { passive: true, capture: true })
el.addEventListener("pointerdown", handleInteraction, { passive: true, capture: true })
el.addEventListener("touchstart", handleInteraction, { passive: true, capture: true })
el.addEventListener("keydown", handleInteraction, { passive: true, capture: true })
props.onScrollRef?.(el) props.onScrollRef?.(el)
restoreScroll() queueRestore()
}} }}
onScroll={handleScroll} onScroll={handleScroll}
onDiffRendered={() => requestAnimationFrame(restoreScroll)} onDiffRendered={queueRestore}
open={props.view().review.open()} open={props.view().review.open()}
onOpenChange={props.view().review.setOpen} onOpenChange={props.view().review.setOpen}
classes={{ classes={{
@@ -123,6 +169,9 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
focusedFile={props.focusedFile} focusedFile={props.focusedFile}
readFile={readFile} readFile={readFile}
onLineComment={props.onLineComment} onLineComment={props.onLineComment}
onLineCommentUpdate={props.onLineCommentUpdate}
onLineCommentDelete={props.onLineCommentDelete}
lineCommentActions={props.lineCommentActions}
comments={props.comments} comments={props.comments}
focusedComment={props.focusedComment} focusedComment={props.focusedComment}
onFocusedCommentChange={props.onFocusedCommentChange} onFocusedCommentChange={props.onFocusedCommentChange}

View File

@@ -0,0 +1,88 @@
import type { FileSelection } from "@/context/file"
export type PromptComment = {
path: string
selection?: FileSelection
comment: string
preview?: string
origin?: "review" | "file"
}
function selection(selection: unknown) {
if (!selection || typeof selection !== "object") return undefined
const startLine = Number((selection as FileSelection).startLine)
const startChar = Number((selection as FileSelection).startChar)
const endLine = Number((selection as FileSelection).endLine)
const endChar = Number((selection as FileSelection).endChar)
if (![startLine, startChar, endLine, endChar].every(Number.isFinite)) return undefined
return {
startLine,
startChar,
endLine,
endChar,
} satisfies FileSelection
}
export function createCommentMetadata(input: PromptComment) {
return {
opencodeComment: {
path: input.path,
selection: input.selection,
comment: input.comment,
preview: input.preview,
origin: input.origin,
},
}
}
export function readCommentMetadata(value: unknown) {
if (!value || typeof value !== "object") return
const meta = (value as { opencodeComment?: unknown }).opencodeComment
if (!meta || typeof meta !== "object") return
const path = (meta as { path?: unknown }).path
const comment = (meta as { comment?: unknown }).comment
if (typeof path !== "string" || typeof comment !== "string") return
const preview = (meta as { preview?: unknown }).preview
const origin = (meta as { origin?: unknown }).origin
return {
path,
selection: selection((meta as { selection?: unknown }).selection),
comment,
preview: typeof preview === "string" ? preview : undefined,
origin: origin === "review" || origin === "file" ? origin : undefined,
} satisfies PromptComment
}
export function formatCommentNote(input: { path: string; selection?: FileSelection; comment: string }) {
const start = input.selection ? Math.min(input.selection.startLine, input.selection.endLine) : undefined
const end = input.selection ? Math.max(input.selection.startLine, input.selection.endLine) : undefined
const range =
start === undefined || end === undefined
? "this file"
: start === end
? `line ${start}`
: `lines ${start} through ${end}`
return `The user made the following comment regarding ${range} of ${input.path}: ${input.comment}`
}
export function parseCommentNote(text: string) {
const match = text.match(
/^The user made the following comment regarding (this file|line (\d+)|lines (\d+) through (\d+)) of (.+?): ([\s\S]+)$/,
)
if (!match) return
const start = match[2] ? Number(match[2]) : match[3] ? Number(match[3]) : undefined
const end = match[2] ? Number(match[2]) : match[4] ? Number(match[4]) : undefined
return {
path: match[5],
selection:
start !== undefined && end !== undefined
? {
startLine: start,
startChar: 0,
endLine: end,
endChar: 0,
}
: undefined,
comment: match[6],
} satisfies PromptComment
}

View File

@@ -2,8 +2,7 @@ import { FileDiff, Message, Model, Part, Session, SessionStatus, UserMessage } f
import { SessionTurn } from "@opencode-ai/ui/session-turn" import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { SessionReview } from "@opencode-ai/ui/session-review" import { SessionReview } from "@opencode-ai/ui/session-review"
import { DataProvider } from "@opencode-ai/ui/context" import { DataProvider } from "@opencode-ai/ui/context"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff" import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool" import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
import { createAsync, query, useParams } from "@solidjs/router" import { createAsync, query, useParams } from "@solidjs/router"
import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js" 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 { Tabs } from "@opencode-ai/ui/tabs"
import { MessageNav } from "@opencode-ai/ui/message-nav" import { MessageNav } from "@opencode-ai/ui/message-nav"
import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" 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 { clientOnly } from "@solidjs/start"
import { type IconName } from "@opencode-ai/ui/icons/provider" import { type IconName } from "@opencode-ai/ui/icons/provider"
import { Meta, Title } from "@solidjs/meta" import { Meta, Title } from "@solidjs/meta"
import { Base64 } from "js-base64" 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(() => const ClientOnlyWorkerPoolProvider = clientOnly(() =>
import("@opencode-ai/ui/pierre/worker").then((m) => ({ import("@opencode-ai/ui/pierre/worker").then((m) => ({
default: (props: { children: any }) => ( default: (props: { children: any }) => (
@@ -218,252 +215,244 @@ export default function () {
<Meta property="og:image" content={ogImage()} /> <Meta property="og:image" content={ogImage()} />
<Meta name="twitter:image" content={ogImage()} /> <Meta name="twitter:image" content={ogImage()} />
<ClientOnlyWorkerPoolProvider> <ClientOnlyWorkerPoolProvider>
<DiffComponentProvider component={ClientOnlyDiff}> <FileComponentProvider component={FileSSR}>
<CodeComponentProvider component={ClientOnlyCode}> <DataProvider data={data()} directory={info().directory}>
<DataProvider data={data()} directory={info().directory}> {iife(() => {
{iife(() => { const [store, setStore] = createStore({
const [store, setStore] = createStore({ messageId: undefined as string | undefined,
messageId: undefined as string | undefined, })
}) const messages = createMemo(() =>
const messages = createMemo(() => data().sessionID
data().sessionID ? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort( (a, b) => a.time.created - b.time.created,
(a, b) => a.time.created - b.time.created, )
) : [],
: [], )
) const firstUserMessage = createMemo(() => messages().at(0))
const firstUserMessage = createMemo(() => messages().at(0)) const activeMessage = createMemo(
const activeMessage = createMemo( () => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
() => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(), )
) function setActiveMessage(message: UserMessage | undefined) {
function setActiveMessage(message: UserMessage | undefined) { if (message) {
if (message) { setStore("messageId", message.id)
setStore("messageId", message.id) } else {
} else { setStore("messageId", undefined)
setStore("messageId", undefined)
}
} }
const provider = createMemo(() => activeMessage()?.model?.providerID) }
const modelID = createMemo(() => activeMessage()?.model?.modelID) const provider = createMemo(() => activeMessage()?.model?.providerID)
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID())) const modelID = createMemo(() => activeMessage()?.model?.modelID)
const diffs = createMemo(() => { const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
const diffs = data().session_diff[data().sessionID] ?? [] const diffs = createMemo(() => {
const preloaded = data().session_diff_preload[data().sessionID] ?? [] const diffs = data().session_diff[data().sessionID] ?? []
return diffs.map((diff) => ({ const preloaded = data().session_diff_preload[data().sessionID] ?? []
...diff, return diffs.map((diff) => ({
preloaded: preloaded.find((d) => d.newFile.name === diff.file), ...diff,
})) preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}) }))
const splitDiffs = createMemo(() => { })
const diffs = data().session_diff[data().sessionID] ?? [] const splitDiffs = createMemo(() => {
const preloaded = data().session_diff_preload_split[data().sessionID] ?? [] const diffs = data().session_diff[data().sessionID] ?? []
return diffs.map((diff) => ({ const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
...diff, return diffs.map((diff) => ({
preloaded: preloaded.find((d) => d.newFile.name === diff.file), ...diff,
})) preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}) }))
})
const title = () => ( const title = () => (
<div class="flex flex-col gap-4"> <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="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"> <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" /> <Mark class="shrink-0 w-3 my-0.5" />
<div class="text-12-mono text-text-base">v{info().version}</div> <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>
</div> </div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div> <div class="flex gap-4 items-center">
</div> <div class="flex gap-2 items-center">
) <ProviderIcon
id={provider() as IconName}
const turns = () => ( class="size-3.5 shrink-0 text-icon-strong-base"
<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="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
<div class="flex flex-col gap-15 items-start justify-start mt-4"> </div>
<For each={messages()}> <div class="text-12-regular text-text-weaker">
{(message) => ( {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
<SessionTurn </div>
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>
</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 ( const wide = createMemo(() => diffs().length === 0)
<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"> return (
<div class=""> <div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
<a href="https://opencode.ai"> <header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
<Mark /> <div class="">
</a> <a href="https://opencode.ai">
</div> <Mark />
<div class="flex gap-3 items-center"> </a>
<IconButton </div>
as={"a"} <div class="flex gap-3 items-center">
href="https://github.com/anomalyco/opencode" <IconButton
target="_blank" as={"a"}
icon="github" href="https://github.com/anomalyco/opencode"
variant="ghost" target="_blank"
/> icon="github"
<IconButton variant="ghost"
as={"a"} />
href="https://opencode.ai/discord" <IconButton
target="_blank" as={"a"}
icon="discord" href="https://opencode.ai/discord"
variant="ghost" target="_blank"
/> icon="discord"
</div> variant="ghost"
</header> />
<div class="select-text flex flex-col flex-1 min-h-0"> </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 <div
classList={{ classList={{
"hidden w-full flex-1 min-h-0": true, "@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
"md:flex": wide(),
"lg:flex": !wide(),
}} }}
> >
<div <div
classList={{ 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 {title()}
classList={{ </div>
"w-full flex justify-start items-start min-w-0 px-6": true, <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 classList={{ "w-full flex items-center justify-center pb-8 shrink-0": true }}>
</div> <Logo class="w-58.5 opacity-12" />
<div class="flex items-start justify-start h-full min-h-0"> </div>
<Show when={messages().length > 1}> </SessionTurn>
<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> </div>
<Show when={diffs().length > 0}> </div>
<DiffComponentProvider component={SSRDiff}> <Show when={diffs().length > 0}>
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base"> <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 <SessionReview
class="@4xl:hidden"
diffs={diffs()} diffs={diffs()}
classes={{ classes={{
root: "pb-20", root: "pb-20",
header: "px-6", header: "px-4",
container: "px-6", container: "px-4",
}}
/>
<SessionReview
split
class="hidden @4xl:flex"
diffs={splitDiffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}} }}
/> />
</div> </div>
</DiffComponentProvider> </Tabs.Content>
</Show> </Tabs>
</div> </Match>
<Switch> <Match when={true}>
<Match when={diffs().length > 0}> <div
<Tabs classList={{ "md:hidden": wide(), "lg:hidden": !wide() }}> classList={{ "!overflow-hidden": true, "md:hidden": wide(), "lg:hidden": !wide() }}
<Tabs.List> >
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}> {turns()}
Session </div>
</Tabs.Trigger> </Match>
<Tabs.Trigger </Switch>
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>
</div> </div>
) </div>
})} )
</DataProvider> })}
</CodeComponentProvider> </DataProvider>
</DiffComponentProvider> </FileComponentProvider>
</ClientOnlyWorkerPoolProvider> </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,265 @@
import type { FileContent } from "@opencode-ai/sdk/v2"
import { createEffect, createMemo, createResource, Match, on, Show, Switch, type JSX } from "solid-js"
import { useI18n } from "../context/i18n"
import {
dataUrlFromMediaValue,
hasMediaValue,
isBinaryContent,
mediaKindFromPath,
normalizeMimeType,
svgTextFromValue,
} from "../pierre/media"
export type FileMediaOptions = {
mode?: "auto" | "off"
path?: string
current?: unknown
before?: unknown
after?: unknown
readFile?: (path: string) => Promise<FileContent | undefined>
onLoad?: () => void
onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void
}
function mediaValue(cfg: FileMediaOptions, mode: "image" | "audio") {
if (cfg.current !== undefined) return cfg.current
if (mode === "image") return cfg.after ?? cfg.before
return cfg.after ?? cfg.before
}
export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX.Element }) {
const i18n = useI18n()
const cfg = () => props.media
const kind = createMemo(() => {
const media = cfg()
if (!media || media.mode === "off") return
return mediaKindFromPath(media.path)
})
const isBinary = createMemo(() => {
const media = cfg()
if (!media || media.mode === "off") return false
if (kind()) return false
return isBinaryContent(media.current as any)
})
const onLoad = () => props.media?.onLoad?.()
const deleted = createMemo(() => {
const media = cfg()
const k = kind()
if (!media || !k) return false
if (k === "svg") return false
if (media.current !== undefined) return false
return !hasMediaValue(media.after as any) && hasMediaValue(media.before as any)
})
const direct = createMemo(() => {
const media = cfg()
const k = kind()
if (!media || (k !== "image" && k !== "audio")) return
return dataUrlFromMediaValue(mediaValue(media, k), k)
})
const request = createMemo(() => {
const media = cfg()
const k = kind()
if (!media || (k !== "image" && k !== "audio")) return
if (media.current !== undefined) return
if (deleted()) return
if (direct()) return
if (!media.path || !media.readFile) return
return {
key: `${k}:${media.path}`,
kind: k,
path: media.path,
readFile: media.readFile,
onError: media.onError,
}
})
const [loaded] = createResource(request, async (input) => {
return input.readFile(input.path).then(
(result) => {
const src = dataUrlFromMediaValue(result as any, input.kind)
if (!src) {
input.onError?.({ kind: input.kind })
return { key: input.key, error: true as const }
}
return {
key: input.key,
src,
mime: input.kind === "audio" ? normalizeMimeType(result?.mimeType) : undefined,
}
},
() => {
input.onError?.({ kind: input.kind })
return { key: input.key, error: true as const }
},
)
})
const remote = createMemo(() => {
const input = request()
const value = loaded()
if (!input || !value || value.key !== input.key) return
return value
})
const src = createMemo(() => {
const value = remote()
return direct() ?? (value && "src" in value ? value.src : undefined)
})
const status = createMemo(() => {
if (direct()) return "ready" as const
if (!request()) return "idle" as const
if (loaded.loading) return "loading" as const
if (remote()?.error) return "error" as const
if (src()) return "ready" as const
return "idle" as const
})
const audioMime = createMemo(() => {
const value = remote()
return value && "mime" in value ? value.mime : undefined
})
const svgSource = createMemo(() => {
const media = cfg()
if (!media || kind() !== "svg") return
return svgTextFromValue(media.current as any)
})
const svgSrc = createMemo(() => {
const media = cfg()
if (!media || kind() !== "svg") return
return dataUrlFromMediaValue(media.current as any, "svg")
})
const svgInvalid = createMemo(() => {
const media = cfg()
if (!media || kind() !== "svg") return
if (svgSource() !== undefined) return
if (!hasMediaValue(media.current as any)) return
return [media.path, media.current] as const
})
createEffect(
on(
svgInvalid,
(value) => {
if (!value) return
cfg()?.onError?.({ kind: "svg" })
},
{ defer: true },
),
)
const kindLabel = (value: "image" | "audio") =>
i18n.t(value === "image" ? "ui.fileMedia.kind.image" : "ui.fileMedia.kind.audio")
return (
<Switch>
<Match when={kind() === "image" || kind() === "audio"}>
<Show
when={src()}
fallback={(() => {
const media = cfg()
const k = kind()
if (!media || (k !== "image" && k !== "audio")) return props.fallback()
const label = kindLabel(k)
if (deleted()) {
return (
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
{i18n.t("ui.fileMedia.state.removed", { kind: label })}
</div>
)
}
if (status() === "loading") {
return (
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
{i18n.t("ui.fileMedia.state.loading", { kind: label })}
</div>
)
}
if (status() === "error") {
return (
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
{i18n.t("ui.fileMedia.state.error", { kind: label })}
</div>
)
}
return (
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
{i18n.t("ui.fileMedia.state.unavailable", { kind: label })}
</div>
)
})()}
>
{(value) => {
const k = kind()
if (k !== "image" && k !== "audio") return props.fallback()
if (k === "image") {
return (
<div class="flex justify-center bg-background-stronger px-6 py-4">
<img
src={value()}
alt={cfg()?.path}
class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
onLoad={onLoad}
/>
</div>
)
}
return (
<div class="flex justify-center bg-background-stronger px-6 py-4">
<audio class="w-full max-w-xl" controls preload="metadata" onLoadedMetadata={onLoad}>
<source src={value()} type={audioMime()} />
</audio>
</div>
)
}}
</Show>
</Match>
<Match when={kind() === "svg"}>
{(() => {
if (svgSource() === undefined && svgSrc() == null) return props.fallback()
return (
<div class="flex flex-col gap-4 px-6 py-4">
<Show when={svgSource() !== undefined}>{props.fallback()}</Show>
<Show when={svgSrc()}>
{(value) => (
<div class="flex justify-center">
<img
src={value()}
alt={cfg()?.path}
class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
onLoad={onLoad}
/>
</div>
)}
</Show>
</div>
)
})()}
</Match>
<Match when={isBinary()}>
<div class="flex min-h-56 flex-col items-center justify-center gap-2 px-6 py-10 text-center">
<div class="text-14-semibold text-text-strong">
{cfg()?.path?.split("/").pop() ?? i18n.t("ui.fileMedia.binary.title")}
</div>
<div class="text-14-regular text-text-weak">
{(() => {
const path = cfg()?.path
if (!path) return i18n.t("ui.fileMedia.binary.description.default")
return i18n.t("ui.fileMedia.binary.description.path", { path })
})()}
</div>
</div>
</Match>
<Match when={true}>{props.fallback()}</Match>
</Switch>
)
}

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; content-visibility: auto;
}
[data-component="file"][data-mode="text"] {
overflow: hidden;
}
[data-component="file"][data-mode="diff"] {
[data-slot="diff-hunk-separator-line-number"] { [data-slot="diff-hunk-separator-line-number"] {
position: sticky; position: sticky;
left: 0; left: 0;
@@ -17,6 +23,7 @@
color: var(--icon-strong-base); color: var(--icon-strong-base);
} }
} }
[data-slot="diff-hunk-separator-content"] { [data-slot="diff-hunk-separator-content"] {
position: sticky; position: sticky;
background-color: var(--surface-diff-hidden-base); background-color: var(--surface-diff-hidden-base);

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,9 +1,23 @@
export const lineCommentStyles = `
[data-annotation-slot] {
padding: 12px;
box-sizing: border-box;
}
[data-component="line-comment"] { [data-component="line-comment"] {
position: absolute; position: absolute;
right: 24px; right: 24px;
z-index: var(--line-comment-z, 30); z-index: var(--line-comment-z, 30);
} }
[data-component="line-comment"][data-inline] {
position: relative;
right: auto;
display: flex;
width: 100%;
align-items: flex-start;
}
[data-component="line-comment"][data-open] { [data-component="line-comment"][data-open] {
z-index: var(--line-comment-open-z, 100); z-index: var(--line-comment-open-z, 100);
} }
@@ -21,10 +35,20 @@
border: none; 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"] { [data-component="line-comment"] [data-component="icon"] {
color: var(--white); 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 { [data-component="line-comment"] [data-slot="line-comment-button"]:focus {
outline: none; outline: none;
} }
@@ -39,27 +63,56 @@
right: -8px; right: -8px;
z-index: var(--line-comment-popover-z, 40); z-index: var(--line-comment-popover-z, 40);
min-width: 200px; min-width: 200px;
max-width: min(320px, calc(100vw - 48px)); max-width: none;
border-radius: 8px; border-radius: 8px;
background: var(--surface-raised-stronger-non-alpha); background: var(--surface-raised-stronger-non-alpha);
box-shadow: var(--shadow-lg-border-base); box-shadow: var(--shadow-xxs-border);
padding: 12px; padding: 12px;
} }
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"] {
position: relative;
top: auto;
right: auto;
margin-left: 8px;
flex: 0 1 600px;
width: min(100%, 600px);
max-width: min(100%, 600px);
}
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"][data-inline-body] {
margin-left: 0;
}
[data-component="line-comment"][data-inline][data-variant="default"] [data-slot="line-comment-popover"][data-inline-body] {
cursor: pointer;
}
[data-component="line-comment"][data-variant="editor"] [data-slot="line-comment-popover"] { [data-component="line-comment"][data-variant="editor"] [data-slot="line-comment-popover"] {
width: 380px; width: 380px;
max-width: min(380px, calc(100vw - 48px)); max-width: none;
padding: 8px; padding: 8px;
border-radius: 14px; border-radius: 14px;
} }
[data-component="line-comment"][data-inline][data-variant="editor"] [data-slot="line-comment-popover"] {
flex-basis: 600px;
}
[data-component="line-comment"] [data-slot="line-comment-content"] { [data-component="line-comment"] [data-slot="line-comment-content"] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
} }
[data-component="line-comment"] [data-slot="line-comment-head"] {
display: flex;
align-items: flex-start;
gap: 8px;
}
[data-component="line-comment"] [data-slot="line-comment-text"] { [data-component="line-comment"] [data-slot="line-comment-text"] {
flex: 1;
font-family: var(--font-family-sans); font-family: var(--font-family-sans);
font-size: var(--font-size-base); font-size: var(--font-size-base);
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
@@ -69,6 +122,13 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
[data-component="line-comment"] [data-slot="line-comment-tools"] {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
}
[data-component="line-comment"] [data-slot="line-comment-label"], [data-component="line-comment"] [data-slot="line-comment-label"],
[data-component="line-comment"] [data-slot="line-comment-editor-label"] { [data-component="line-comment"] [data-slot="line-comment-editor-label"] {
font-family: var(--font-family-sans); font-family: var(--font-family-sans);
@@ -108,8 +168,56 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding-left: 8px;
} }
[data-component="line-comment"] [data-slot="line-comment-editor-label"] { [data-component="line-comment"] [data-slot="line-comment-editor-label"] {
margin-right: auto; 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 { Button } from "./button"
import { Icon } from "./icon" import { Icon } from "./icon"
import { installLineCommentStyles } from "./line-comment-styles"
import { useI18n } from "../context/i18n" 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 = { export type LineCommentAnchorProps = {
id?: string id?: string
top?: number top?: number
inline?: boolean
hideButton?: boolean
open: boolean open: boolean
variant?: LineCommentVariant variant?: LineCommentVariant
icon?: "comment" | "plus"
buttonLabel?: string
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent> onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
class?: string class?: string
popoverClass?: string popoverClass?: string
children: JSX.Element children?: JSX.Element
} }
export const LineCommentAnchor = (props: LineCommentAnchorProps) => { export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
const hidden = () => props.top === undefined const hidden = () => !props.inline && props.top === undefined
const variant = () => props.variant ?? "default" const variant = () => props.variant ?? "default"
const icon = () => props.icon ?? "comment"
const inlineBody = () => props.inline && props.hideButton
return ( return (
<div <div
data-component="line-comment" data-component="line-comment"
data-prevent-autofocus=""
data-variant={variant()} data-variant={variant()}
data-comment-id={props.id} data-comment-id={props.id}
data-open={props.open ? "" : undefined} data-open={props.open ? "" : undefined}
data-inline={props.inline ? "" : undefined}
classList={{ classList={{
[props.class ?? ""]: !!props.class, [props.class ?? ""]: !!props.class,
}} }}
style={{ style={
top: `${props.top ?? 0}px`, props.inline
opacity: hidden() ? 0 : 1, ? undefined
"pointer-events": hidden() ? "none" : "auto", : {
}} 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}> <Show
<Icon name="comment" size="small" /> when={inlineBody()}
</button> fallback={
<Show when={props.open}> <>
<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 <div
data-slot="line-comment-popover" data-slot="line-comment-popover"
data-inline-body=""
classList={{ classList={{
[props.popoverClass ?? ""]: !!props.popoverClass, [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} {props.children}
</div> </div>
@@ -58,16 +127,22 @@ export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
export type LineCommentProps = Omit<LineCommentAnchorProps, "children" | "variant"> & { export type LineCommentProps = Omit<LineCommentAnchorProps, "children" | "variant"> & {
comment: JSX.Element comment: JSX.Element
selection: JSX.Element selection: JSX.Element
actions?: JSX.Element
} }
export const LineComment = (props: LineCommentProps) => { export const LineComment = (props: LineCommentProps) => {
const i18n = useI18n() const i18n = useI18n()
const [split, rest] = splitProps(props, ["comment", "selection"]) const [split, rest] = splitProps(props, ["comment", "selection", "actions"])
return ( return (
<LineCommentAnchor {...rest} variant="default"> <LineCommentAnchor {...rest} variant="default" hideButton={props.inline}>
<div data-slot="line-comment-content"> <div data-slot="line-comment-content">
<div data-slot="line-comment-text">{split.comment}</div> <div data-slot="line-comment-head">
<div data-slot="line-comment-text">{split.comment}</div>
<Show when={split.actions}>
<div data-slot="line-comment-tools">{split.actions}</div>
</Show>
</div>
<div data-slot="line-comment-label"> <div data-slot="line-comment-label">
{i18n.t("ui.lineComment.label.prefix")} {i18n.t("ui.lineComment.label.prefix")}
{split.selection} {split.selection}
@@ -78,6 +153,25 @@ export const LineComment = (props: LineCommentProps) => {
) )
} }
export type LineCommentAddProps = Omit<LineCommentAnchorProps, "children" | "variant" | "open" | "icon"> & {
label?: string
}
export const LineCommentAdd = (props: LineCommentAddProps) => {
const [split, rest] = splitProps(props, ["label"])
const i18n = useI18n()
return (
<LineCommentAnchor
{...rest}
open={false}
variant="add"
icon="plus"
buttonLabel={split.label ?? i18n.t("ui.lineComment.submit")}
/>
)
}
export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & { export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
value: string value: string
selection: JSX.Element selection: JSX.Element
@@ -109,11 +203,16 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
const refs = { const refs = {
textarea: undefined as HTMLTextAreaElement | undefined, textarea: undefined as HTMLTextAreaElement | undefined,
} }
const [text, setText] = createSignal(split.value)
const focus = () => refs.textarea?.focus() const focus = () => refs.textarea?.focus()
createEffect(() => {
setText(split.value)
})
const submit = () => { const submit = () => {
const value = split.value.trim() const value = text().trim()
if (!value) return if (!value) return
split.onSubmit(value) split.onSubmit(value)
} }
@@ -124,7 +223,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
}) })
return ( 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"> <div data-slot="line-comment-editor">
<textarea <textarea
ref={(el) => { ref={(el) => {
@@ -133,19 +232,23 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
data-slot="line-comment-textarea" data-slot="line-comment-textarea"
rows={split.rows ?? 3} rows={split.rows ?? 3}
placeholder={split.placeholder ?? i18n.t("ui.lineComment.placeholder")} placeholder={split.placeholder ?? i18n.t("ui.lineComment.placeholder")}
value={split.value} value={text()}
onInput={(e) => split.onInput(e.currentTarget.value)} on:input={(e) => {
onKeyDown={(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") { if (e.key === "Escape") {
e.preventDefault() event.preventDefault()
e.stopPropagation()
split.onCancel() split.onCancel()
return return
} }
if (e.key !== "Enter") return if (e.key !== "Enter") return
if (e.shiftKey) return if (e.shiftKey) return
e.preventDefault() event.preventDefault()
e.stopPropagation()
submit() submit()
}} }}
/> />
@@ -155,12 +258,37 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
{split.selection} {split.selection}
{i18n.t("ui.lineComment.editorLabel.suffix")} {i18n.t("ui.lineComment.editorLabel.suffix")}
</div> </div>
<Button size="small" variant="ghost" onClick={split.onCancel}> <Show
{split.cancelLabel ?? i18n.t("ui.common.cancel")} when={!props.inline}
</Button> fallback={
<Button size="small" variant="primary" disabled={split.value.trim().length === 0} onClick={submit}> <>
{split.submitLabel ?? i18n.t("ui.lineComment.submit")} <button
</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>
</div> </div>
</LineCommentAnchor> </LineCommentAnchor>

View File

@@ -27,8 +27,7 @@ import {
QuestionInfo, QuestionInfo,
} from "@opencode-ai/sdk/v2" } from "@opencode-ai/sdk/v2"
import { useData } from "../context" import { useData } from "../context"
import { useDiffComponent } from "../context/diff" import { useFileComponent } from "../context/file"
import { useCodeComponent } from "../context/code"
import { useDialog } from "../context/dialog" import { useDialog } from "../context/dialog"
import { useI18n } from "../context/i18n" import { useI18n } from "../context/i18n"
import { BasicTool } from "./basic-tool" import { BasicTool } from "./basic-tool"
@@ -1452,7 +1451,7 @@ ToolRegistry.register({
name: "edit", name: "edit",
render(props) { render(props) {
const i18n = useI18n() const i18n = useI18n()
const diffComponent = useDiffComponent() const fileComponent = useFileComponent()
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "") const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "")
const filename = () => getFilename(props.input.filePath ?? "") const filename = () => getFilename(props.input.filePath ?? "")
@@ -1499,7 +1498,8 @@ ToolRegistry.register({
> >
<div data-component="edit-content"> <div data-component="edit-content">
<Dynamic <Dynamic
component={diffComponent} component={fileComponent}
mode="diff"
before={{ before={{
name: props.metadata?.filediff?.file || props.input.filePath, name: props.metadata?.filediff?.file || props.input.filePath,
contents: props.metadata?.filediff?.before || props.input.oldString, contents: props.metadata?.filediff?.before || props.input.oldString,
@@ -1523,7 +1523,7 @@ ToolRegistry.register({
name: "write", name: "write",
render(props) { render(props) {
const i18n = useI18n() const i18n = useI18n()
const codeComponent = useCodeComponent() const fileComponent = useFileComponent()
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
const path = createMemo(() => props.input.filePath || "") const path = createMemo(() => props.input.filePath || "")
const filename = () => getFilename(props.input.filePath ?? "") const filename = () => getFilename(props.input.filePath ?? "")
@@ -1561,7 +1561,8 @@ ToolRegistry.register({
<ToolFileAccordion path={path()}> <ToolFileAccordion path={path()}>
<div data-component="write-content"> <div data-component="write-content">
<Dynamic <Dynamic
component={codeComponent} component={fileComponent}
mode="text"
file={{ file={{
name: props.input.filePath, name: props.input.filePath,
contents: props.input.content, contents: props.input.content,
@@ -1595,7 +1596,7 @@ ToolRegistry.register({
name: "apply_patch", name: "apply_patch",
render(props) { render(props) {
const i18n = useI18n() const i18n = useI18n()
const diffComponent = useDiffComponent() const fileComponent = useFileComponent()
const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
const pending = createMemo(() => props.status === "pending" || props.status === "running") const pending = createMemo(() => props.status === "pending" || props.status === "running")
const single = createMemo(() => { const single = createMemo(() => {
@@ -1703,7 +1704,8 @@ ToolRegistry.register({
<Show when={visible()}> <Show when={visible()}>
<div data-component="apply-patch-file-diff"> <div data-component="apply-patch-file-diff">
<Dynamic <Dynamic
component={diffComponent} component={fileComponent}
mode="diff"
before={{ name: file.filePath, contents: file.before }} before={{ name: file.filePath, contents: file.before }}
after={{ name: file.movePath ?? file.filePath, contents: file.after }} after={{ name: file.movePath ?? file.filePath, contents: file.after }}
/> />
@@ -1780,7 +1782,8 @@ ToolRegistry.register({
> >
<div data-component="apply-patch-file-diff"> <div data-component="apply-patch-file-diff">
<Dynamic <Dynamic
component={diffComponent} component={fileComponent}
mode="diff"
before={{ name: file().filePath, contents: file().before }} before={{ name: file().filePath, contents: file().before }}
after={{ name: file().movePath ?? file().filePath, contents: file().after }} after={{ name: file().movePath ?? file().filePath, contents: file().after }}
/> />

View File

@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test"
import { buildSessionSearchHits, stepSessionSearchIndex } from "./session-review-search"
describe("session review search", () => {
test("builds hits with line, col, and side", () => {
const hits = buildSessionSearchHits({
query: "alpha",
files: [
{
file: "a.txt",
before: "alpha\nbeta alpha",
after: "ALPHA",
},
],
})
expect(hits).toEqual([
{ file: "a.txt", side: "deletions", line: 1, col: 1, len: 5 },
{ file: "a.txt", side: "deletions", line: 2, col: 6, len: 5 },
{ file: "a.txt", side: "additions", line: 1, col: 1, len: 5 },
])
})
test("uses non-overlapping matches", () => {
const hits = buildSessionSearchHits({
query: "aa",
files: [{ file: "a.txt", after: "aaaa" }],
})
expect(hits.map((hit) => hit.col)).toEqual([1, 3])
})
test("wraps next and previous navigation", () => {
expect(stepSessionSearchIndex(5, 0, -1)).toBe(4)
expect(stepSessionSearchIndex(5, 4, 1)).toBe(0)
expect(stepSessionSearchIndex(5, 2, 1)).toBe(3)
expect(stepSessionSearchIndex(0, 0, 1)).toBe(0)
})
})

View File

@@ -0,0 +1,59 @@
export type SessionSearchHit = {
file: string
side: "additions" | "deletions"
line: number
col: number
len: number
}
type SessionSearchFile = {
file: string
before?: string
after?: string
}
function hitsForSide(args: { file: string; side: SessionSearchHit["side"]; text: string; needle: string }) {
return args.text.split("\n").flatMap((line, i) => {
if (!line) return []
const hay = line.toLowerCase()
let at = hay.indexOf(args.needle)
if (at < 0) return []
const out: SessionSearchHit[] = []
while (at >= 0) {
out.push({
file: args.file,
side: args.side,
line: i + 1,
col: at + 1,
len: args.needle.length,
})
at = hay.indexOf(args.needle, at + args.needle.length)
}
return out
})
}
export function buildSessionSearchHits(args: { query: string; files: SessionSearchFile[] }) {
const value = args.query.trim().toLowerCase()
if (!value) return []
return args.files.flatMap((file) => {
const out: SessionSearchHit[] = []
if (typeof file.before === "string") {
out.push(...hitsForSide({ file: file.file, side: "deletions", text: file.before, needle: value }))
}
if (typeof file.after === "string") {
out.push(...hitsForSide({ file: file.file, side: "additions", text: file.after, needle: value }))
}
return out
})
}
export function stepSessionSearchIndex(total: number, current: number, dir: 1 | -1) {
if (total <= 0) return 0
if (current < 0 || current >= total) return dir > 0 ? 0 : total - 1
return (current + dir + total) % total
}

View File

@@ -200,50 +200,6 @@
color: var(--icon-diff-modified-base); color: var(--icon-diff-modified-base);
} }
[data-slot="session-review-file-container"] {
padding: 0;
}
[data-slot="session-review-image-container"] {
padding: 12px;
display: flex;
justify-content: center;
background: var(--background-stronger);
}
[data-slot="session-review-image"] {
max-width: 100%;
max-height: 60vh;
object-fit: contain;
border-radius: 8px;
border: 1px solid var(--border-weak-base);
background: var(--background-base);
}
[data-slot="session-review-image-placeholder"] {
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
color: var(--text-weak);
}
[data-slot="session-review-audio-container"] {
padding: 12px;
display: flex;
justify-content: center;
background: var(--background-stronger);
}
[data-slot="session-review-audio"] {
width: 100%;
max-width: 560px;
}
[data-slot="session-review-audio-placeholder"] {
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
color: var(--text-weak);
}
[data-slot="session-review-diff-wrapper"] { [data-slot="session-review-diff-wrapper"] {
position: relative; position: relative;
overflow: hidden; overflow: hidden;

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -13,6 +13,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "Diff كبير جدا لعرضه", "ui.sessionReview.largeDiff.title": "Diff كبير جدا لعرضه",
"ui.sessionReview.largeDiff.meta": "الحد: {{limit}} سطرًا متغيرًا. الحالي: {{current}} سطرًا متغيرًا.", "ui.sessionReview.largeDiff.meta": "الحد: {{limit}} سطرًا متغيرًا. الحالي: {{current}} سطرًا متغيرًا.",
"ui.sessionReview.largeDiff.renderAnyway": "اعرض على أي حال", "ui.sessionReview.largeDiff.renderAnyway": "اعرض على أي حال",
"ui.fileMedia.kind.image": "صورة",
"ui.fileMedia.kind.audio": "صوت",
"ui.fileMedia.state.removed": "تمت إزالة {{kind}}",
"ui.fileMedia.state.loading": "جاري تحميل {{kind}}...",
"ui.fileMedia.state.error": "خطأ في تحميل {{kind}}",
"ui.fileMedia.state.unavailable": "{{kind}} غير متوفر",
"ui.fileMedia.binary.title": "ملف ثنائي",
"ui.fileMedia.binary.description.path": "{{path}} عبارة عن ملف ثنائي ولا يمكن عرضه.",
"ui.fileMedia.binary.description.default": "هذا ملف ثنائي ولا يمكن عرضه.",
"ui.lineComment.label.prefix": "تعليق على ", "ui.lineComment.label.prefix": "تعليق على ",
"ui.lineComment.label.suffix": "", "ui.lineComment.label.suffix": "",

View File

@@ -13,6 +13,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "Diff grande demais para renderizar", "ui.sessionReview.largeDiff.title": "Diff grande demais para renderizar",
"ui.sessionReview.largeDiff.meta": "Limite: {{limit}} linhas alteradas. Atual: {{current}} linhas alteradas.", "ui.sessionReview.largeDiff.meta": "Limite: {{limit}} linhas alteradas. Atual: {{current}} linhas alteradas.",
"ui.sessionReview.largeDiff.renderAnyway": "Renderizar mesmo assim", "ui.sessionReview.largeDiff.renderAnyway": "Renderizar mesmo assim",
"ui.fileMedia.kind.image": "imagem",
"ui.fileMedia.kind.audio": "áudio",
"ui.fileMedia.state.removed": "Removido: {{kind}}",
"ui.fileMedia.state.loading": "Carregando {{kind}}...",
"ui.fileMedia.state.error": "Erro ao carregar {{kind}}",
"ui.fileMedia.state.unavailable": "{{kind}} indisponível",
"ui.fileMedia.binary.title": "Arquivo binário",
"ui.fileMedia.binary.description.path": "Não é possível exibir {{path}} porque é um arquivo binário.",
"ui.fileMedia.binary.description.default": "Não é possível exibir o arquivo porque ele é binário.",
"ui.lineComment.label.prefix": "Comentar em ", "ui.lineComment.label.prefix": "Comentar em ",
"ui.lineComment.label.suffix": "", "ui.lineComment.label.suffix": "",

View File

@@ -17,6 +17,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "Diff je prevelik za prikaz", "ui.sessionReview.largeDiff.title": "Diff je prevelik za prikaz",
"ui.sessionReview.largeDiff.meta": "Limit: {{limit}} izmijenjenih linija. Trenutno: {{current}} izmijenjenih linija.", "ui.sessionReview.largeDiff.meta": "Limit: {{limit}} izmijenjenih linija. Trenutno: {{current}} izmijenjenih linija.",
"ui.sessionReview.largeDiff.renderAnyway": "Prikaži svejedno", "ui.sessionReview.largeDiff.renderAnyway": "Prikaži svejedno",
"ui.fileMedia.kind.image": "slika",
"ui.fileMedia.kind.audio": "audio",
"ui.fileMedia.state.removed": "Uklonjeno: {{kind}}",
"ui.fileMedia.state.loading": "Učitavanje: {{kind}}...",
"ui.fileMedia.state.error": "Greška pri učitavanju: {{kind}}",
"ui.fileMedia.state.unavailable": "Nedostupno: {{kind}}",
"ui.fileMedia.binary.title": "Binarni fajl",
"ui.fileMedia.binary.description.path": "{{path}} se ne može prikazati jer je binarni fajl.",
"ui.fileMedia.binary.description.default": "Ovaj fajl se ne može prikazati jer je binarni.",
"ui.lineComment.label.prefix": "Komentar na ", "ui.lineComment.label.prefix": "Komentar na ",
"ui.lineComment.label.suffix": "", "ui.lineComment.label.suffix": "",

View File

@@ -14,6 +14,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "Diff er for stor til at blive vist", "ui.sessionReview.largeDiff.title": "Diff er for stor til at blive vist",
"ui.sessionReview.largeDiff.meta": "Grænse: {{limit}} ændrede linjer. Nuværende: {{current}} ændrede linjer.", "ui.sessionReview.largeDiff.meta": "Grænse: {{limit}} ændrede linjer. Nuværende: {{current}} ændrede linjer.",
"ui.sessionReview.largeDiff.renderAnyway": "Vis alligevel", "ui.sessionReview.largeDiff.renderAnyway": "Vis alligevel",
"ui.fileMedia.kind.image": "billede",
"ui.fileMedia.kind.audio": "lyd",
"ui.fileMedia.state.removed": "Fjernet: {{kind}}",
"ui.fileMedia.state.loading": "Indlæser {{kind}}...",
"ui.fileMedia.state.error": "Fejl ved indlæsning: {{kind}}",
"ui.fileMedia.state.unavailable": "Utilgængelig: {{kind}}",
"ui.fileMedia.binary.title": "Binær fil",
"ui.fileMedia.binary.description.path": "{{path}} kan ikke vises, fordi det er en binær fil.",
"ui.fileMedia.binary.description.default": "Denne fil kan ikke vises, fordi det er en binær fil.",
"ui.lineComment.label.prefix": "Kommenter på ", "ui.lineComment.label.prefix": "Kommenter på ",
"ui.lineComment.label.suffix": "", "ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Kommenterer på ", "ui.lineComment.editorLabel.prefix": "Kommenterer på ",

View File

@@ -18,6 +18,17 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "Diff zu groß zum Rendern", "ui.sessionReview.largeDiff.title": "Diff zu groß zum Rendern",
"ui.sessionReview.largeDiff.meta": "Limit: {{limit}} geänderte Zeilen. Aktuell: {{current}} geänderte Zeilen.", "ui.sessionReview.largeDiff.meta": "Limit: {{limit}} geänderte Zeilen. Aktuell: {{current}} geänderte Zeilen.",
"ui.sessionReview.largeDiff.renderAnyway": "Trotzdem rendern", "ui.sessionReview.largeDiff.renderAnyway": "Trotzdem rendern",
"ui.fileMedia.kind.image": "bild",
"ui.fileMedia.kind.audio": "audio",
"ui.fileMedia.state.removed": "{{kind}} entfernt",
"ui.fileMedia.state.loading": "{{kind}} wird geladen",
"ui.fileMedia.state.error": "Fehler bei {{kind}}",
"ui.fileMedia.state.unavailable": "{{kind}} nicht verfügbar",
"ui.fileMedia.binary.title": "Binärdatei",
"ui.fileMedia.binary.description.path":
"{{path}} kann nicht angezeigt werden, da es sich um eine Binärdatei handelt.",
"ui.fileMedia.binary.description.default":
"Diese Datei kann nicht angezeigt werden, da es sich um eine Binärdatei handelt.",
"ui.lineComment.label.prefix": "Kommentar zu ", "ui.lineComment.label.prefix": "Kommentar zu ",
"ui.lineComment.label.suffix": "", "ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Kommentiere ", "ui.lineComment.editorLabel.prefix": "Kommentiere ",

View File

@@ -14,6 +14,16 @@ export const dict = {
"ui.sessionReview.largeDiff.meta": "Limit: {{limit}} changed lines. Current: {{current}} changed lines.", "ui.sessionReview.largeDiff.meta": "Limit: {{limit}} changed lines. Current: {{current}} changed lines.",
"ui.sessionReview.largeDiff.renderAnyway": "Render anyway", "ui.sessionReview.largeDiff.renderAnyway": "Render anyway",
"ui.fileMedia.kind.image": "image",
"ui.fileMedia.kind.audio": "audio",
"ui.fileMedia.state.removed": "Removed {{kind}} file.",
"ui.fileMedia.state.loading": "Loading {{kind}}...",
"ui.fileMedia.state.error": "Unable to load {{kind}}.",
"ui.fileMedia.state.unavailable": "{{kind}} preview unavailable.",
"ui.fileMedia.binary.title": "Binary file",
"ui.fileMedia.binary.description.path": "{{path}} is binary.",
"ui.fileMedia.binary.description.default": "Binary content",
"ui.lineComment.label.prefix": "Comment on ", "ui.lineComment.label.prefix": "Comment on ",
"ui.lineComment.label.suffix": "", "ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Commenting on ", "ui.lineComment.editorLabel.prefix": "Commenting on ",

View File

@@ -13,6 +13,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "Diff demasiado grande para renderizar", "ui.sessionReview.largeDiff.title": "Diff demasiado grande para renderizar",
"ui.sessionReview.largeDiff.meta": "Límite: {{limit}} líneas modificadas. Actual: {{current}} líneas modificadas.", "ui.sessionReview.largeDiff.meta": "Límite: {{limit}} líneas modificadas. Actual: {{current}} líneas modificadas.",
"ui.sessionReview.largeDiff.renderAnyway": "Renderizar de todos modos", "ui.sessionReview.largeDiff.renderAnyway": "Renderizar de todos modos",
"ui.fileMedia.kind.image": "imagen",
"ui.fileMedia.kind.audio": "audio",
"ui.fileMedia.state.removed": "Archivo de {{kind}} eliminado",
"ui.fileMedia.state.loading": "Cargando archivo de {{kind}}",
"ui.fileMedia.state.error": "Error en el archivo de {{kind}}",
"ui.fileMedia.state.unavailable": "Archivo de {{kind}} no disponible",
"ui.fileMedia.binary.title": "Archivo binario",
"ui.fileMedia.binary.description.path": "No se puede mostrar {{path}} porque es un archivo binario.",
"ui.fileMedia.binary.description.default": "No se puede mostrar este archivo porque es un archivo binario.",
"ui.lineComment.label.prefix": "Comentar en ", "ui.lineComment.label.prefix": "Comentar en ",
"ui.lineComment.label.suffix": "", "ui.lineComment.label.suffix": "",

View File

@@ -13,6 +13,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "Diff trop volumineux pour être affiché", "ui.sessionReview.largeDiff.title": "Diff trop volumineux pour être affiché",
"ui.sessionReview.largeDiff.meta": "Limite : {{limit}} lignes modifiées. Actuel : {{current}} lignes modifiées.", "ui.sessionReview.largeDiff.meta": "Limite : {{limit}} lignes modifiées. Actuel : {{current}} lignes modifiées.",
"ui.sessionReview.largeDiff.renderAnyway": "Afficher quand même", "ui.sessionReview.largeDiff.renderAnyway": "Afficher quand même",
"ui.fileMedia.kind.image": "image",
"ui.fileMedia.kind.audio": "audio",
"ui.fileMedia.state.removed": "Fichier {{kind}} supprimé",
"ui.fileMedia.state.loading": "Chargement du fichier {{kind}}",
"ui.fileMedia.state.error": "Erreur avec le fichier {{kind}}",
"ui.fileMedia.state.unavailable": "Fichier {{kind}} indisponible",
"ui.fileMedia.binary.title": "Fichier binaire",
"ui.fileMedia.binary.description.path": "Impossible d'afficher {{path}} car il s'agit d'un fichier binaire.",
"ui.fileMedia.binary.description.default": "Impossible d'afficher ce fichier car il s'agit d'un fichier binaire.",
"ui.lineComment.label.prefix": "Commenter sur ", "ui.lineComment.label.prefix": "Commenter sur ",
"ui.lineComment.label.suffix": "", "ui.lineComment.label.suffix": "",

View File

@@ -14,6 +14,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "差分が大きすぎて表示できません", "ui.sessionReview.largeDiff.title": "差分が大きすぎて表示できません",
"ui.sessionReview.largeDiff.meta": "上限: {{limit}} 変更行。現在: {{current}} 変更行。", "ui.sessionReview.largeDiff.meta": "上限: {{limit}} 変更行。現在: {{current}} 変更行。",
"ui.sessionReview.largeDiff.renderAnyway": "それでも表示する", "ui.sessionReview.largeDiff.renderAnyway": "それでも表示する",
"ui.fileMedia.kind.image": "画像",
"ui.fileMedia.kind.audio": "音声",
"ui.fileMedia.state.removed": "{{kind}}は削除されました",
"ui.fileMedia.state.loading": "{{kind}}を読み込んでいます...",
"ui.fileMedia.state.error": "{{kind}}の読み込みに失敗しました",
"ui.fileMedia.state.unavailable": "{{kind}}は表示できません",
"ui.fileMedia.binary.title": "バイナリファイル",
"ui.fileMedia.binary.description.path": "{{path}} はバイナリファイルのため表示できません。",
"ui.fileMedia.binary.description.default": "このファイルはバイナリファイルのため表示できません。",
"ui.lineComment.label.prefix": "", "ui.lineComment.label.prefix": "",
"ui.lineComment.label.suffix": "へのコメント", "ui.lineComment.label.suffix": "へのコメント",
"ui.lineComment.editorLabel.prefix": "", "ui.lineComment.editorLabel.prefix": "",

View File

@@ -13,6 +13,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "차이가 너무 커서 렌더링할 수 없습니다", "ui.sessionReview.largeDiff.title": "차이가 너무 커서 렌더링할 수 없습니다",
"ui.sessionReview.largeDiff.meta": "제한: {{limit}} 변경 줄. 현재: {{current}} 변경 줄.", "ui.sessionReview.largeDiff.meta": "제한: {{limit}} 변경 줄. 현재: {{current}} 변경 줄.",
"ui.sessionReview.largeDiff.renderAnyway": "그래도 렌더링", "ui.sessionReview.largeDiff.renderAnyway": "그래도 렌더링",
"ui.fileMedia.kind.image": "이미지",
"ui.fileMedia.kind.audio": "오디오",
"ui.fileMedia.state.removed": "{{kind}} 제거됨",
"ui.fileMedia.state.loading": "{{kind}} 로드 중...",
"ui.fileMedia.state.error": "{{kind}} 로드 오류",
"ui.fileMedia.state.unavailable": "{{kind}} 사용 불가",
"ui.fileMedia.binary.title": "바이너리 파일",
"ui.fileMedia.binary.description.path": "{{path}}은(는) 바이너리 파일이므로 표시할 수 없습니다.",
"ui.fileMedia.binary.description.default": "바이너리 파일이므로 표시할 수 없습니다.",
"ui.lineComment.label.prefix": "", "ui.lineComment.label.prefix": "",
"ui.lineComment.label.suffix": "에 댓글 달기", "ui.lineComment.label.suffix": "에 댓글 달기",

View File

@@ -16,6 +16,15 @@ export const dict: Record<Keys, string> = {
"ui.sessionReview.largeDiff.title": "Diff er for stor til å gjengi", "ui.sessionReview.largeDiff.title": "Diff er for stor til å gjengi",
"ui.sessionReview.largeDiff.meta": "Grense: {{limit}} endrede linjer. Nåværende: {{current}} endrede linjer.", "ui.sessionReview.largeDiff.meta": "Grense: {{limit}} endrede linjer. Nåværende: {{current}} endrede linjer.",
"ui.sessionReview.largeDiff.renderAnyway": "Gjengi likevel", "ui.sessionReview.largeDiff.renderAnyway": "Gjengi likevel",
"ui.fileMedia.kind.image": "bilde",
"ui.fileMedia.kind.audio": "lyd",
"ui.fileMedia.state.removed": "Fjernet: {{kind}}",
"ui.fileMedia.state.loading": "Laster inn {{kind}}...",
"ui.fileMedia.state.error": "Feil ved innlasting: {{kind}}",
"ui.fileMedia.state.unavailable": "Ikke tilgjengelig: {{kind}}",
"ui.fileMedia.binary.title": "Binærfil",
"ui.fileMedia.binary.description.path": "{{path}} kan ikke vises fordi det er en binærfil.",
"ui.fileMedia.binary.description.default": "Denne filen kan ikke vises fordi det er en binærfil.",
"ui.lineComment.label.prefix": "Kommenter på ", "ui.lineComment.label.prefix": "Kommenter på ",
"ui.lineComment.label.suffix": "", "ui.lineComment.label.suffix": "",

View File

@@ -14,6 +14,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "Diff jest zbyt duży, aby go wyrenderować", "ui.sessionReview.largeDiff.title": "Diff jest zbyt duży, aby go wyrenderować",
"ui.sessionReview.largeDiff.meta": "Limit: {{limit}} zmienionych linii. Obecnie: {{current}} zmienionych linii.", "ui.sessionReview.largeDiff.meta": "Limit: {{limit}} zmienionych linii. Obecnie: {{current}} zmienionych linii.",
"ui.sessionReview.largeDiff.renderAnyway": "Renderuj mimo to", "ui.sessionReview.largeDiff.renderAnyway": "Renderuj mimo to",
"ui.fileMedia.kind.image": "obraz",
"ui.fileMedia.kind.audio": "dźwięk",
"ui.fileMedia.state.removed": "{{kind}} usunięty",
"ui.fileMedia.state.loading": "Wczytywanie: {{kind}}...",
"ui.fileMedia.state.error": "Błąd wczytywania: {{kind}}",
"ui.fileMedia.state.unavailable": "{{kind}} niedostępny",
"ui.fileMedia.binary.title": "Plik binarny",
"ui.fileMedia.binary.description.path": "Nie można wyświetlić pliku {{path}}, ponieważ jest to plik binarny.",
"ui.fileMedia.binary.description.default": "Nie można wyświetlić tego pliku, ponieważ jest to plik binarny.",
"ui.lineComment.label.prefix": "Komentarz do ", "ui.lineComment.label.prefix": "Komentarz do ",
"ui.lineComment.label.suffix": "", "ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Komentowanie: ", "ui.lineComment.editorLabel.prefix": "Komentowanie: ",

View File

@@ -14,6 +14,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "Diff слишком большой для отображения", "ui.sessionReview.largeDiff.title": "Diff слишком большой для отображения",
"ui.sessionReview.largeDiff.meta": "Лимит: {{limit}} изменённых строк. Текущий: {{current}} изменённых строк.", "ui.sessionReview.largeDiff.meta": "Лимит: {{limit}} изменённых строк. Текущий: {{current}} изменённых строк.",
"ui.sessionReview.largeDiff.renderAnyway": "Отобразить всё равно", "ui.sessionReview.largeDiff.renderAnyway": "Отобразить всё равно",
"ui.fileMedia.kind.image": "изображение",
"ui.fileMedia.kind.audio": "аудио",
"ui.fileMedia.state.removed": "{{kind}} удалено",
"ui.fileMedia.state.loading": "Загружается {{kind}}...",
"ui.fileMedia.state.error": "Не удалось загрузить {{kind}}",
"ui.fileMedia.state.unavailable": "{{kind}} недоступно",
"ui.fileMedia.binary.title": "Бинарный файл",
"ui.fileMedia.binary.description.path": "Невозможно отобразить {{path}}, так как это бинарный файл.",
"ui.fileMedia.binary.description.default": "Невозможно отобразить этот файл, так как он бинарный.",
"ui.lineComment.label.prefix": "Комментарий к ", "ui.lineComment.label.prefix": "Комментарий к ",
"ui.lineComment.label.suffix": "", "ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Комментирование: ", "ui.lineComment.editorLabel.prefix": "Комментирование: ",

View File

@@ -14,6 +14,15 @@ export const dict = {
"ui.sessionReview.largeDiff.meta": "ui.sessionReview.largeDiff.meta":
"ขีดจำกัด: {{limit}} บรรทัดที่เปลี่ยนแปลง. ปัจจุบัน: {{current}} บรรทัดที่เปลี่ยนแปลง.", "ขีดจำกัด: {{limit}} บรรทัดที่เปลี่ยนแปลง. ปัจจุบัน: {{current}} บรรทัดที่เปลี่ยนแปลง.",
"ui.sessionReview.largeDiff.renderAnyway": "แสดงผลต่อไป", "ui.sessionReview.largeDiff.renderAnyway": "แสดงผลต่อไป",
"ui.fileMedia.kind.image": "รูปภาพ",
"ui.fileMedia.kind.audio": "เสียง",
"ui.fileMedia.state.removed": "ลบ{{kind}}แล้ว",
"ui.fileMedia.state.loading": "กำลังโหลด{{kind}}...",
"ui.fileMedia.state.error": "เกิดข้อผิดพลาดในการโหลด{{kind}}",
"ui.fileMedia.state.unavailable": "{{kind}}ไม่พร้อมใช้งาน",
"ui.fileMedia.binary.title": "ไฟล์ไบนารี",
"ui.fileMedia.binary.description.path": "{{path}} เป็นไฟล์ไบนารีและไม่สามารถแสดงผลได้",
"ui.fileMedia.binary.description.default": "ไฟล์ไบนารีไม่สามารถแสดงผลได้",
"ui.lineComment.label.prefix": "แสดงความคิดเห็นบน ", "ui.lineComment.label.prefix": "แสดงความคิดเห็นบน ",
"ui.lineComment.label.suffix": "", "ui.lineComment.label.suffix": "",

View File

@@ -17,6 +17,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "差异过大,无法渲染", "ui.sessionReview.largeDiff.title": "差异过大,无法渲染",
"ui.sessionReview.largeDiff.meta": "限制:{{limit}} 行变更。当前:{{current}} 行变更。", "ui.sessionReview.largeDiff.meta": "限制:{{limit}} 行变更。当前:{{current}} 行变更。",
"ui.sessionReview.largeDiff.renderAnyway": "仍然渲染", "ui.sessionReview.largeDiff.renderAnyway": "仍然渲染",
"ui.fileMedia.kind.image": "图片",
"ui.fileMedia.kind.audio": "音频",
"ui.fileMedia.state.removed": "{{kind}}已移除",
"ui.fileMedia.state.loading": "正在加载{{kind}}...",
"ui.fileMedia.state.error": "加载{{kind}}失败",
"ui.fileMedia.state.unavailable": "{{kind}}不可预览",
"ui.fileMedia.binary.title": "二进制文件",
"ui.fileMedia.binary.description.path": "无法显示 {{path}},因为它是二进制文件。",
"ui.fileMedia.binary.description.default": "无法显示此文件,因为它是二进制文件。",
"ui.lineComment.label.prefix": "评论 ", "ui.lineComment.label.prefix": "评论 ",
"ui.lineComment.label.suffix": "", "ui.lineComment.label.suffix": "",

View File

@@ -17,6 +17,15 @@ export const dict = {
"ui.sessionReview.largeDiff.title": "差異過大,無法渲染", "ui.sessionReview.largeDiff.title": "差異過大,無法渲染",
"ui.sessionReview.largeDiff.meta": "限制:{{limit}} 行變更。目前:{{current}} 行變更。", "ui.sessionReview.largeDiff.meta": "限制:{{limit}} 行變更。目前:{{current}} 行變更。",
"ui.sessionReview.largeDiff.renderAnyway": "仍然渲染", "ui.sessionReview.largeDiff.renderAnyway": "仍然渲染",
"ui.fileMedia.kind.image": "圖片",
"ui.fileMedia.kind.audio": "音訊",
"ui.fileMedia.state.removed": "{{kind}}已移除",
"ui.fileMedia.state.loading": "正在載入{{kind}}...",
"ui.fileMedia.state.error": "載入{{kind}}失敗",
"ui.fileMedia.state.unavailable": "{{kind}}無法預覽",
"ui.fileMedia.binary.title": "二進位檔案",
"ui.fileMedia.binary.description.path": "無法顯示 {{path}},因為它是二進位檔案。",
"ui.fileMedia.binary.description.default": "無法顯示此檔案,因為它是二進位檔案。",
"ui.lineComment.label.prefix": "評論 ", "ui.lineComment.label.prefix": "評論 ",
"ui.lineComment.label.suffix": "", "ui.lineComment.label.suffix": "",

View File

@@ -0,0 +1,74 @@
export type HoverCommentLine = {
lineNumber: number
side?: "additions" | "deletions"
}
export function createHoverCommentUtility(props: {
label: string
getHoveredLine: () => HoverCommentLine | undefined
onSelect: (line: HoverCommentLine) => void
}) {
if (typeof document === "undefined") return
const button = document.createElement("button")
button.type = "button"
button.ariaLabel = props.label
button.textContent = "+"
button.style.width = "20px"
button.style.height = "20px"
button.style.display = "flex"
button.style.alignItems = "center"
button.style.justifyContent = "center"
button.style.border = "none"
button.style.borderRadius = "var(--radius-md)"
button.style.background = "var(--icon-interactive-base)"
button.style.color = "var(--white)"
button.style.boxShadow = "var(--shadow-xs)"
button.style.fontSize = "14px"
button.style.lineHeight = "1"
button.style.cursor = "pointer"
button.style.position = "relative"
button.style.left = "30px"
button.style.top = "calc((var(--diffs-line-height, 24px) - 20px) / 2)"
let line: HoverCommentLine | undefined
const sync = () => {
const next = props.getHoveredLine()
if (!next) return
line = next
}
const loop = () => {
if (!button.isConnected) return
sync()
requestAnimationFrame(loop)
}
const open = () => {
const next = props.getHoveredLine() ?? line
if (!next) return
props.onSelect(next)
}
requestAnimationFrame(loop)
button.addEventListener("mouseenter", sync)
button.addEventListener("mousemove", sync)
button.addEventListener("pointerdown", (event) => {
event.preventDefault()
event.stopPropagation()
sync()
})
button.addEventListener("mousedown", (event) => {
event.preventDefault()
event.stopPropagation()
sync()
})
button.addEventListener("click", (event) => {
event.preventDefault()
event.stopPropagation()
open()
})
return button
}

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,576 @@
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
export type FindHost = {
element: () => HTMLElement | undefined
open: () => void
close: () => void
next: (dir: 1 | -1) => void
isOpen: () => boolean
}
type FileFindSide = "additions" | "deletions"
export type FileFindReveal = {
side: FileFindSide
line: number
col: number
len: number
}
type FileFindHit = FileFindReveal & {
range: Range
alt?: number
}
const hosts = new Set<FindHost>()
let target: FindHost | undefined
let current: FindHost | undefined
let installed = false
function isEditable(node: unknown): boolean {
if (!(node instanceof HTMLElement)) return false
if (node.closest("[data-prevent-autofocus]")) return true
if (node.isContentEditable) return true
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName)
}
function hostForNode(node: unknown) {
if (!(node instanceof Node)) return
for (const host of hosts) {
const el = host.element()
if (el && el.isConnected && el.contains(node)) return host
}
}
function installShortcuts() {
if (installed) return
if (typeof window === "undefined") return
installed = true
window.addEventListener(
"keydown",
(event) => {
if (event.defaultPrevented) return
if (isEditable(event.target)) return
const mod = event.metaKey || event.ctrlKey
if (!mod) return
const key = event.key.toLowerCase()
if (key === "g") {
const host = current
if (!host || !host.isOpen()) return
event.preventDefault()
event.stopPropagation()
host.next(event.shiftKey ? -1 : 1)
return
}
if (key !== "f") return
const active = current
if (active && active.isOpen()) {
event.preventDefault()
event.stopPropagation()
active.open()
return
}
const host = hostForNode(document.activeElement) ?? hostForNode(event.target) ?? target ?? Array.from(hosts)[0]
if (!host) return
event.preventDefault()
event.stopPropagation()
host.open()
},
{ capture: true },
)
}
function clearHighlightFind() {
const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights
if (!api) return
api.delete("opencode-find")
api.delete("opencode-find-current")
}
function supportsHighlights() {
const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown }
return typeof g.Highlight === "function" && g.CSS?.highlights != null
}
function scrollParent(el: HTMLElement): HTMLElement | undefined {
let parent = el.parentElement
while (parent) {
const style = getComputedStyle(parent)
if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
parent = parent.parentElement
}
}
type CreateFileFindOptions = {
wrapper: () => HTMLElement | undefined
overlay: () => HTMLDivElement | undefined
getRoot: () => ShadowRoot | undefined
shortcuts?: "global" | "disabled"
}
export function createFileFind(opts: CreateFileFindOptions) {
let input: HTMLInputElement | undefined
let overlayFrame: number | undefined
let overlayScroll: HTMLElement[] = []
let mode: "highlights" | "overlay" = "overlay"
let hits: FileFindHit[] = []
const [open, setOpen] = createSignal(false)
const [query, setQuery] = createSignal("")
const [index, setIndex] = createSignal(0)
const [count, setCount] = createSignal(0)
const [pos, setPos] = createSignal({ top: 8, right: 8 })
const clearOverlayScroll = () => {
for (const el of overlayScroll) el.removeEventListener("scroll", scheduleOverlay)
overlayScroll = []
}
const clearOverlay = () => {
const el = opts.overlay()
if (!el) return
if (overlayFrame !== undefined) {
cancelAnimationFrame(overlayFrame)
overlayFrame = undefined
}
el.innerHTML = ""
}
const renderOverlay = () => {
if (mode !== "overlay") {
clearOverlay()
return
}
const wrapper = opts.wrapper()
const overlay = opts.overlay()
if (!wrapper || !overlay) return
clearOverlay()
if (hits.length === 0) return
const base = wrapper.getBoundingClientRect()
const currentIndex = index()
const frag = document.createDocumentFragment()
for (let i = 0; i < hits.length; i++) {
const range = hits[i].range
const active = i === currentIndex
for (const rect of Array.from(range.getClientRects())) {
if (!rect.width || !rect.height) continue
const mark = document.createElement("div")
mark.style.position = "absolute"
mark.style.left = `${Math.round(rect.left - base.left)}px`
mark.style.top = `${Math.round(rect.top - base.top)}px`
mark.style.width = `${Math.round(rect.width)}px`
mark.style.height = `${Math.round(rect.height)}px`
mark.style.borderRadius = "2px"
mark.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)"
mark.style.opacity = active ? "0.55" : "0.35"
if (active) mark.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)"
frag.appendChild(mark)
}
}
overlay.appendChild(frag)
}
function scheduleOverlay() {
if (mode !== "overlay") return
if (!open()) return
if (overlayFrame !== undefined) return
overlayFrame = requestAnimationFrame(() => {
overlayFrame = undefined
renderOverlay()
})
}
const syncOverlayScroll = () => {
if (mode !== "overlay") return
const root = opts.getRoot()
const next = root
? Array.from(root.querySelectorAll("[data-code]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
: []
if (next.length === overlayScroll.length && next.every((el, i) => el === overlayScroll[i])) return
clearOverlayScroll()
overlayScroll = next
for (const el of overlayScroll) el.addEventListener("scroll", scheduleOverlay, { passive: true })
}
const clearFind = () => {
clearHighlightFind()
clearOverlay()
clearOverlayScroll()
hits = []
setCount(0)
setIndex(0)
}
const positionBar = () => {
if (typeof window === "undefined") return
const wrapper = opts.wrapper()
if (!wrapper) return
const root = scrollParent(wrapper) ?? wrapper
const rect = root.getBoundingClientRect()
const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
const header = Number.isNaN(title) ? 0 : title
setPos({
top: Math.round(rect.top) + header - 4,
right: Math.round(window.innerWidth - rect.right) + 8,
})
}
const scan = (root: ShadowRoot, value: string) => {
const needle = value.toLowerCase()
const ranges: FileFindHit[] = []
const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
for (const col of cols) {
const text = col.textContent
if (!text) continue
const hay = text.toLowerCase()
let at = hay.indexOf(needle)
if (at === -1) continue
const row = col.closest("[data-line], [data-alt-line]")
if (!(row instanceof HTMLElement)) continue
const primary = parseInt(row.dataset.line ?? "", 10)
const alt = parseInt(row.dataset.altLine ?? "", 10)
const line = (() => {
if (!Number.isNaN(primary)) return primary
if (!Number.isNaN(alt)) return alt
})()
if (line === undefined) continue
const side = (() => {
const code = col.closest("[data-code]")
if (code instanceof HTMLElement) return code.hasAttribute("data-deletions") ? "deletions" : "additions"
const row = col.closest("[data-line-type]")
if (!(row instanceof HTMLElement)) return "additions"
const type = row.dataset.lineType
if (type === "change-deletion") return "deletions"
return "additions"
})() as FileFindSide
const nodes: Text[] = []
const ends: number[] = []
const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT)
let node = walker.nextNode()
let pos = 0
while (node) {
if (node instanceof Text) {
pos += node.data.length
nodes.push(node)
ends.push(pos)
}
node = walker.nextNode()
}
if (nodes.length === 0) continue
const locate = (offset: number) => {
let lo = 0
let hi = ends.length - 1
while (lo < hi) {
const mid = (lo + hi) >> 1
if (ends[mid] >= offset) hi = mid
else lo = mid + 1
}
const prev = lo === 0 ? 0 : ends[lo - 1]
return { node: nodes[lo], offset: offset - prev }
}
while (at !== -1) {
const start = locate(at)
const end = locate(at + value.length)
const range = document.createRange()
range.setStart(start.node, start.offset)
range.setEnd(end.node, end.offset)
ranges.push({
range,
side,
line,
alt: Number.isNaN(alt) ? undefined : alt,
col: at + 1,
len: value.length,
})
at = hay.indexOf(needle, at + value.length)
}
}
return ranges
}
const scrollToRange = (range: Range) => {
const scroll = () => {
const start = range.startContainer
const el = start instanceof Element ? start : start.parentElement
el?.scrollIntoView({ block: "center", inline: "center" })
}
scroll()
requestAnimationFrame(scroll)
}
const setHighlights = (ranges: FileFindHit[], currentIndex: number) => {
const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights
const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight
if (!api || typeof Highlight !== "function") return false
api.delete("opencode-find")
api.delete("opencode-find-current")
const active = ranges[currentIndex]?.range
if (active) api.set("opencode-find-current", new Highlight(active))
const rest = ranges.flatMap((hit, i) => (i === currentIndex ? [] : [hit.range]))
if (rest.length > 0) api.set("opencode-find", new Highlight(...rest))
return true
}
const select = (currentIndex: number, scroll: boolean) => {
const active = hits[currentIndex]?.range
if (!active) return false
setIndex(currentIndex)
if (mode === "highlights") {
if (!setHighlights(hits, currentIndex)) {
mode = "overlay"
apply({ reset: true, scroll })
return false
}
if (scroll) scrollToRange(active)
return true
}
clearHighlightFind()
syncOverlayScroll()
if (scroll) scrollToRange(active)
scheduleOverlay()
return true
}
const apply = (args?: { reset?: boolean; scroll?: boolean }) => {
if (!open()) return
const value = query().trim()
if (!value) {
clearFind()
return
}
const root = opts.getRoot()
if (!root) return
mode = supportsHighlights() ? "highlights" : "overlay"
const ranges = scan(root, value)
const total = ranges.length
const desired = args?.reset ? 0 : index()
const currentIndex = total ? Math.min(desired, total - 1) : 0
hits = ranges
setCount(total)
setIndex(currentIndex)
const active = ranges[currentIndex]?.range
if (mode === "highlights") {
clearOverlay()
clearOverlayScroll()
if (!setHighlights(ranges, currentIndex)) {
mode = "overlay"
clearHighlightFind()
syncOverlayScroll()
scheduleOverlay()
}
if (args?.scroll && active) scrollToRange(active)
return
}
clearHighlightFind()
syncOverlayScroll()
if (args?.scroll && active) scrollToRange(active)
scheduleOverlay()
}
const close = () => {
setOpen(false)
setQuery("")
clearFind()
if (current === host) current = undefined
}
const clear = () => {
setQuery("")
clearFind()
}
const activate = () => {
if (opts.shortcuts !== "disabled") {
if (current && current !== host) current.close()
current = host
target = host
}
if (!open()) setOpen(true)
}
const focus = () => {
activate()
requestAnimationFrame(() => {
apply({ scroll: true })
input?.focus()
input?.select()
})
}
const next = (dir: 1 | -1) => {
if (!open()) return
const total = count()
if (total <= 0) return
const currentIndex = (index() + dir + total) % total
select(currentIndex, true)
}
const reveal = (targetHit: FileFindReveal) => {
if (!open()) return false
if (hits.length === 0) return false
const exact = hits.findIndex(
(hit) =>
hit.side === targetHit.side &&
hit.line === targetHit.line &&
hit.col === targetHit.col &&
hit.len === targetHit.len,
)
const fallback = hits.findIndex(
(hit) =>
(hit.line === targetHit.line || hit.alt === targetHit.line) &&
hit.col === targetHit.col &&
hit.len === targetHit.len,
)
const nextIndex = exact >= 0 ? exact : fallback
if (nextIndex < 0) return false
return select(nextIndex, true)
}
const host: FindHost = {
element: opts.wrapper,
isOpen: () => open(),
next,
open: focus,
close,
}
onMount(() => {
mode = supportsHighlights() ? "highlights" : "overlay"
if (opts.shortcuts !== "disabled") {
installShortcuts()
hosts.add(host)
if (!target) target = host
}
onCleanup(() => {
if (opts.shortcuts !== "disabled") {
hosts.delete(host)
if (current === host) {
current = undefined
clearHighlightFind()
}
if (target === host) target = undefined
}
})
})
createEffect(() => {
if (!open()) return
const update = () => positionBar()
requestAnimationFrame(update)
window.addEventListener("resize", update, { passive: true })
const wrapper = opts.wrapper()
if (!wrapper) return
const root = scrollParent(wrapper) ?? wrapper
const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(() => update())
observer?.observe(root)
onCleanup(() => {
window.removeEventListener("resize", update)
observer?.disconnect()
})
})
onCleanup(() => {
clearOverlayScroll()
clearOverlay()
if (current === host) {
current = undefined
clearHighlightFind()
}
})
return {
open,
query,
count,
index,
pos,
setInput: (el: HTMLInputElement) => {
input = el
},
setQuery: (value: string, args?: { scroll?: boolean }) => {
setQuery(value)
setIndex(0)
apply({ reset: true, scroll: args?.scroll ?? true })
},
clear,
activate,
focus,
close,
next,
reveal,
refresh: (args?: { reset?: boolean; scroll?: boolean }) => apply(args),
onPointerDown: () => {
if (opts.shortcuts === "disabled") return
target = host
opts.wrapper()?.focus({ preventScroll: true })
},
onFocus: () => {
if (opts.shortcuts === "disabled") return
target = host
},
onInputKeyDown: (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault()
close()
return
}
if (event.key !== "Enter") return
event.preventDefault()
next(event.shiftKey ? -1 : 1)
},
}
}

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 { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs"
import { ComponentProps } from "solid-js" import { ComponentProps } from "solid-js"
import { lineCommentStyles } from "../components/line-comment-styles"
export type DiffProps<T = {}> = FileDiffOptions<T> & { export type DiffProps<T = {}> = FileDiffOptions<T> & {
before: FileContents before: FileContents
@@ -7,13 +8,15 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
annotations?: DiffLineAnnotation<T>[] annotations?: DiffLineAnnotation<T>[]
selectedLines?: SelectedLineRange | null selectedLines?: SelectedLineRange | null
commentedLines?: SelectedLineRange[] commentedLines?: SelectedLineRange[]
onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
onRendered?: () => void onRendered?: () => void
class?: string class?: string
classList?: ComponentProps<"div">["classList"] classList?: ComponentProps<"div">["classList"]
} }
const unsafeCSS = ` const unsafeCSS = `
[data-diff] { [data-diff],
[data-file] {
--diffs-bg: light-dark(var(--diffs-light-bg), var(--diffs-dark-bg)); --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-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)))); --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); --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-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: var(--diffs-bg-selection-override, rgb(from var(--solaris-dark-6) r g b / 0.65));
--diffs-bg-selection-number: var( --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); background-color: var(--diffs-bg-selection-text);
} }
@@ -69,25 +74,48 @@ const unsafeCSS = `
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection); 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]) { [data-diff] [data-column-number][data-comment-selected]:not([data-selected-line]) {
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number); box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
color: var(--diffs-selection-number-fg); 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] { [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); 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] { [data-diff] [data-line][data-selected-line] {
background-color: var(--diffs-bg-selection); background-color: var(--diffs-bg-selection);
box-shadow: inset 2px 0 0 var(--diffs-selection-border); 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] { [data-diff] [data-column-number][data-selected-line] {
background-color: var(--diffs-bg-selection-number); background-color: var(--diffs-bg-selection-number);
color: var(--diffs-selection-number-fg); 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'][data-selected-line],
[data-diff] [data-column-number][data-line-type='context-expanded'][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], [data-diff] [data-column-number][data-line-type='change-addition'][data-selected-line],
@@ -123,9 +151,13 @@ const unsafeCSS = `
} }
[data-code] { [data-code] {
overflow-x: auto !important; overflow-x: auto !important;
overflow-y: hidden !important; overflow-y: clip !important;
} }
}` }
${lineCommentStyles}
`
export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) { export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) {
return { 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/button.css" layer(components);
@import "../components/card.css" layer(components); @import "../components/card.css" layer(components);
@import "../components/checkbox.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/collapsible.css" layer(components);
@import "../components/diff.css" layer(components);
@import "../components/diff-changes.css" layer(components); @import "../components/diff-changes.css" layer(components);
@import "../components/context-menu.css" layer(components); @import "../components/context-menu.css" layer(components);
@import "../components/dropdown-menu.css" layer(components); @import "../components/dropdown-menu.css" layer(components);
@@ -28,7 +27,6 @@
@import "../components/icon-button.css" layer(components); @import "../components/icon-button.css" layer(components);
@import "../components/image-preview.css" layer(components); @import "../components/image-preview.css" layer(components);
@import "../components/keybind.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/text-field.css" layer(components);
@import "../components/inline-input.css" layer(components); @import "../components/inline-input.css" layer(components);
@import "../components/list.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.

View File

@@ -0,0 +1,234 @@
# Session Review Cross-Diff Search Plan
One search input for all diffs in the review pane
---
## Goal
Add a single search UI to `SessionReview` that searches across all diff files in the accordion and supports next/previous navigation across files.
Navigation should auto-open the target accordion item and reveal the active match inside the existing unified `File` diff viewer.
---
## Non-goals
- Do not change diff rendering visuals, line comments, or file selection behavior.
- Do not add regex, fuzzy search, or replace.
- Do not change `@pierre/diffs` internals.
---
## Current behavior
- `SessionReview` renders one `File` diff viewer per accordion item, but only mounts the viewer when that item is expanded.
- Large diffs may be blocked behind the `MAX_DIFF_CHANGED_LINES` gate until the user clicks "render anyway".
- `File` owns a local search engine (`createFileFind`) with:
- query state
- hit counting
- current match index
- highlighting (CSS Highlight API or overlay fallback)
- `Cmd/Ctrl+F` and `Cmd/Ctrl+G` keyboard handling
- `FileSearchBar` is currently rendered per viewer.
- There is no parent-level search state in `SessionReview`.
---
## UX requirements
- Add one search bar in the `SessionReview` header (input, total count, prev, next, close).
- Show a global count like `3/17` across all searchable diffs.
- `Cmd/Ctrl+F` inside the session review pane opens the session-level search.
- `Cmd/Ctrl+G`, `Shift+Cmd/Ctrl+G`, `Enter`, and `Shift+Enter` navigate globally.
- Navigating to a match in a collapsed file auto-expands that file.
- The active match scrolls into view and is highlighted in the target viewer.
- Media/binary diffs are excluded from search.
- Empty query clears highlights and resets to `0/0`.
---
## Architecture proposal
Use a hybrid model:
- A **session-level match index** for global searching/counting/navigation across all diffs.
- The existing **per-viewer search engine** for local highlighting and scrolling in the active file.
This avoids mounting every accordion item just to search while reusing the existing DOM highlight behavior.
### High-level pieces
- `SessionReview` owns the global query, hit list, and active hit index.
- `File` exposes a small controlled search handle (register, set query, clear, reveal hit).
- `SessionReview` keeps a map of mounted file viewers and their search handles.
- `SessionReview` resolves next/prev hits, expands files as needed, then tells the target viewer to reveal the hit.
---
## Data model and interfaces
```ts
type SessionSearchHit = {
file: string
side: "additions" | "deletions"
line: number
col: number
len: number
}
type SessionSearchState = {
query: string
hits: SessionSearchHit[]
active: number
}
```
```ts
type FileSearchReveal = {
side: "additions" | "deletions"
line: number
col: number
len: number
}
type FileSearchHandle = {
setQuery: (value: string) => void
clear: () => void
reveal: (hit: FileSearchReveal) => boolean
refresh: () => void
}
```
```ts
type FileSearchControl = {
shortcuts?: "global" | "disabled"
showBar?: boolean
register: (handle: FileSearchHandle | null) => void
}
```
---
## Integration steps
### Phase 1: Expose controlled search on `File`
- Extend `createFileFind` and `File` to support a controlled search handle.
- Keep existing per-viewer search behavior as the default path.
- Add a way to disable per-viewer global shortcuts when hosted inside `SessionReview`.
#### Acceptance
- `File` still supports local search unchanged by default.
- `File` can optionally register a search handle and accept controlled reveal calls.
### Phase 2: Add session-level search state in `SessionReview`
- Add a single search UI in the `SessionReview` header (can reuse `FileSearchBar` visuals or extract shared presentational pieces).
- Build a global hit list from `props.diffs` string content.
- Index hits by file/side/line/column/length.
#### Acceptance
- Header search appears once for the pane.
- Global hit count updates as query changes.
- Media/binary diffs are excluded.
### Phase 3: Wire global navigation to viewers
- Register a `FileSearchHandle` per mounted diff viewer.
- On next/prev, resolve the active global hit and:
1. expand the target file if needed
2. wait for the viewer to mount/render
3. call `handle.setQuery(query)` and `handle.reveal(hit)`
#### Acceptance
- Next/prev moves across files.
- Collapsed targets auto-open.
- Active match is highlighted in the target diff.
### Phase 4: Handle large-diff gating
- Lift `render anyway` state from local accordion item state into a file-keyed map in `SessionReview`.
- If navigation targets a gated file, force-render it before reveal.
#### Acceptance
- Global search can navigate into a large diff without manual user expansion/render.
### Phase 5: Keyboard and race-condition polish
- Route `Cmd/Ctrl+F`, `Cmd/Ctrl+G`, `Shift+Cmd/Ctrl+G` to session search when focus is in the review pane.
- Add token/cancel guards so fast navigation does not reveal stale targets after async mounts.
#### Acceptance
- Keyboard shortcuts consistently target session-level search.
- No stale reveal jumps during rapid navigation.
---
## Edge cases
- Empty query: clear all viewer highlights, reset count/index.
- No results: keep the search bar open, disable prev/next.
- Added/deleted files: index only the available side.
- Collapsed files: queue reveal until `onRendered` fires.
- Large diffs: auto-force render before reveal.
- Split diff mode: handle duplicate text on both sides without losing side info.
- Do not clear line comment draft or selected lines when navigating search results.
---
## Testing plan
### Unit tests
- Session hit-index builder:
- line/column mapping
- additions/deletions side tagging
- wrap-around next/prev behavior
- `File` controlled search handle:
- `setQuery`
- `clear`
- `reveal` by side/line/column in unified and split diff
### Component / integration tests
- Search across multiple diffs and navigate across collapsed accordion items.
- Global counter updates correctly (`current/total`).
- Split and unified diff styles both navigate correctly.
- Large diff target auto-renders on navigation.
- Existing line comment draft remains intact while searching.
### Manual verification
- `Cmd/Ctrl+F` opens session-level search in the review pane.
- `Cmd/Ctrl+G` / `Shift+Cmd/Ctrl+G` navigate globally.
- Highlighting and scroll behavior stay stable with many open diffs.
---
## Risks and rollback
### Key risks
- Global index and DOM highlights can drift if line/column mapping does not match viewer DOM content exactly.
- Keyboard shortcut conflicts between session-level search and per-viewer search.
- Performance impact when indexing many large diffs in one session.
### Rollback plan
- Gate session-level search behind a `SessionReview` prop/flag during rollout.
- If unstable, disable the session-level path and keep existing per-viewer search unchanged.
---
## Open questions
- Should search match file paths as well as content, or content only?
- In split mode, should the same text on both sides count as two matches?
- Should auto-navigation into gated large diffs silently render them, or show a prompt first?
- Should the session-level search bar reuse `FileSearchBar` directly or split out a shared non-portal variant?