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