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) { }> - - {props.children} - + {props.children} 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 = (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 = (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 = (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 = (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 = (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 = (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 = (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 = (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 = (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 ( - { dispose() }) }) + + test("update changes only the targeted comment body", () => { + createRoot((dispose) => { + const comments = createCommentSessionForTest({ + "a.ts": [line("a.ts", "a1", 10), line("a.ts", "a2", 20)], + }) + + comments.update("a.ts", "a2", "edited") + + expect(comments.list("a.ts").map((item) => item.comment)).toEqual(["a1", "edited"]) + + dispose() + }) + }) + + test("replace swaps comment state and clears focus state", () => { + createRoot((dispose) => { + const comments = createCommentSessionForTest({ + "a.ts": [line("a.ts", "a1", 10)], + }) + + comments.setFocus({ file: "a.ts", id: "a1" }) + comments.setActive({ file: "a.ts", id: "a1" }) + comments.replace([line("b.ts", "b1", 30)]) + + expect(comments.list("a.ts")).toEqual([]) + expect(comments.list("b.ts").map((item) => item.id)).toEqual(["b1"]) + expect(comments.focus()).toBeNull() + expect(comments.active()).toBeNull() + + dispose() + }) + }) }) diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index ecf63e45b..a97010c0a 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -44,6 +44,37 @@ function aggregate(comments: Record) { .sort((a, b) => a.time - b.time) } +function cloneSelection(selection: SelectedLineRange): SelectedLineRange { + const next: SelectedLineRange = { + start: selection.start, + end: selection.end, + } + + if (selection.side) next.side = selection.side + if (selection.endSide) next.endSide = selection.endSide + return next +} + +function cloneComment(comment: LineComment): LineComment { + return { + ...comment, + selection: cloneSelection(comment.selection), + } +} + +function group(comments: LineComment[]) { + return comments.reduce>((acc, comment) => { + const list = acc[comment.file] + const next = cloneComment(comment) + if (list) { + list.push(next) + return acc + } + acc[comment.file] = [next] + return acc + }, {}) +} + function createCommentSessionState(store: Store, setStore: SetStoreFunction) { const [state, setState] = createStore({ focus: null as CommentFocus | null, @@ -70,6 +101,7 @@ function createCommentSessionState(store: Store, setStore: SetStor id: uuid(), time: Date.now(), ...input, + selection: cloneSelection(input.selection), } batch(() => { @@ -87,6 +119,23 @@ function createCommentSessionState(store: Store, setStore: SetStor }) } + const update = (file: string, id: string, comment: string) => { + setStore("comments", file, (items) => + (items ?? []).map((item) => { + if (item.id !== id) return item + return { ...item, comment } + }), + ) + } + + const replace = (comments: LineComment[]) => { + batch(() => { + setStore("comments", reconcile(group(comments))) + setFocus(null) + setActive(null) + }) + } + const clear = () => { batch(() => { setStore("comments", reconcile({})) @@ -100,6 +149,8 @@ function createCommentSessionState(store: Store, setStore: SetStor all, add, remove, + update, + replace, clear, focus: () => state.focus, setFocus, @@ -132,6 +183,8 @@ function createCommentSession(dir: string, id: string | undefined) { all: session.all, add: session.add, remove: session.remove, + update: session.update, + replace: session.replace, clear: session.clear, focus: session.focus, setFocus: session.setFocus, @@ -176,6 +229,8 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont all: () => session().all(), add: (input: Omit) => session().add(input), remove: (file: string, id: string) => session().remove(file, id), + update: (file: string, id: string, comment: string) => session().update(file, id, comment), + replace: (comments: LineComment[]) => session().replace(comments), clear: () => session().clear(), focus: () => session().focus(), setFocus: (focus: CommentFocus | null) => session().setFocus(focus), diff --git a/packages/app/src/context/file/view-cache.ts b/packages/app/src/context/file/view-cache.ts index 6e8ddf62d..4c060174a 100644 --- a/packages/app/src/context/file/view-cache.ts +++ b/packages/app/src/context/file/view-cache.ts @@ -9,7 +9,7 @@ const MAX_FILE_VIEW_SESSIONS = 20 const MAX_VIEW_FILES = 500 function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange { - if (range.start <= range.end) return range + if (range.start <= range.end) return { ...range } const startSide = range.side const endSide = range.endSide ?? startSide diff --git a/packages/app/src/context/layout-scroll.test.ts b/packages/app/src/context/layout-scroll.test.ts index 2a13e4020..483be150f 100644 --- a/packages/app/src/context/layout-scroll.test.ts +++ b/packages/app/src/context/layout-scroll.test.ts @@ -41,4 +41,24 @@ describe("createScrollPersistence", () => { vi.useRealTimers() } }) + + test("reseeds empty cache after persisted snapshot loads", () => { + const snapshot = { + session: {}, + } as Record> + + const scroll = createScrollPersistence({ + getSnapshot: (sessionKey) => snapshot[sessionKey], + onFlush: () => {}, + }) + + expect(scroll.scroll("session", "review")).toBeUndefined() + + snapshot.session = { + review: { x: 12, y: 34 }, + } + + expect(scroll.scroll("session", "review")).toEqual({ x: 12, y: 34 }) + scroll.dispose() + }) }) diff --git a/packages/app/src/context/layout-scroll.ts b/packages/app/src/context/layout-scroll.ts index 30b0f6904..ef66eccd9 100644 --- a/packages/app/src/context/layout-scroll.ts +++ b/packages/app/src/context/layout-scroll.ts @@ -33,8 +33,16 @@ export function createScrollPersistence(opts: Options) { } function seed(sessionKey: string) { - if (cache[sessionKey]) return - setCache(sessionKey, clone(opts.getSnapshot(sessionKey))) + const next = clone(opts.getSnapshot(sessionKey)) + const current = cache[sessionKey] + if (!current) { + setCache(sessionKey, next) + return + } + + if (Object.keys(current).length > 0) return + if (Object.keys(next).length === 0) return + setCache(sessionKey, next) } function scroll(sessionKey: string, tab: string) { diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 064892105..fb8226559 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -116,6 +116,10 @@ function contextItemKey(item: ContextItem) { return `${key}:c=${digest.slice(0, 8)}` } +function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { + return item.type === "file" && !!item.comment?.trim() +} + function createPromptActions( setStore: SetStoreFunction<{ prompt: Prompt @@ -189,6 +193,26 @@ function createPromptSession(dir: string, id: string | undefined) { remove(key: string) { setStore("context", "items", (items) => items.filter((x) => x.key !== key)) }, + removeComment(path: string, commentID: string) { + setStore("context", "items", (items) => + items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)), + ) + }, + updateComment(path: string, commentID: string, next: Partial & { comment?: string }) { + setStore("context", "items", (items) => + items.map((item) => { + if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item + const value = { ...item, ...next } + return { ...value, key: contextItemKey(value) } + }), + ) + }, + replaceComments(items: FileContextItem[]) { + setStore("context", "items", (current) => [ + ...current.filter((item) => !isCommentItem(item)), + ...items.map((item) => ({ ...item, key: contextItemKey(item) })), + ]) + }, }, set: actions.set, reset: actions.reset, @@ -251,6 +275,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( items: () => session().context.items(), add: (item: ContextItem) => session().context.add(item), remove: (key: string) => session().context.remove(key), + removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID), + updateComment: (path: string, commentID: string, next: Partial & { comment?: string }) => + session().context.updateComment(path, commentID, next), + replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items), }, set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition), reset: () => session().reset(), diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 75bd988f8..0d2718efb 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -379,11 +379,58 @@ export default function Page() { }) } + const updateCommentInContext = (input: { + id: string + file: string + selection: SelectedLineRange + comment: string + preview?: string + }) => { + comments.update(input.file, input.id, input.comment) + prompt.context.updateComment(input.file, input.id, { + comment: input.comment, + ...(input.preview ? { preview: input.preview } : {}), + }) + } + + const removeCommentFromContext = (input: { id: string; file: string }) => { + comments.remove(input.file, input.id) + prompt.context.removeComment(input.file, input.id) + } + + const reviewCommentActions = createMemo(() => ({ + moreLabel: language.t("common.moreOptions"), + editLabel: language.t("common.edit"), + deleteLabel: language.t("common.delete"), + saveLabel: language.t("common.save"), + })) + + const isEditableTarget = (target: EventTarget | null | undefined) => { + if (!(target instanceof HTMLElement)) return false + return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable + } + + const deepActiveElement = () => { + let current: Element | null = document.activeElement + while (current instanceof HTMLElement && current.shadowRoot?.activeElement) { + current = current.shadowRoot.activeElement + } + return current instanceof HTMLElement ? current : undefined + } + const handleKeyDown = (event: KeyboardEvent) => { - const activeElement = document.activeElement as HTMLElement | undefined + const path = event.composedPath() + const target = path.find((item): item is HTMLElement => item instanceof HTMLElement) + const activeElement = deepActiveElement() + + const protectedTarget = path.some( + (item) => item instanceof HTMLElement && item.closest("[data-prevent-autofocus]") !== null, + ) + if (protectedTarget || isEditableTarget(target)) return + if (activeElement) { const isProtected = activeElement.closest("[data-prevent-autofocus]") - const isInput = /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(activeElement.tagName) || activeElement.isContentEditable + const isInput = isEditableTarget(activeElement) if (isProtected || isInput) return } if (dialog.active) return @@ -500,6 +547,9 @@ export default function Page() { onScrollRef={(el) => setTree("reviewScroll", el)} focusedFile={tree.activeDiff} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} + onLineCommentUpdate={updateCommentInContext} + onLineCommentDelete={removeCommentFromContext} + lineCommentActions={reviewCommentActions()} comments={comments.all()} focusedComment={comments.focus()} onFocusedCommentChange={comments.setFocus} @@ -521,6 +571,9 @@ export default function Page() { onScrollRef={(el) => setTree("reviewScroll", el)} focusedFile={tree.activeDiff} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} + onLineCommentUpdate={updateCommentInContext} + onLineCommentDelete={removeCommentFromContext} + lineCommentActions={reviewCommentActions()} comments={comments.all()} focusedComment={comments.focus()} onFocusedCommentChange={comments.setFocus} @@ -549,6 +602,9 @@ export default function Page() { onScrollRef={(el) => setTree("reviewScroll", el)} focusedFile={tree.activeDiff} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} + onLineCommentUpdate={updateCommentInContext} + onLineCommentDelete={removeCommentFromContext} + lineCommentActions={reviewCommentActions()} comments={comments.all()} focusedComment={comments.focus()} onFocusedCommentChange={comments.setFocus} diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 4b30915d8..e92eee670 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -1,15 +1,17 @@ -import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js" -import { createStore, produce } from "solid-js/store" +import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js" +import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { useParams } from "@solidjs/router" -import { useCodeComponent } from "@opencode-ai/ui/context/code" +import type { FileSearchHandle } from "@opencode-ai/ui/file" +import { useFileComponent } from "@opencode-ai/ui/context/file" +import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" +import { createLineCommentController } from "@opencode-ai/ui/line-comment-annotations" import { sampledChecksum } from "@opencode-ai/util/encode" -import { decode64 } from "@/utils/base64" -import { showToast } from "@opencode-ai/ui/toast" -import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment" -import { Mark } from "@opencode-ai/ui/logo" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { IconButton } from "@opencode-ai/ui/icon-button" import { Tabs } from "@opencode-ai/ui/tabs" import { ScrollView } from "@opencode-ai/ui/scroll-view" +import { showToast } from "@opencode-ai/ui/toast" import { useLayout } from "@/context/layout" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { useComments } from "@/context/comments" @@ -17,11 +19,37 @@ import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" import { getSessionHandoff } from "@/pages/session/handoff" -const formatCommentLabel = (range: SelectedLineRange) => { - const start = Math.min(range.start, range.end) - const end = Math.max(range.start, range.end) - if (start === end) return `line ${start}` - return `lines ${start}-${end}` +function FileCommentMenu(props: { + moreLabel: string + editLabel: string + deleteLabel: string + onEdit: VoidFunction + onDelete: VoidFunction +}) { + return ( +
event.stopPropagation()} onClick={(event) => event.stopPropagation()}> + + + + + + {props.editLabel} + + + {props.deleteLabel} + + + + +
+ ) } export function FileTabContent(props: { tab: string }) { @@ -31,7 +59,7 @@ export function FileTabContent(props: { tab: string }) { const comments = useComments() const language = useLanguage() const prompt = usePrompt() - const codeComponent = useCodeComponent() + const fileComponent = useFileComponent() const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) @@ -41,6 +69,13 @@ export function FileTabContent(props: { tab: string }) { let scrollFrame: number | undefined let pending: { x: number; y: number } | undefined let codeScroll: HTMLElement[] = [] + let find: FileSearchHandle | null = null + + const search = { + register: (handle: FileSearchHandle | null) => { + find = handle + }, + } const path = createMemo(() => file.pathFromTab(props.tab)) const state = createMemo(() => { @@ -50,66 +85,18 @@ export function FileTabContent(props: { tab: string }) { }) const contents = createMemo(() => state()?.content?.content ?? "") const cacheKey = createMemo(() => sampledChecksum(contents())) - const isImage = createMemo(() => { - const c = state()?.content - return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml" - }) - const isSvg = createMemo(() => { - const c = state()?.content - return c?.mimeType === "image/svg+xml" - }) - const isBinary = createMemo(() => state()?.content?.type === "binary") - const svgContent = createMemo(() => { - if (!isSvg()) return - const c = state()?.content - if (!c) return - if (c.encoding !== "base64") return c.content - return decode64(c.content) - }) - - const svgDecodeFailed = createMemo(() => { - if (!isSvg()) return false - const c = state()?.content - if (!c) return false - if (c.encoding !== "base64") return false - return svgContent() === undefined - }) - - const svgToast = { shown: false } - createEffect(() => { - if (!svgDecodeFailed()) return - if (svgToast.shown) return - svgToast.shown = true - showToast({ - variant: "error", - title: language.t("toast.file.loadFailed.title"), - }) - }) - const svgPreviewUrl = createMemo(() => { - if (!isSvg()) return - const c = state()?.content - if (!c) return - if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}` - return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}` - }) - const imageDataUrl = createMemo(() => { - if (!isImage()) return - const c = state()?.content - return `data:${c?.mimeType};base64,${c?.content}` - }) - const selectedLines = createMemo(() => { + const selectedLines = createMemo(() => { const p = path() if (!p) return null - if (file.ready()) return file.selectedLines(p) ?? null - return getSessionHandoff(sessionKey())?.files[p] ?? null + if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null + return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null }) const selectionPreview = (source: string, selection: FileSelection) => { - const start = Math.max(1, Math.min(selection.startLine, selection.endLine)) - const end = Math.max(selection.startLine, selection.endLine) - const lines = source.split("\n").slice(start - 1, end) - if (lines.length === 0) return undefined - return lines.slice(0, 2).join("\n") + return previewSelectedLines(source, { + start: selection.startLine, + end: selection.endLine, + }) } const addCommentToContext = (input: { @@ -145,7 +132,25 @@ export function FileTabContent(props: { tab: string }) { }) } - let wrap: HTMLDivElement | undefined + const updateCommentInContext = (input: { + id: string + file: string + selection: SelectedLineRange + comment: string + }) => { + comments.update(input.file, input.id, input.comment) + const preview = + input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined + prompt.context.updateComment(input.file, input.id, { + comment: input.comment, + ...(preview ? { preview } : {}), + }) + } + + const removeCommentFromContext = (input: { id: string; file: string }) => { + comments.remove(input.file, input.id) + prompt.context.removeComment(input.file, input.id) + } const fileComments = createMemo(() => { const p = path() @@ -153,121 +158,105 @@ export function FileTabContent(props: { tab: string }) { return comments.list(p) }) - const commentLayout = createMemo(() => { - return fileComments() - .map((comment) => `${comment.id}:${comment.selection.start}:${comment.selection.end}`) - .join("|") - }) - const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection)) const [note, setNote] = createStore({ openedComment: null as string | null, commenting: null as SelectedLineRange | null, - draft: "", - positions: {} as Record, - draftTop: undefined as number | undefined, + selected: null as SelectedLineRange | null, }) - const setCommenting = (range: SelectedLineRange | null) => { - setNote("commenting", range) - scheduleComments() - if (!range) return - setNote("draft", "") + const syncSelected = (range: SelectedLineRange | null) => { + const p = path() + if (!p) return + file.setSelectedLines(p, range ? cloneSelectedLineRange(range) : null) } - const getRoot = () => { - const el = wrap - if (!el) return + const activeSelection = () => note.selected ?? selectedLines() - const host = el.querySelector("diffs-container") - if (!(host instanceof HTMLElement)) return + const commentsUi = createLineCommentController({ + comments: fileComments, + label: language.t("ui.lineComment.submit"), + draftKey: () => path() ?? props.tab, + state: { + opened: () => note.openedComment, + setOpened: (id) => setNote("openedComment", id), + selected: () => note.selected, + setSelected: (range) => setNote("selected", range), + commenting: () => note.commenting, + setCommenting: (range) => setNote("commenting", range), + syncSelected, + hoverSelected: syncSelected, + }, + getHoverSelectedRange: activeSelection, + cancelDraftOnCommentToggle: true, + clearSelectionOnSelectionEndNull: true, + onSubmit: ({ comment, selection }) => { + const p = path() + if (!p) return + addCommentToContext({ file: p, selection, comment, origin: "file" }) + }, + onUpdate: ({ id, comment, selection }) => { + const p = path() + if (!p) return + updateCommentInContext({ id, file: p, selection, comment }) + }, + onDelete: (comment) => { + const p = path() + if (!p) return + removeCommentFromContext({ id: comment.id, file: p }) + }, + editSubmitLabel: language.t("common.save"), + renderCommentActions: (_, controls) => ( + + ), + onDraftPopoverFocusOut: (e: FocusEvent) => { + const current = e.currentTarget as HTMLDivElement + const target = e.relatedTarget + if (target instanceof Node && current.contains(target)) return - const root = host.shadowRoot - if (!root) return - - return root - } - - const findMarker = (root: ShadowRoot, range: SelectedLineRange) => { - const line = Math.max(range.start, range.end) - const node = root.querySelector(`[data-line="${line}"]`) - if (!(node instanceof HTMLElement)) return - return node - } - - const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => { - const wrapperRect = wrapper.getBoundingClientRect() - const rect = marker.getBoundingClientRect() - return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2) - } - - const updateComments = () => { - const el = wrap - const root = getRoot() - if (!el || !root) { - setNote("positions", {}) - setNote("draftTop", undefined) - return - } - - const estimateTop = (range: SelectedLineRange) => { - const line = Math.max(range.start, range.end) - const height = 24 - const offset = 2 - return Math.max(0, (line - 1) * height + offset) - } - - const large = contents().length > 500_000 - - const next: Record = {} - for (const comment of fileComments()) { - const marker = findMarker(root, comment.selection) - if (marker) next[comment.id] = markerTop(el, marker) - else if (large) next[comment.id] = estimateTop(comment.selection) - } - - const removed = Object.keys(note.positions).filter((id) => next[id] === undefined) - const changed = Object.entries(next).filter(([id, top]) => note.positions[id] !== top) - if (removed.length > 0 || changed.length > 0) { - setNote( - "positions", - produce((draft) => { - for (const id of removed) { - delete draft[id] - } - - for (const [id, top] of changed) { - draft[id] = top - } - }), - ) - } - - const range = note.commenting - if (!range) { - setNote("draftTop", undefined) - return - } - - const marker = findMarker(root, range) - if (marker) { - setNote("draftTop", markerTop(el, marker)) - return - } - - setNote("draftTop", large ? estimateTop(range) : undefined) - } - - const scheduleComments = () => { - requestAnimationFrame(updateComments) - } + setTimeout(() => { + if (!document.activeElement || !current.contains(document.activeElement)) { + setNote("commenting", null) + } + }, 0) + }, + }) createEffect(() => { - commentLayout() - scheduleComments() + if (typeof window === "undefined") return + + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return + if (tabs().active() !== props.tab) return + if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return + if (event.key.toLowerCase() !== "f") return + + event.preventDefault() + event.stopPropagation() + find?.focus() + } + + window.addEventListener("keydown", onKeyDown, { capture: true }) + onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true })) }) + createEffect( + on( + path, + () => { + commentsUi.note.reset() + }, + { defer: true }, + ), + ) + createEffect(() => { const focus = comments.focus() const p = path() @@ -278,9 +267,7 @@ export function FileTabContent(props: { tab: string }) { const target = fileComments().find((comment) => comment.id === focus.id) if (!target) return - setNote("openedComment", target.id) - setCommenting(null) - file.setSelectedLines(p, target.selection) + commentsUi.note.openComment(target.id, target.selection, { cancelDraft: true }) requestAnimationFrame(() => comments.clearFocus()) }) @@ -419,99 +406,50 @@ export function FileTabContent(props: { tab: string }) { cancelAnimationFrame(scrollFrame) }) - const renderCode = (source: string, wrapperClass: string) => ( -
{ - wrap = el - scheduleComments() - }} - class={`relative overflow-hidden ${wrapperClass}`} - > + const renderFile = (source: string) => ( +
{ requestAnimationFrame(restoreScroll) - requestAnimationFrame(scheduleComments) }} + annotations={commentsUi.annotations()} + renderAnnotation={commentsUi.renderAnnotation} + renderHoverUtility={commentsUi.renderHoverUtility} onLineSelected={(range: SelectedLineRange | null) => { - const p = path() - if (!p) return - file.setSelectedLines(p, range) - if (!range) setCommenting(null) + commentsUi.onLineSelected(range) }} + onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd} onLineSelectionEnd={(range: SelectedLineRange | null) => { - if (!range) { - setCommenting(null) - return - } - - setNote("openedComment", null) - setCommenting(range) + commentsUi.onLineSelectionEnd(range) }} + search={search} overflow="scroll" class="select-text" + media={{ + mode: "auto", + path: path(), + current: state()?.content, + onLoad: () => requestAnimationFrame(restoreScroll), + onError: (args: { kind: "image" | "audio" | "svg" }) => { + if (args.kind !== "svg") return + showToast({ + variant: "error", + title: language.t("toast.file.loadFailed.title"), + }) + }, + }} /> - - {(comment) => ( - { - const p = path() - if (!p) return - file.setSelectedLines(p, comment.selection) - }} - onClick={() => { - const p = path() - if (!p) return - setCommenting(null) - setNote("openedComment", (current) => (current === comment.id ? null : comment.id)) - file.setSelectedLines(p, comment.selection) - }} - /> - )} - - - {(range) => ( - - setNote("draft", value)} - onCancel={cancelCommenting} - onSubmit={(value) => { - const p = path() - if (!p) return - addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" }) - setCommenting(null) - }} - onPopoverFocusOut={(e: FocusEvent) => { - const current = e.currentTarget as HTMLDivElement - const target = e.relatedTarget - if (target instanceof Node && current.contains(target)) return - - setTimeout(() => { - if (!document.activeElement || !current.contains(document.activeElement)) { - cancelCommenting() - } - }, 0) - }} - /> - - )} -
) @@ -526,36 +464,7 @@ export function FileTabContent(props: { tab: string }) { onScroll={handleScroll as any} > - -
- {path()} requestAnimationFrame(restoreScroll)} - /> -
-
- -
- {renderCode(svgContent() ?? "", "")} - -
- {path()} -
-
-
-
- -
- -
-
{path()?.split("/").pop()}
-
{language.t("session.files.binaryContent")}
-
-
-
- {renderCode(contents(), "pb-40")} + {renderFile(contents())}
{language.t("common.loading")}...
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index b84109035..8215f31ba 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -2,6 +2,7 @@ import { For, createEffect, createMemo, on, onCleanup, Show, type JSX } from "so import { createStore, produce } from "solid-js/store" import { useNavigate, useParams } from "@solidjs/router" import { Button } from "@opencode-ai/ui/button" +import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" @@ -9,8 +10,9 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { InlineInput } from "@opencode-ai/ui/inline-input" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { ScrollView } from "@opencode-ai/ui/scroll-view" -import type { UserMessage } from "@opencode-ai/sdk/v2" +import type { Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" +import { getFilename } from "@opencode-ai/util/path" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { SessionContextUsage } from "@/components/session-context-usage" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -18,6 +20,35 @@ import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" +import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" + +type MessageComment = { + path: string + comment: string + selection?: { + startLine: number + endLine: number + } +} + +const messageComments = (parts: Part[]): MessageComment[] => + parts.flatMap((part) => { + if (part.type !== "text" || !(part as TextPart).synthetic) return [] + const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text) + if (!next) return [] + return [ + { + path: next.path, + comment: next.comment, + selection: next.selection + ? { + startLine: next.selection.startLine, + endLine: next.selection.endLine, + } + : undefined, + }, + ] + }) const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { const current = target instanceof Element ? target : undefined @@ -522,34 +553,67 @@ export function MessageTimeline(props: {
- {(message) => ( -
{ - props.onRegisterMessage(el, message.id) - onCleanup(() => props.onUnregisterMessage(message.id)) - }} - classList={{ - "min-w-0 w-full max-w-full": true, - "md:max-w-200 2xl:max-w-[1000px]": props.centered, - }} - > - { + const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? [])) + return ( +
{ + props.onRegisterMessage(el, message.id) + onCleanup(() => props.onUnregisterMessage(message.id)) }} - /> -
- )} + classList={{ + "min-w-0 w-full max-w-full": true, + "md:max-w-200 2xl:max-w-[1000px]": props.centered, + }} + > + 0}> +
+
+
+ + {(comment) => ( +
+
+ + {getFilename(comment.path)} + + {(selection) => ( + + {selection().startLine === selection().endLine + ? `:${selection().startLine}` + : `:${selection().startLine}-${selection().endLine}`} + + )} + +
+
+ {comment.comment} +
+
+ )} +
+
+
+
+
+ +
+ ) + }}
diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index fd2f3b2bd..7f90ff5ac 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -1,6 +1,11 @@ import { createEffect, on, onCleanup, type JSX } from "solid-js" import type { FileDiff } from "@opencode-ai/sdk/v2" import { SessionReview } from "@opencode-ai/ui/session-review" +import type { + SessionReviewCommentActions, + SessionReviewCommentDelete, + SessionReviewCommentUpdate, +} from "@opencode-ai/ui/session-review" import type { SelectedLineRange } from "@/context/file" import { useSDK } from "@/context/sdk" import { useLayout } from "@/context/layout" @@ -17,6 +22,9 @@ export interface SessionReviewTabProps { onDiffStyleChange?: (style: DiffStyle) => void onViewFile?: (file: string) => void onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void + onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void + onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void + lineCommentActions?: SessionReviewCommentActions comments?: LineComment[] focusedComment?: { file: string; id: string } | null onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void @@ -39,10 +47,11 @@ export function StickyAddButton(props: { children: JSX.Element }) { export function SessionReviewTab(props: SessionReviewTabProps) { let scroll: HTMLDivElement | undefined - let frame: number | undefined - let pending: { x: number; y: number } | undefined + let restoreFrame: number | undefined + let userInteracted = false const sdk = useSDK() + const layout = useLayout() const readFile = async (path: string) => { return sdk.client.file @@ -54,48 +63,81 @@ export function SessionReviewTab(props: SessionReviewTabProps) { }) } - const restoreScroll = () => { + const handleInteraction = () => { + userInteracted = true + } + + const doRestore = () => { + restoreFrame = undefined const el = scroll - if (!el) return + if (!el || !layout.ready() || userInteracted) return + if (el.clientHeight === 0 || el.clientWidth === 0) return const s = props.view().scroll("review") - if (!s) return + if (!s || (s.x === 0 && s.y === 0)) return - if (el.scrollTop !== s.y) el.scrollTop = s.y - if (el.scrollLeft !== s.x) el.scrollLeft = s.x + const maxY = Math.max(0, el.scrollHeight - el.clientHeight) + const maxX = Math.max(0, el.scrollWidth - el.clientWidth) + + const targetY = Math.min(s.y, maxY) + const targetX = Math.min(s.x, maxX) + + if (el.scrollTop !== targetY) el.scrollTop = targetY + if (el.scrollLeft !== targetX) el.scrollLeft = targetX + } + + const queueRestore = () => { + if (userInteracted || restoreFrame !== undefined) return + restoreFrame = requestAnimationFrame(doRestore) } const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { - pending = { - x: event.currentTarget.scrollLeft, - y: event.currentTarget.scrollTop, - } - if (frame !== undefined) return + if (!layout.ready() || !userInteracted) return - frame = requestAnimationFrame(() => { - frame = undefined + const el = event.currentTarget + if (el.clientHeight === 0 || el.clientWidth === 0) return - const next = pending - pending = undefined - if (!next) return - - props.view().setScroll("review", next) + props.view().setScroll("review", { + x: el.scrollLeft, + y: el.scrollTop, }) } createEffect( on( () => props.diffs().length, - () => { - requestAnimationFrame(restoreScroll) + () => queueRestore(), + { defer: true }, + ), + ) + + createEffect( + on( + () => props.diffStyle, + () => queueRestore(), + { defer: true }, + ), + ) + + createEffect( + on( + () => layout.ready(), + (ready) => { + if (!ready) return + queueRestore() }, { defer: true }, ), ) onCleanup(() => { - if (frame === undefined) return - cancelAnimationFrame(frame) + if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame) + if (scroll) { + scroll.removeEventListener("wheel", handleInteraction) + scroll.removeEventListener("pointerdown", handleInteraction) + scroll.removeEventListener("touchstart", handleInteraction) + scroll.removeEventListener("keydown", handleInteraction) + } }) return ( @@ -104,11 +146,15 @@ export function SessionReviewTab(props: SessionReviewTabProps) { empty={props.empty} scrollRef={(el) => { scroll = el + el.addEventListener("wheel", handleInteraction, { passive: true, capture: true }) + el.addEventListener("pointerdown", handleInteraction, { passive: true, capture: true }) + el.addEventListener("touchstart", handleInteraction, { passive: true, capture: true }) + el.addEventListener("keydown", handleInteraction, { passive: true, capture: true }) props.onScrollRef?.(el) - restoreScroll() + queueRestore() }} onScroll={handleScroll} - onDiffRendered={() => requestAnimationFrame(restoreScroll)} + onDiffRendered={queueRestore} open={props.view().review.open()} onOpenChange={props.view().review.setOpen} classes={{ @@ -123,6 +169,9 @@ export function SessionReviewTab(props: SessionReviewTabProps) { focusedFile={props.focusedFile} readFile={readFile} onLineComment={props.onLineComment} + onLineCommentUpdate={props.onLineCommentUpdate} + onLineCommentDelete={props.onLineCommentDelete} + lineCommentActions={props.lineCommentActions} comments={props.comments} focusedComment={props.focusedComment} onFocusedCommentChange={props.onFocusedCommentChange} diff --git a/packages/app/src/utils/comment-note.ts b/packages/app/src/utils/comment-note.ts new file mode 100644 index 000000000..99e87fc81 --- /dev/null +++ b/packages/app/src/utils/comment-note.ts @@ -0,0 +1,88 @@ +import type { FileSelection } from "@/context/file" + +export type PromptComment = { + path: string + selection?: FileSelection + comment: string + preview?: string + origin?: "review" | "file" +} + +function selection(selection: unknown) { + if (!selection || typeof selection !== "object") return undefined + const startLine = Number((selection as FileSelection).startLine) + const startChar = Number((selection as FileSelection).startChar) + const endLine = Number((selection as FileSelection).endLine) + const endChar = Number((selection as FileSelection).endChar) + if (![startLine, startChar, endLine, endChar].every(Number.isFinite)) return undefined + return { + startLine, + startChar, + endLine, + endChar, + } satisfies FileSelection +} + +export function createCommentMetadata(input: PromptComment) { + return { + opencodeComment: { + path: input.path, + selection: input.selection, + comment: input.comment, + preview: input.preview, + origin: input.origin, + }, + } +} + +export function readCommentMetadata(value: unknown) { + if (!value || typeof value !== "object") return + const meta = (value as { opencodeComment?: unknown }).opencodeComment + if (!meta || typeof meta !== "object") return + const path = (meta as { path?: unknown }).path + const comment = (meta as { comment?: unknown }).comment + if (typeof path !== "string" || typeof comment !== "string") return + const preview = (meta as { preview?: unknown }).preview + const origin = (meta as { origin?: unknown }).origin + return { + path, + selection: selection((meta as { selection?: unknown }).selection), + comment, + preview: typeof preview === "string" ? preview : undefined, + origin: origin === "review" || origin === "file" ? origin : undefined, + } satisfies PromptComment +} + +export function formatCommentNote(input: { path: string; selection?: FileSelection; comment: string }) { + const start = input.selection ? Math.min(input.selection.startLine, input.selection.endLine) : undefined + const end = input.selection ? Math.max(input.selection.startLine, input.selection.endLine) : undefined + const range = + start === undefined || end === undefined + ? "this file" + : start === end + ? `line ${start}` + : `lines ${start} through ${end}` + return `The user made the following comment regarding ${range} of ${input.path}: ${input.comment}` +} + +export function parseCommentNote(text: string) { + const match = text.match( + /^The user made the following comment regarding (this file|line (\d+)|lines (\d+) through (\d+)) of (.+?): ([\s\S]+)$/, + ) + if (!match) return + const start = match[2] ? Number(match[2]) : match[3] ? Number(match[3]) : undefined + const end = match[2] ? Number(match[2]) : match[4] ? Number(match[4]) : undefined + return { + path: match[5], + selection: + start !== undefined && end !== undefined + ? { + startLine: start, + startChar: 0, + endLine: end, + endChar: 0, + } + : undefined, + comment: match[6], + } satisfies PromptComment +} diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index eb830e4a6..ada543b7d 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -2,8 +2,7 @@ import { FileDiff, Message, Model, Part, Session, SessionStatus, UserMessage } f import { SessionTurn } from "@opencode-ai/ui/session-turn" import { SessionReview } from "@opencode-ai/ui/session-review" import { DataProvider } from "@opencode-ai/ui/context" -import { DiffComponentProvider } from "@opencode-ai/ui/context/diff" -import { CodeComponentProvider } from "@opencode-ai/ui/context/code" +import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool" import { createAsync, query, useParams } from "@solidjs/router" import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js" @@ -22,14 +21,12 @@ import NotFound from "../[...404]" import { Tabs } from "@opencode-ai/ui/tabs" import { MessageNav } from "@opencode-ai/ui/message-nav" import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" -import { Diff as SSRDiff } from "@opencode-ai/ui/diff-ssr" +import { FileSSR } from "@opencode-ai/ui/file-ssr" import { clientOnly } from "@solidjs/start" import { type IconName } from "@opencode-ai/ui/icons/provider" import { Meta, Title } from "@solidjs/meta" import { Base64 } from "js-base64" -const ClientOnlyDiff = clientOnly(() => import("@opencode-ai/ui/diff").then((m) => ({ default: m.Diff }))) -const ClientOnlyCode = clientOnly(() => import("@opencode-ai/ui/code").then((m) => ({ default: m.Code }))) const ClientOnlyWorkerPoolProvider = clientOnly(() => import("@opencode-ai/ui/pierre/worker").then((m) => ({ default: (props: { children: any }) => ( @@ -218,252 +215,244 @@ export default function () { - - - - {iife(() => { - const [store, setStore] = createStore({ - messageId: undefined as string | undefined, - }) - const messages = createMemo(() => - data().sessionID - ? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort( - (a, b) => a.time.created - b.time.created, - ) - : [], - ) - const firstUserMessage = createMemo(() => messages().at(0)) - const activeMessage = createMemo( - () => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(), - ) - function setActiveMessage(message: UserMessage | undefined) { - if (message) { - setStore("messageId", message.id) - } else { - setStore("messageId", undefined) - } + + + {iife(() => { + const [store, setStore] = createStore({ + messageId: undefined as string | undefined, + }) + const messages = createMemo(() => + data().sessionID + ? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort( + (a, b) => a.time.created - b.time.created, + ) + : [], + ) + const firstUserMessage = createMemo(() => messages().at(0)) + const activeMessage = createMemo( + () => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(), + ) + function setActiveMessage(message: UserMessage | undefined) { + if (message) { + setStore("messageId", message.id) + } else { + setStore("messageId", undefined) } - const provider = createMemo(() => activeMessage()?.model?.providerID) - const modelID = createMemo(() => activeMessage()?.model?.modelID) - const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID())) - const diffs = createMemo(() => { - const diffs = data().session_diff[data().sessionID] ?? [] - const preloaded = data().session_diff_preload[data().sessionID] ?? [] - return diffs.map((diff) => ({ - ...diff, - preloaded: preloaded.find((d) => d.newFile.name === diff.file), - })) - }) - const splitDiffs = createMemo(() => { - const diffs = data().session_diff[data().sessionID] ?? [] - const preloaded = data().session_diff_preload_split[data().sessionID] ?? [] - return diffs.map((diff) => ({ - ...diff, - preloaded: preloaded.find((d) => d.newFile.name === diff.file), - })) - }) + } + const provider = createMemo(() => activeMessage()?.model?.providerID) + const modelID = createMemo(() => activeMessage()?.model?.modelID) + const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID())) + const diffs = createMemo(() => { + const diffs = data().session_diff[data().sessionID] ?? [] + const preloaded = data().session_diff_preload[data().sessionID] ?? [] + return diffs.map((diff) => ({ + ...diff, + preloaded: preloaded.find((d) => d.newFile.name === diff.file), + })) + }) + const splitDiffs = createMemo(() => { + const diffs = data().session_diff[data().sessionID] ?? [] + const preloaded = data().session_diff_preload_split[data().sessionID] ?? [] + return diffs.map((diff) => ({ + ...diff, + preloaded: preloaded.find((d) => d.newFile.name === diff.file), + })) + }) - const title = () => ( -
-
-
- -
v{info().version}
-
-
-
- -
{model()?.name ?? modelID()}
-
-
- {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")} -
-
+ const title = () => ( +
+
+
+ +
v{info().version}
-
{info().title}
-
- ) - - const turns = () => ( -
-
{title()}
-
- - {(message) => ( - - )} - -
-
- +
+
+ +
{model()?.name ?? modelID()}
+
+
+ {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")} +
- ) +
{info().title}
+
+ ) - const wide = createMemo(() => diffs().length === 0) + const turns = () => ( +
+
{title()}
+
+ + {(message) => ( + + )} + +
+
+ +
+
+ ) - return ( -
-
-
- - - -
-
- - -
-
-
+ const wide = createMemo(() => diffs().length === 0) + + return ( +
+
+
+ + + +
+
+ + +
+
+
+
-
+
+ 1}> + + + - {title()} -
-
- 1}> - - - -
- -
-
-
+
+ +
+
- 0}> - -
+
+ 0}> +
+ +
+
+
+ + 0}> + + + + Session + + + {diffs().length} Files Changed + + + + {turns()} + +
- - 0}> - - - - Session - - - {diffs().length} Files Changed - - - - {turns()} - - - - - -
- {turns()} -
-
-
-
+ + + + +
+ {turns()} +
+
+
- ) - })} - - - +
+ ) + })} + + ) diff --git a/packages/ui/src/components/code.css b/packages/ui/src/components/code.css deleted file mode 100644 index 671b40512..000000000 --- a/packages/ui/src/components/code.css +++ /dev/null @@ -1,4 +0,0 @@ -[data-component="code"] { - content-visibility: auto; - overflow: hidden; -} diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx deleted file mode 100644 index 837cc5337..000000000 --- a/packages/ui/src/components/code.tsx +++ /dev/null @@ -1,1097 +0,0 @@ -import { - DEFAULT_VIRTUAL_FILE_METRICS, - type FileContents, - File, - FileOptions, - LineAnnotation, - type SelectedLineRange, - type VirtualFileMetrics, - VirtualizedFile, - Virtualizer, -} from "@pierre/diffs" -import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js" -import { Portal } from "solid-js/web" -import { createDefaultOptions, styleVariables } from "../pierre" -import { getWorkerPool } from "../pierre/worker" -import { Icon } from "./icon" - -const VIRTUALIZE_BYTES = 500_000 -const codeMetrics = { - ...DEFAULT_VIRTUAL_FILE_METRICS, - lineHeight: 24, - fileGap: 0, -} satisfies Partial - -type SelectionSide = "additions" | "deletions" - -export type CodeProps = FileOptions & { - file: FileContents - annotations?: LineAnnotation[] - selectedLines?: SelectedLineRange | null - commentedLines?: SelectedLineRange[] - onRendered?: () => void - onLineSelectionEnd?: (selection: SelectedLineRange | null) => void - class?: string - classList?: ComponentProps<"div">["classList"] -} - -function findElement(node: Node | null): HTMLElement | undefined { - if (!node) return - if (node instanceof HTMLElement) return node - return node.parentElement ?? undefined -} - -function findLineNumber(node: Node | null): number | undefined { - const element = findElement(node) - if (!element) return - - const line = element.closest("[data-line]") - if (!(line instanceof HTMLElement)) return - - const value = parseInt(line.dataset.line ?? "", 10) - if (Number.isNaN(value)) return - - return value -} - -function findSide(node: Node | null): SelectionSide | undefined { - const element = findElement(node) - if (!element) return - - const code = element.closest("[data-code]") - if (!(code instanceof HTMLElement)) return - - if (code.hasAttribute("data-deletions")) return "deletions" - return "additions" -} - -type FindHost = { - element: () => HTMLElement | undefined - open: () => void - close: () => void - next: (dir: 1 | -1) => void - isOpen: () => boolean -} - -const findHosts = new Set() -let findTarget: FindHost | undefined -let findCurrent: FindHost | undefined -let findInstalled = false - -function isEditable(node: unknown): boolean { - if (!(node instanceof HTMLElement)) return false - if (node.closest("[data-prevent-autofocus]")) return true - if (node.isContentEditable) return true - return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName) -} - -function hostForNode(node: unknown): FindHost | undefined { - if (!(node instanceof Node)) return - for (const host of findHosts) { - const el = host.element() - if (el && el.isConnected && el.contains(node)) return host - } -} - -function installFindShortcuts() { - if (findInstalled) return - if (typeof window === "undefined") return - findInstalled = true - - window.addEventListener( - "keydown", - (event) => { - if (event.defaultPrevented) return - - const mod = event.metaKey || event.ctrlKey - if (!mod) return - - const key = event.key.toLowerCase() - - if (key === "g") { - const host = findCurrent - if (!host || !host.isOpen()) return - event.preventDefault() - event.stopPropagation() - host.next(event.shiftKey ? -1 : 1) - return - } - - if (key !== "f") return - - const current = findCurrent - if (current && current.isOpen()) { - event.preventDefault() - event.stopPropagation() - current.open() - return - } - - const host = - hostForNode(document.activeElement) ?? hostForNode(event.target) ?? findTarget ?? Array.from(findHosts)[0] - if (!host) return - - event.preventDefault() - event.stopPropagation() - host.open() - }, - { capture: true }, - ) -} - -export function Code(props: CodeProps) { - let wrapper!: HTMLDivElement - let container!: HTMLDivElement - let findInput: HTMLInputElement | undefined - let findOverlay!: HTMLDivElement - let findOverlayFrame: number | undefined - let findOverlayScroll: HTMLElement[] = [] - let observer: MutationObserver | undefined - let renderToken = 0 - let selectionFrame: number | undefined - let dragFrame: number | undefined - let dragStart: number | undefined - let dragEnd: number | undefined - let dragMoved = false - let lastSelection: SelectedLineRange | null = null - let pendingSelectionEnd = false - - const [local, others] = splitProps(props, [ - "file", - "class", - "classList", - "annotations", - "selectedLines", - "commentedLines", - "onRendered", - ]) - - const [rendered, setRendered] = createSignal(0) - - const [findOpen, setFindOpen] = createSignal(false) - const [findQuery, setFindQuery] = createSignal("") - const [findIndex, setFindIndex] = createSignal(0) - const [findCount, setFindCount] = createSignal(0) - let findMode: "highlights" | "overlay" = "overlay" - let findHits: Range[] = [] - - const [findPos, setFindPos] = createSignal<{ top: number; right: number }>({ top: 8, right: 8 }) - - let instance: File | VirtualizedFile | undefined - let virtualizer: Virtualizer | undefined - let virtualRoot: Document | HTMLElement | undefined - - const bytes = createMemo(() => { - const value = local.file.contents as unknown - if (typeof value === "string") return value.length - if (Array.isArray(value)) { - return value.reduce( - (acc, part) => acc + (typeof part === "string" ? part.length + 1 : String(part).length + 1), - 0, - ) - } - if (value == null) return 0 - return String(value).length - }) - const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES) - - const options = createMemo(() => ({ - ...createDefaultOptions("unified"), - ...others, - })) - - const getRoot = () => { - const host = container.querySelector("diffs-container") - if (!(host instanceof HTMLElement)) return - - const root = host.shadowRoot - if (!root) return - - return root - } - - const applyScheme = () => { - const host = container.querySelector("diffs-container") - if (!(host instanceof HTMLElement)) return - - const scheme = document.documentElement.dataset.colorScheme - if (scheme === "dark" || scheme === "light") { - host.dataset.colorScheme = scheme - return - } - - host.removeAttribute("data-color-scheme") - } - - const supportsHighlights = () => { - const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown } - return typeof g.Highlight === "function" && g.CSS?.highlights != null - } - - const clearHighlightFind = () => { - const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights - if (!api) return - api.delete("opencode-find") - api.delete("opencode-find-current") - } - - const clearOverlayScroll = () => { - for (const el of findOverlayScroll) el.removeEventListener("scroll", scheduleOverlay) - findOverlayScroll = [] - } - - const clearOverlay = () => { - if (findOverlayFrame !== undefined) { - cancelAnimationFrame(findOverlayFrame) - findOverlayFrame = undefined - } - findOverlay.innerHTML = "" - } - - const renderOverlay = () => { - if (findMode !== "overlay") { - clearOverlay() - return - } - - clearOverlay() - if (findHits.length === 0) return - - const base = wrapper.getBoundingClientRect() - const current = findIndex() - - const frag = document.createDocumentFragment() - for (let i = 0; i < findHits.length; i++) { - const range = findHits[i] - const active = i === current - - for (const rect of Array.from(range.getClientRects())) { - if (!rect.width || !rect.height) continue - - const el = document.createElement("div") - el.style.position = "absolute" - el.style.left = `${Math.round(rect.left - base.left)}px` - el.style.top = `${Math.round(rect.top - base.top)}px` - el.style.width = `${Math.round(rect.width)}px` - el.style.height = `${Math.round(rect.height)}px` - el.style.borderRadius = "2px" - el.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)" - el.style.opacity = active ? "0.55" : "0.35" - if (active) el.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)" - frag.appendChild(el) - } - } - - findOverlay.appendChild(frag) - } - - function scheduleOverlay() { - if (findMode !== "overlay") return - if (!findOpen()) return - if (findOverlayFrame !== undefined) return - - findOverlayFrame = requestAnimationFrame(() => { - findOverlayFrame = undefined - renderOverlay() - }) - } - - const syncOverlayScroll = () => { - if (findMode !== "overlay") return - const root = getRoot() - - const next = root - ? Array.from(root.querySelectorAll("[data-code]")).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - : [] - if (next.length === findOverlayScroll.length && next.every((el, i) => el === findOverlayScroll[i])) return - - clearOverlayScroll() - findOverlayScroll = next - for (const el of findOverlayScroll) el.addEventListener("scroll", scheduleOverlay, { passive: true }) - } - - const clearFind = () => { - clearHighlightFind() - clearOverlay() - clearOverlayScroll() - findHits = [] - setFindCount(0) - setFindIndex(0) - } - - const getScrollParent = (el: HTMLElement): HTMLElement | undefined => { - let parent = el.parentElement - while (parent) { - const style = getComputedStyle(parent) - if (style.overflowY === "auto" || style.overflowY === "scroll") return parent - parent = parent.parentElement - } - } - - const positionFindBar = () => { - if (typeof window === "undefined") return - - const root = getScrollParent(wrapper) ?? wrapper - const rect = root.getBoundingClientRect() - const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height")) - const header = Number.isNaN(title) ? 0 : title - setFindPos({ - top: Math.round(rect.top) + header - 4, - right: Math.round(window.innerWidth - rect.right) + 8, - }) - } - - const scanFind = (root: ShadowRoot, query: string) => { - const needle = query.toLowerCase() - const out: Range[] = [] - - const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - - for (const col of cols) { - const text = col.textContent - if (!text) continue - - const hay = text.toLowerCase() - let idx = hay.indexOf(needle) - if (idx === -1) continue - - const nodes: Text[] = [] - const ends: number[] = [] - const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT) - let node = walker.nextNode() - let pos = 0 - - while (node) { - if (node instanceof Text) { - pos += node.data.length - nodes.push(node) - ends.push(pos) - } - node = walker.nextNode() - } - - if (nodes.length === 0) continue - - const locate = (at: number) => { - let lo = 0 - let hi = ends.length - 1 - while (lo < hi) { - const mid = (lo + hi) >> 1 - if (ends[mid] >= at) hi = mid - else lo = mid + 1 - } - const prev = lo === 0 ? 0 : ends[lo - 1] - return { node: nodes[lo], offset: at - prev } - } - - while (idx !== -1) { - const start = locate(idx) - const end = locate(idx + query.length) - const range = document.createRange() - range.setStart(start.node, start.offset) - range.setEnd(end.node, end.offset) - out.push(range) - idx = hay.indexOf(needle, idx + query.length) - } - } - - return out - } - - const scrollToRange = (range: Range) => { - const start = range.startContainer - const el = start instanceof Element ? start : start.parentElement - el?.scrollIntoView({ block: "center", inline: "center" }) - } - - const setHighlights = (ranges: Range[], index: number) => { - const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights - const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight - if (!api || typeof Highlight !== "function") return false - - api.delete("opencode-find") - api.delete("opencode-find-current") - - const active = ranges[index] - if (active) api.set("opencode-find-current", new Highlight(active)) - - const rest = ranges.filter((_, i) => i !== index) - if (rest.length > 0) api.set("opencode-find", new Highlight(...rest)) - return true - } - - const applyFind = (opts?: { reset?: boolean; scroll?: boolean }) => { - if (!findOpen()) return - - const query = findQuery().trim() - if (!query) { - clearFind() - return - } - - const root = getRoot() - if (!root) return - - findMode = supportsHighlights() ? "highlights" : "overlay" - - const ranges = scanFind(root, query) - const total = ranges.length - const desired = opts?.reset ? 0 : findIndex() - const index = total ? Math.min(desired, total - 1) : 0 - - findHits = ranges - setFindCount(total) - setFindIndex(index) - - const active = ranges[index] - if (findMode === "highlights") { - clearOverlay() - clearOverlayScroll() - if (!setHighlights(ranges, index)) { - findMode = "overlay" - clearHighlightFind() - syncOverlayScroll() - scheduleOverlay() - } - if (opts?.scroll && active) { - scrollToRange(active) - } - return - } - - clearHighlightFind() - syncOverlayScroll() - if (opts?.scroll && active) { - scrollToRange(active) - } - scheduleOverlay() - } - - const closeFind = () => { - setFindOpen(false) - clearFind() - if (findCurrent === host) findCurrent = undefined - } - - const stepFind = (dir: 1 | -1) => { - if (!findOpen()) return - const total = findCount() - if (total <= 0) return - - const index = (findIndex() + dir + total) % total - setFindIndex(index) - - const active = findHits[index] - if (!active) return - - if (findMode === "highlights") { - if (!setHighlights(findHits, index)) { - findMode = "overlay" - applyFind({ reset: true, scroll: true }) - return - } - scrollToRange(active) - return - } - - clearHighlightFind() - syncOverlayScroll() - scrollToRange(active) - scheduleOverlay() - } - - const host: FindHost = { - element: () => wrapper, - isOpen: () => findOpen(), - next: stepFind, - open: () => { - if (findCurrent && findCurrent !== host) findCurrent.close() - findCurrent = host - findTarget = host - - if (!findOpen()) setFindOpen(true) - requestAnimationFrame(() => { - applyFind({ scroll: true }) - findInput?.focus() - findInput?.select() - }) - }, - close: closeFind, - } - - onMount(() => { - findMode = supportsHighlights() ? "highlights" : "overlay" - installFindShortcuts() - findHosts.add(host) - if (!findTarget) findTarget = host - - onCleanup(() => { - findHosts.delete(host) - if (findCurrent === host) { - findCurrent = undefined - clearHighlightFind() - } - if (findTarget === host) findTarget = undefined - }) - }) - - createEffect(() => { - if (!findOpen()) return - - const update = () => positionFindBar() - requestAnimationFrame(update) - window.addEventListener("resize", update, { passive: true }) - - const root = getScrollParent(wrapper) ?? wrapper - const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(() => update()) - observer?.observe(root) - - onCleanup(() => { - window.removeEventListener("resize", update) - observer?.disconnect() - }) - }) - - const applyCommentedLines = (ranges: SelectedLineRange[]) => { - const root = getRoot() - if (!root) return - - const existing = Array.from(root.querySelectorAll("[data-comment-selected]")) - for (const node of existing) { - if (!(node instanceof HTMLElement)) continue - node.removeAttribute("data-comment-selected") - } - - const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - - for (const range of ranges) { - const start = Math.max(1, Math.min(range.start, range.end)) - const end = Math.max(range.start, range.end) - - for (let line = start; line <= end; line++) { - const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`)) - for (const node of nodes) { - if (!(node instanceof HTMLElement)) continue - node.setAttribute("data-comment-selected", "") - } - } - - for (const annotation of annotations) { - const line = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10) - if (Number.isNaN(line)) continue - if (line < start || line > end) continue - annotation.setAttribute("data-comment-selected", "") - } - } - } - - const text = () => { - const value = local.file.contents as unknown - if (typeof value === "string") return value - if (Array.isArray(value)) return value.join("\n") - if (value == null) return "" - return String(value) - } - - const lineCount = () => { - const value = text() - const total = value.split("\n").length - (value.endsWith("\n") ? 1 : 0) - return Math.max(1, total) - } - - const applySelection = (range: SelectedLineRange | null) => { - const current = instance - if (!current) return false - - if (virtual()) { - current.setSelectedLines(range) - return true - } - - const root = getRoot() - if (!root) return false - - const lines = lineCount() - if (root.querySelectorAll("[data-line]").length < lines) return false - - if (!range) { - current.setSelectedLines(null) - return true - } - - const start = Math.min(range.start, range.end) - const end = Math.max(range.start, range.end) - - if (start < 1 || end > lines) { - current.setSelectedLines(null) - return true - } - - if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) { - current.setSelectedLines(null) - return true - } - - const normalized = (() => { - if (range.endSide != null) return { start: range.start, end: range.end } - if (range.side !== "deletions") return range - if (root.querySelector("[data-deletions]") != null) return range - return { start: range.start, end: range.end } - })() - - current.setSelectedLines(normalized) - return true - } - - const notifyRendered = () => { - observer?.disconnect() - observer = undefined - renderToken++ - - const token = renderToken - - const lines = virtual() ? undefined : lineCount() - - const isReady = (root: ShadowRoot) => - virtual() - ? root.querySelector("[data-line]") != null - : root.querySelectorAll("[data-line]").length >= (lines ?? 0) - - const notify = () => { - if (token !== renderToken) return - - observer?.disconnect() - observer = undefined - requestAnimationFrame(() => { - if (token !== renderToken) return - applySelection(lastSelection) - applyFind({ reset: true }) - local.onRendered?.() - }) - } - - const root = getRoot() - if (root && isReady(root)) { - notify() - return - } - - if (typeof MutationObserver === "undefined") return - - const observeRoot = (root: ShadowRoot) => { - if (isReady(root)) { - notify() - return - } - - observer?.disconnect() - observer = new MutationObserver(() => { - if (token !== renderToken) return - if (!isReady(root)) return - - notify() - }) - - observer.observe(root, { childList: true, subtree: true }) - } - - if (root) { - observeRoot(root) - return - } - - observer = new MutationObserver(() => { - if (token !== renderToken) return - - const root = getRoot() - if (!root) return - - observeRoot(root) - }) - - observer.observe(container, { childList: true, subtree: true }) - } - - const updateSelection = () => { - const root = getRoot() - if (!root) return - - const selection = - (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection() - if (!selection || selection.isCollapsed) return - - const domRange = - ( - selection as unknown as { - getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[] - } - ).getComposedRanges?.({ shadowRoots: [root] })?.[0] ?? - (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined) - - const startNode = domRange?.startContainer ?? selection.anchorNode - const endNode = domRange?.endContainer ?? selection.focusNode - if (!startNode || !endNode) return - - if (!root.contains(startNode) || !root.contains(endNode)) return - - const start = findLineNumber(startNode) - const end = findLineNumber(endNode) - if (start === undefined || end === undefined) return - - const startSide = findSide(startNode) - const endSide = findSide(endNode) - const side = startSide ?? endSide - - const selected: SelectedLineRange = { - start, - end, - } - - if (side) selected.side = side - if (endSide && side && endSide !== side) selected.endSide = endSide - - setSelectedLines(selected) - } - - const setSelectedLines = (range: SelectedLineRange | null) => { - lastSelection = range - applySelection(range) - } - - const scheduleSelectionUpdate = () => { - if (selectionFrame !== undefined) return - - selectionFrame = requestAnimationFrame(() => { - selectionFrame = undefined - updateSelection() - - if (!pendingSelectionEnd) return - pendingSelectionEnd = false - props.onLineSelectionEnd?.(lastSelection) - }) - } - - const updateDragSelection = () => { - if (dragStart === undefined || dragEnd === undefined) return - - const start = Math.min(dragStart, dragEnd) - const end = Math.max(dragStart, dragEnd) - - setSelectedLines({ start, end }) - } - - const scheduleDragUpdate = () => { - if (dragFrame !== undefined) return - - dragFrame = requestAnimationFrame(() => { - dragFrame = undefined - updateDragSelection() - }) - } - - const lineFromMouseEvent = (event: MouseEvent) => { - const path = event.composedPath() - - let numberColumn = false - let line: number | undefined - - for (const item of path) { - if (!(item instanceof HTMLElement)) continue - - numberColumn = numberColumn || item.dataset.columnNumber != null - - if (line === undefined && item.dataset.line) { - const parsed = parseInt(item.dataset.line, 10) - if (!Number.isNaN(parsed)) line = parsed - } - - if (numberColumn && line !== undefined) break - } - - return { line, numberColumn } - } - - const handleMouseDown = (event: MouseEvent) => { - if (props.enableLineSelection !== true) return - if (event.button !== 0) return - - const { line, numberColumn } = lineFromMouseEvent(event) - if (numberColumn) return - if (line === undefined) return - - dragStart = line - dragEnd = line - dragMoved = false - } - - const handleMouseMove = (event: MouseEvent) => { - if (props.enableLineSelection !== true) return - if (dragStart === undefined) return - - if ((event.buttons & 1) === 0) { - dragStart = undefined - dragEnd = undefined - dragMoved = false - return - } - - const { line } = lineFromMouseEvent(event) - if (line === undefined) return - - dragEnd = line - dragMoved = true - scheduleDragUpdate() - } - - const handleMouseUp = () => { - if (props.enableLineSelection !== true) return - if (dragStart === undefined) return - - if (!dragMoved) { - pendingSelectionEnd = false - const line = dragStart - setSelectedLines({ start: line, end: line }) - props.onLineSelectionEnd?.(lastSelection) - dragStart = undefined - dragEnd = undefined - dragMoved = false - return - } - - pendingSelectionEnd = true - scheduleDragUpdate() - scheduleSelectionUpdate() - - dragStart = undefined - dragEnd = undefined - dragMoved = false - } - - const handleSelectionChange = () => { - if (props.enableLineSelection !== true) return - if (dragStart === undefined) return - - const selection = window.getSelection() - if (!selection || selection.isCollapsed) return - - scheduleSelectionUpdate() - } - - createEffect(() => { - const opts = options() - const workerPool = getWorkerPool("unified") - const isVirtual = virtual() - - observer?.disconnect() - observer = undefined - - instance?.cleanUp() - instance = undefined - - if (!isVirtual && virtualizer) { - virtualizer.cleanUp() - virtualizer = undefined - virtualRoot = undefined - } - - const v = (() => { - if (!isVirtual) return - if (typeof document === "undefined") return - - const root = getScrollParent(wrapper) ?? document - if (virtualizer && virtualRoot === root) return virtualizer - - virtualizer?.cleanUp() - virtualizer = new Virtualizer() - virtualRoot = root - virtualizer.setup(root, root instanceof Document ? undefined : wrapper) - return virtualizer - })() - - instance = isVirtual && v ? new VirtualizedFile(opts, v, codeMetrics, workerPool) : new File(opts, workerPool) - - container.innerHTML = "" - const value = text() - instance.render({ - file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents: value }, - lineAnnotations: local.annotations, - containerWrapper: container, - }) - - applyScheme() - - setRendered((value) => value + 1) - notifyRendered() - }) - - createEffect(() => { - if (typeof document === "undefined") return - if (typeof MutationObserver === "undefined") return - - const root = document.documentElement - const monitor = new MutationObserver(() => applyScheme()) - monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] }) - applyScheme() - - onCleanup(() => monitor.disconnect()) - }) - - createEffect(() => { - rendered() - const ranges = local.commentedLines ?? [] - requestAnimationFrame(() => applyCommentedLines(ranges)) - }) - - createEffect(() => { - setSelectedLines(local.selectedLines ?? null) - }) - - createEffect(() => { - if (props.enableLineSelection !== true) return - - container.addEventListener("mousedown", handleMouseDown) - container.addEventListener("mousemove", handleMouseMove) - window.addEventListener("mouseup", handleMouseUp) - document.addEventListener("selectionchange", handleSelectionChange) - - onCleanup(() => { - container.removeEventListener("mousedown", handleMouseDown) - container.removeEventListener("mousemove", handleMouseMove) - window.removeEventListener("mouseup", handleMouseUp) - document.removeEventListener("selectionchange", handleSelectionChange) - }) - }) - - onCleanup(() => { - observer?.disconnect() - - instance?.cleanUp() - instance = undefined - - virtualizer?.cleanUp() - virtualizer = undefined - virtualRoot = undefined - - clearOverlayScroll() - clearOverlay() - if (findCurrent === host) { - findCurrent = undefined - clearHighlightFind() - } - - if (selectionFrame !== undefined) { - cancelAnimationFrame(selectionFrame) - selectionFrame = undefined - } - - if (dragFrame !== undefined) { - cancelAnimationFrame(dragFrame) - dragFrame = undefined - } - - dragStart = undefined - dragEnd = undefined - dragMoved = false - lastSelection = null - pendingSelectionEnd = false - }) - - const FindBar = (barProps: { class: string; style?: ComponentProps<"div">["style"] }) => ( -
e.stopPropagation()}> - - { - 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) - }} - /> -
- {findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"} -
-
- - -
- -
- ) - - return ( -
{ - findTarget = host - wrapper.focus({ preventScroll: true }) - }} - onFocus={() => { - findTarget = host - }} - > - - - - - -
-
-
- ) -} diff --git a/packages/ui/src/components/diff-ssr.tsx b/packages/ui/src/components/diff-ssr.tsx deleted file mode 100644 index e739afc16..000000000 --- a/packages/ui/src/components/diff-ssr.tsx +++ /dev/null @@ -1,317 +0,0 @@ -import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs" -import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" -import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js" -import { Dynamic, isServer } from "solid-js/web" -import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre" -import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer" -import { useWorkerPool } from "../context/worker-pool" - -export type SSRDiffProps = DiffProps & { - preloadedDiff: PreloadMultiFileDiffResult -} - -export function Diff(props: SSRDiffProps) { - let container!: HTMLDivElement - let fileDiffRef!: HTMLElement - const [local, others] = splitProps(props, [ - "before", - "after", - "class", - "classList", - "annotations", - "selectedLines", - "commentedLines", - ]) - const workerPool = useWorkerPool(props.diffStyle) - - let fileDiffInstance: FileDiff | undefined - let sharedVirtualizer: NonNullable> | undefined - const cleanupFunctions: Array<() => void> = [] - - const getRoot = () => fileDiffRef?.shadowRoot ?? undefined - - const getVirtualizer = () => { - if (sharedVirtualizer) return sharedVirtualizer.virtualizer - - const result = acquireVirtualizer(container) - if (!result) return - - sharedVirtualizer = result - return result.virtualizer - } - - const applyScheme = () => { - const scheme = document.documentElement.dataset.colorScheme - if (scheme === "dark" || scheme === "light") { - fileDiffRef.dataset.colorScheme = scheme - return - } - - fileDiffRef.removeAttribute("data-color-scheme") - } - - const lineIndex = (split: boolean, element: HTMLElement) => { - const raw = element.dataset.lineIndex - if (!raw) return - const values = raw - .split(",") - .map((value) => parseInt(value, 10)) - .filter((value) => !Number.isNaN(value)) - if (values.length === 0) return - if (!split) return values[0] - if (values.length === 2) return values[1] - return values[0] - } - - const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: "additions" | "deletions" | undefined) => { - const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - if (nodes.length === 0) return - - const targetSide = side ?? "additions" - - for (const node of nodes) { - if (findSide(node) === targetSide) return lineIndex(split, node) - if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node) - } - } - - const fixSelection = (range: SelectedLineRange | null) => { - if (!range) return range - const root = getRoot() - if (!root) return - - const diffs = root.querySelector("[data-diff]") - if (!(diffs instanceof HTMLElement)) return - - const split = diffs.dataset.diffType === "split" - - const start = rowIndex(root, split, range.start, range.side) - const end = rowIndex(root, split, range.end, range.endSide ?? range.side) - - if (start === undefined || end === undefined) { - if (root.querySelector("[data-line], [data-alt-line]") == null) return - return null - } - if (start <= end) return range - - const side = range.endSide ?? range.side - const swapped: SelectedLineRange = { - start: range.end, - end: range.start, - } - if (side) swapped.side = side - if (range.endSide && range.side) swapped.endSide = range.side - - return swapped - } - - const setSelectedLines = (range: SelectedLineRange | null, attempt = 0) => { - const diff = fileDiffInstance - if (!diff) return - - const fixed = fixSelection(range) - if (fixed === undefined) { - if (attempt >= 120) return - requestAnimationFrame(() => setSelectedLines(range, attempt + 1)) - return - } - - diff.setSelectedLines(fixed) - } - - const findSide = (element: HTMLElement): "additions" | "deletions" => { - const line = element.closest("[data-line], [data-alt-line]") - if (line instanceof HTMLElement) { - const type = line.dataset.lineType - if (type === "change-deletion") return "deletions" - if (type === "change-addition" || type === "change-additions") return "additions" - } - - const code = element.closest("[data-code]") - if (!(code instanceof HTMLElement)) return "additions" - return code.hasAttribute("data-deletions") ? "deletions" : "additions" - } - - const applyCommentedLines = (ranges: SelectedLineRange[]) => { - const root = getRoot() - if (!root) return - - const existing = Array.from(root.querySelectorAll("[data-comment-selected]")) - for (const node of existing) { - if (!(node instanceof HTMLElement)) continue - node.removeAttribute("data-comment-selected") - } - - const diffs = root.querySelector("[data-diff]") - if (!(diffs instanceof HTMLElement)) return - - const split = diffs.dataset.diffType === "split" - - const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - if (rows.length === 0) return - - const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - - const lineIndex = (element: HTMLElement) => { - const raw = element.dataset.lineIndex - if (!raw) return - const values = raw - .split(",") - .map((value) => parseInt(value, 10)) - .filter((value) => !Number.isNaN(value)) - if (values.length === 0) return - if (!split) return values[0] - if (values.length === 2) return values[1] - return values[0] - } - - const rowIndex = (line: number, side: "additions" | "deletions" | undefined) => { - const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - if (nodes.length === 0) return - - const targetSide = side ?? "additions" - - for (const node of nodes) { - if (findSide(node) === targetSide) return lineIndex(node) - if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(node) - } - } - - for (const range of ranges) { - const start = rowIndex(range.start, range.side) - if (start === undefined) continue - - const end = (() => { - const same = range.end === range.start && (range.endSide == null || range.endSide === range.side) - if (same) return start - return rowIndex(range.end, range.endSide ?? range.side) - })() - if (end === undefined) continue - - const first = Math.min(start, end) - const last = Math.max(start, end) - - for (const row of rows) { - const idx = lineIndex(row) - if (idx === undefined) continue - if (idx < first || idx > last) continue - row.setAttribute("data-comment-selected", "") - } - - for (const annotation of annotations) { - const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10) - if (Number.isNaN(idx)) continue - if (idx < first || idx > last) continue - annotation.setAttribute("data-comment-selected", "") - } - } - } - - onMount(() => { - if (isServer || !props.preloadedDiff) return - - applyScheme() - - if (typeof MutationObserver !== "undefined") { - const root = document.documentElement - const monitor = new MutationObserver(() => applyScheme()) - monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] }) - onCleanup(() => monitor.disconnect()) - } - - const virtualizer = getVirtualizer() - - fileDiffInstance = virtualizer - ? new VirtualizedFileDiff( - { - ...createDefaultOptions(props.diffStyle), - ...others, - ...props.preloadedDiff, - }, - virtualizer, - virtualMetrics, - workerPool, - ) - : new FileDiff( - { - ...createDefaultOptions(props.diffStyle), - ...others, - ...props.preloadedDiff, - }, - workerPool, - ) - // @ts-expect-error - fileContainer is private but needed for SSR hydration - fileDiffInstance.fileContainer = fileDiffRef - fileDiffInstance.hydrate({ - oldFile: local.before, - newFile: local.after, - lineAnnotations: local.annotations, - fileContainer: fileDiffRef, - containerWrapper: container, - }) - - setSelectedLines(local.selectedLines ?? null) - - createEffect(() => { - fileDiffInstance?.setLineAnnotations(local.annotations ?? []) - }) - - createEffect(() => { - setSelectedLines(local.selectedLines ?? null) - }) - - createEffect(() => { - const ranges = local.commentedLines ?? [] - requestAnimationFrame(() => applyCommentedLines(ranges)) - }) - - // Hydrate annotation slots with interactive SolidJS components - // if (props.annotations.length > 0 && props.renderAnnotation != null) { - // for (const annotation of props.annotations) { - // const slotName = `annotation-${annotation.side}-${annotation.lineNumber}`; - // const slotElement = fileDiffRef.querySelector( - // `[slot="${slotName}"]` - // ) as HTMLElement; - // - // if (slotElement != null) { - // // Clear the static server-rendered content from the slot - // slotElement.innerHTML = ''; - // - // // Mount a fresh SolidJS component into this slot using render(). - // // This enables full SolidJS reactivity (signals, effects, etc.) - // const dispose = render( - // () => props.renderAnnotation!(annotation), - // slotElement - // ); - // cleanupFunctions.push(dispose); - // } - // } - // } - }) - - onCleanup(() => { - // Clean up FileDiff event handlers and dispose SolidJS components - fileDiffInstance?.cleanUp() - cleanupFunctions.forEach((dispose) => dispose()) - sharedVirtualizer?.release() - sharedVirtualizer = undefined - }) - - return ( -
- - -