feat(app): better diff/code comments (#14621)
Co-authored-by: adamelmore <2363879+adamdottv@users.noreply.github.com> Co-authored-by: David Hill <iamdavidhill@gmail.com>
This commit is contained in:
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import "@/index.css"
|
||||
import { Code } from "@opencode-ai/ui/code"
|
||||
import { File } from "@opencode-ai/ui/file"
|
||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||
import { Diff } from "@opencode-ai/ui/diff"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
@@ -122,9 +120,7 @@ export function AppBaseProviders(props: ParentProps) {
|
||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||
<DialogProvider>
|
||||
<MarkedProviderWithNativeParser>
|
||||
<DiffComponentProvider component={Diff}>
|
||||
<CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
|
||||
</DiffComponentProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</MarkedProviderWithNativeParser>
|
||||
</DialogProvider>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useFile } from "@/context/file"
|
||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||
import {
|
||||
ContentPart,
|
||||
DEFAULT_PROMPT,
|
||||
@@ -43,6 +43,9 @@ import {
|
||||
canNavigateHistoryAtCursor,
|
||||
navigatePromptHistory,
|
||||
prependHistoryEntry,
|
||||
type PromptHistoryComment,
|
||||
type PromptHistoryEntry,
|
||||
type PromptHistoryStoredEntry,
|
||||
promptLength,
|
||||
} from "./prompt-input/history"
|
||||
import { createPromptSubmit } from "./prompt-input/submit"
|
||||
@@ -170,12 +173,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const focus = { file: item.path, id: item.commentID }
|
||||
comments.setActive(focus)
|
||||
|
||||
const queueCommentFocus = (attempts = 6) => {
|
||||
const schedule = (left: number) => {
|
||||
requestAnimationFrame(() => {
|
||||
comments.setFocus({ ...focus })
|
||||
if (left <= 0) return
|
||||
requestAnimationFrame(() => {
|
||||
const current = comments.focus()
|
||||
if (!current) return
|
||||
if (current.file !== focus.file || current.id !== focus.id) return
|
||||
schedule(left - 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
schedule(attempts)
|
||||
}
|
||||
|
||||
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
|
||||
if (wantsReview) {
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
layout.fileTree.setTab("changes")
|
||||
tabs().setActive("review")
|
||||
requestAnimationFrame(() => comments.setFocus(focus))
|
||||
queueCommentFocus()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -183,8 +203,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
layout.fileTree.setTab("all")
|
||||
const tab = files.tab(item.path)
|
||||
tabs().open(tab)
|
||||
files.load(item.path)
|
||||
requestAnimationFrame(() => comments.setFocus(focus))
|
||||
tabs().setActive(tab)
|
||||
Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus())
|
||||
}
|
||||
|
||||
const recent = createMemo(() => {
|
||||
@@ -219,7 +239,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const [store, setStore] = createStore<{
|
||||
popover: "at" | "slash" | null
|
||||
historyIndex: number
|
||||
savedPrompt: Prompt | null
|
||||
savedPrompt: PromptHistoryEntry | null
|
||||
placeholder: number
|
||||
draggingType: "image" | "@mention" | null
|
||||
mode: "normal" | "shell"
|
||||
@@ -227,7 +247,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}>({
|
||||
popover: null,
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
savedPrompt: null as PromptHistoryEntry | null,
|
||||
placeholder: Math.floor(Math.random() * EXAMPLES.length),
|
||||
draggingType: null,
|
||||
mode: "normal",
|
||||
@@ -256,7 +276,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const [history, setHistory] = persisted(
|
||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||
createStore<{
|
||||
entries: Prompt[]
|
||||
entries: PromptHistoryStoredEntry[]
|
||||
}>({
|
||||
entries: [],
|
||||
}),
|
||||
@@ -264,7 +284,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const [shellHistory, setShellHistory] = persisted(
|
||||
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
||||
createStore<{
|
||||
entries: Prompt[]
|
||||
entries: PromptHistoryStoredEntry[]
|
||||
}>({
|
||||
entries: [],
|
||||
}),
|
||||
@@ -282,9 +302,66 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}),
|
||||
)
|
||||
|
||||
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
|
||||
const historyComments = () => {
|
||||
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||
return prompt.context.items().flatMap((item) => {
|
||||
if (item.type !== "file") return []
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment) return []
|
||||
|
||||
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
|
||||
const nextSelection =
|
||||
selection ??
|
||||
(item.selection
|
||||
? ({
|
||||
start: item.selection.startLine,
|
||||
end: item.selection.endLine,
|
||||
} satisfies SelectedLineRange)
|
||||
: undefined)
|
||||
if (!nextSelection) return []
|
||||
|
||||
return [
|
||||
{
|
||||
id: item.commentID ?? item.key,
|
||||
path: item.path,
|
||||
selection: { ...nextSelection },
|
||||
comment,
|
||||
time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
|
||||
origin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
} satisfies PromptHistoryComment,
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const applyHistoryComments = (items: PromptHistoryComment[]) => {
|
||||
comments.replace(
|
||||
items.map((item) => ({
|
||||
id: item.id,
|
||||
file: item.path,
|
||||
selection: { ...item.selection },
|
||||
comment: item.comment,
|
||||
time: item.time,
|
||||
})),
|
||||
)
|
||||
prompt.context.replaceComments(
|
||||
items.map((item) => ({
|
||||
type: "file" as const,
|
||||
path: item.path,
|
||||
selection: selectionFromLines(item.selection),
|
||||
comment: item.comment,
|
||||
commentID: item.id,
|
||||
commentOrigin: item.origin,
|
||||
preview: item.preview,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
const applyHistoryPrompt = (entry: PromptHistoryEntry, position: "start" | "end") => {
|
||||
const p = entry.prompt
|
||||
const length = position === "start" ? 0 : promptLength(p)
|
||||
setStore("applyingHistory", true)
|
||||
applyHistoryComments(entry.comments)
|
||||
prompt.set(p, length)
|
||||
requestAnimationFrame(() => {
|
||||
editorRef.focus()
|
||||
@@ -846,7 +923,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||
const currentHistory = mode === "shell" ? shellHistory : history
|
||||
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
|
||||
const next = prependHistoryEntry(currentHistory.entries, prompt)
|
||||
const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments())
|
||||
if (next === currentHistory.entries) return
|
||||
setCurrentHistory("entries", next)
|
||||
}
|
||||
@@ -857,12 +934,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
entries: store.mode === "shell" ? shellHistory.entries : history.entries,
|
||||
historyIndex: store.historyIndex,
|
||||
currentPrompt: prompt.current(),
|
||||
currentComments: historyComments(),
|
||||
savedPrompt: store.savedPrompt,
|
||||
})
|
||||
if (!result.handled) return false
|
||||
setStore("historyIndex", result.historyIndex)
|
||||
setStore("savedPrompt", result.savedPrompt)
|
||||
applyHistoryPrompt(result.prompt, result.cursor)
|
||||
applyHistoryPrompt(result.entry, result.cursor)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { same } from "@/utils/same"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { Code } from "@opencode-ai/ui/code"
|
||||
import { File } from "@opencode-ai/ui/file"
|
||||
import { Markdown } from "@opencode-ai/ui/markdown"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
@@ -47,7 +47,8 @@ function RawMessageContent(props: { message: Message; getParts: (id: string) =>
|
||||
})
|
||||
|
||||
return (
|
||||
<Code
|
||||
<File
|
||||
mode="text"
|
||||
file={file()}
|
||||
overflow="wrap"
|
||||
class="select-text"
|
||||
|
||||
@@ -150,4 +150,37 @@ describe("comments session indexing", () => {
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("update changes only the targeted comment body", () => {
|
||||
createRoot((dispose) => {
|
||||
const comments = createCommentSessionForTest({
|
||||
"a.ts": [line("a.ts", "a1", 10), line("a.ts", "a2", 20)],
|
||||
})
|
||||
|
||||
comments.update("a.ts", "a2", "edited")
|
||||
|
||||
expect(comments.list("a.ts").map((item) => item.comment)).toEqual(["a1", "edited"])
|
||||
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("replace swaps comment state and clears focus state", () => {
|
||||
createRoot((dispose) => {
|
||||
const comments = createCommentSessionForTest({
|
||||
"a.ts": [line("a.ts", "a1", 10)],
|
||||
})
|
||||
|
||||
comments.setFocus({ file: "a.ts", id: "a1" })
|
||||
comments.setActive({ file: "a.ts", id: "a1" })
|
||||
comments.replace([line("b.ts", "b1", 30)])
|
||||
|
||||
expect(comments.list("a.ts")).toEqual([])
|
||||
expect(comments.list("b.ts").map((item) => item.id)).toEqual(["b1"])
|
||||
expect(comments.focus()).toBeNull()
|
||||
expect(comments.active()).toBeNull()
|
||||
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,6 +44,37 @@ function aggregate(comments: Record<string, LineComment[]>) {
|
||||
.sort((a, b) => a.time - b.time)
|
||||
}
|
||||
|
||||
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
|
||||
const next: SelectedLineRange = {
|
||||
start: selection.start,
|
||||
end: selection.end,
|
||||
}
|
||||
|
||||
if (selection.side) next.side = selection.side
|
||||
if (selection.endSide) next.endSide = selection.endSide
|
||||
return next
|
||||
}
|
||||
|
||||
function cloneComment(comment: LineComment): LineComment {
|
||||
return {
|
||||
...comment,
|
||||
selection: cloneSelection(comment.selection),
|
||||
}
|
||||
}
|
||||
|
||||
function group(comments: LineComment[]) {
|
||||
return comments.reduce<Record<string, LineComment[]>>((acc, comment) => {
|
||||
const list = acc[comment.file]
|
||||
const next = cloneComment(comment)
|
||||
if (list) {
|
||||
list.push(next)
|
||||
return acc
|
||||
}
|
||||
acc[comment.file] = [next]
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
|
||||
const [state, setState] = createStore({
|
||||
focus: null as CommentFocus | null,
|
||||
@@ -70,6 +101,7 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
|
||||
id: uuid(),
|
||||
time: Date.now(),
|
||||
...input,
|
||||
selection: cloneSelection(input.selection),
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
@@ -87,6 +119,23 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
|
||||
})
|
||||
}
|
||||
|
||||
const update = (file: string, id: string, comment: string) => {
|
||||
setStore("comments", file, (items) =>
|
||||
(items ?? []).map((item) => {
|
||||
if (item.id !== id) return item
|
||||
return { ...item, comment }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const replace = (comments: LineComment[]) => {
|
||||
batch(() => {
|
||||
setStore("comments", reconcile(group(comments)))
|
||||
setFocus(null)
|
||||
setActive(null)
|
||||
})
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
batch(() => {
|
||||
setStore("comments", reconcile({}))
|
||||
@@ -100,6 +149,8 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
|
||||
all,
|
||||
add,
|
||||
remove,
|
||||
update,
|
||||
replace,
|
||||
clear,
|
||||
focus: () => state.focus,
|
||||
setFocus,
|
||||
@@ -132,6 +183,8 @@ function createCommentSession(dir: string, id: string | undefined) {
|
||||
all: session.all,
|
||||
add: session.add,
|
||||
remove: session.remove,
|
||||
update: session.update,
|
||||
replace: session.replace,
|
||||
clear: session.clear,
|
||||
focus: session.focus,
|
||||
setFocus: session.setFocus,
|
||||
@@ -176,6 +229,8 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
||||
all: () => session().all(),
|
||||
add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
|
||||
remove: (file: string, id: string) => session().remove(file, id),
|
||||
update: (file: string, id: string, comment: string) => session().update(file, id, comment),
|
||||
replace: (comments: LineComment[]) => session().replace(comments),
|
||||
clear: () => session().clear(),
|
||||
focus: () => session().focus(),
|
||||
setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,4 +41,24 @@ describe("createScrollPersistence", () => {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
test("reseeds empty cache after persisted snapshot loads", () => {
|
||||
const snapshot = {
|
||||
session: {},
|
||||
} as Record<string, Record<string, { x: number; y: number }>>
|
||||
|
||||
const scroll = createScrollPersistence({
|
||||
getSnapshot: (sessionKey) => snapshot[sessionKey],
|
||||
onFlush: () => {},
|
||||
})
|
||||
|
||||
expect(scroll.scroll("session", "review")).toBeUndefined()
|
||||
|
||||
snapshot.session = {
|
||||
review: { x: 12, y: 34 },
|
||||
}
|
||||
|
||||
expect(scroll.scroll("session", "review")).toEqual({ x: 12, y: 34 })
|
||||
scroll.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -116,6 +116,10 @@ function contextItemKey(item: ContextItem) {
|
||||
return `${key}:c=${digest.slice(0, 8)}`
|
||||
}
|
||||
|
||||
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
|
||||
return item.type === "file" && !!item.comment?.trim()
|
||||
}
|
||||
|
||||
function createPromptActions(
|
||||
setStore: SetStoreFunction<{
|
||||
prompt: Prompt
|
||||
@@ -189,6 +193,26 @@ function createPromptSession(dir: string, id: string | undefined) {
|
||||
remove(key: string) {
|
||||
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
||||
},
|
||||
removeComment(path: string, commentID: string) {
|
||||
setStore("context", "items", (items) =>
|
||||
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
|
||||
)
|
||||
},
|
||||
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
|
||||
setStore("context", "items", (items) =>
|
||||
items.map((item) => {
|
||||
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
|
||||
const value = { ...item, ...next }
|
||||
return { ...value, key: contextItemKey(value) }
|
||||
}),
|
||||
)
|
||||
},
|
||||
replaceComments(items: FileContextItem[]) {
|
||||
setStore("context", "items", (current) => [
|
||||
...current.filter((item) => !isCommentItem(item)),
|
||||
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
|
||||
])
|
||||
},
|
||||
},
|
||||
set: actions.set,
|
||||
reset: actions.reset,
|
||||
@@ -251,6 +275,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||
items: () => session().context.items(),
|
||||
add: (item: ContextItem) => session().context.add(item),
|
||||
remove: (key: string) => session().context.remove(key),
|
||||
removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID),
|
||||
updateComment: (path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) =>
|
||||
session().context.updateComment(path, commentID, next),
|
||||
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
|
||||
},
|
||||
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
|
||||
reset: () => session().reset(),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useCodeComponent } from "@opencode-ai/ui/context/code"
|
||||
import type { FileSearchHandle } from "@opencode-ai/ui/file"
|
||||
import { useFileComponent } from "@opencode-ai/ui/context/file"
|
||||
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
|
||||
import { createLineCommentController } from "@opencode-ai/ui/line-comment-annotations"
|
||||
import { sampledChecksum } from "@opencode-ai/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||
import { useComments } from "@/context/comments"
|
||||
@@ -17,11 +19,37 @@ import { useLanguage } from "@/context/language"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { getSessionHandoff } from "@/pages/session/handoff"
|
||||
|
||||
const formatCommentLabel = (range: SelectedLineRange) => {
|
||||
const start = Math.min(range.start, range.end)
|
||||
const end = Math.max(range.start, range.end)
|
||||
if (start === end) return `line ${start}`
|
||||
return `lines ${start}-${end}`
|
||||
function FileCommentMenu(props: {
|
||||
moreLabel: string
|
||||
editLabel: string
|
||||
deleteLabel: string
|
||||
onEdit: VoidFunction
|
||||
onDelete: VoidFunction
|
||||
}) {
|
||||
return (
|
||||
<div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
|
||||
<DropdownMenu gutter={4} placement="bottom-end">
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
class="size-6 rounded-md"
|
||||
aria-label={props.moreLabel}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item onSelect={props.onEdit}>
|
||||
<DropdownMenu.ItemLabel>{props.editLabel}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={props.onDelete}>
|
||||
<DropdownMenu.ItemLabel>{props.deleteLabel}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FileTabContent(props: { tab: string }) {
|
||||
@@ -31,7 +59,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
const comments = useComments()
|
||||
const language = useLanguage()
|
||||
const prompt = usePrompt()
|
||||
const codeComponent = useCodeComponent()
|
||||
const fileComponent = useFileComponent()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
@@ -41,6 +69,13 @@ export function FileTabContent(props: { tab: string }) {
|
||||
let scrollFrame: number | undefined
|
||||
let pending: { x: number; y: number } | undefined
|
||||
let codeScroll: HTMLElement[] = []
|
||||
let find: FileSearchHandle | null = null
|
||||
|
||||
const search = {
|
||||
register: (handle: FileSearchHandle | null) => {
|
||||
find = handle
|
||||
},
|
||||
}
|
||||
|
||||
const path = createMemo(() => file.pathFromTab(props.tab))
|
||||
const state = createMemo(() => {
|
||||
@@ -50,66 +85,18 @@ export function FileTabContent(props: { tab: string }) {
|
||||
})
|
||||
const contents = createMemo(() => state()?.content?.content ?? "")
|
||||
const cacheKey = createMemo(() => sampledChecksum(contents()))
|
||||
const isImage = createMemo(() => {
|
||||
const c = state()?.content
|
||||
return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
|
||||
})
|
||||
const isSvg = createMemo(() => {
|
||||
const c = state()?.content
|
||||
return c?.mimeType === "image/svg+xml"
|
||||
})
|
||||
const isBinary = createMemo(() => state()?.content?.type === "binary")
|
||||
const svgContent = createMemo(() => {
|
||||
if (!isSvg()) return
|
||||
const c = state()?.content
|
||||
if (!c) return
|
||||
if (c.encoding !== "base64") return c.content
|
||||
return decode64(c.content)
|
||||
})
|
||||
|
||||
const svgDecodeFailed = createMemo(() => {
|
||||
if (!isSvg()) return false
|
||||
const c = state()?.content
|
||||
if (!c) return false
|
||||
if (c.encoding !== "base64") return false
|
||||
return svgContent() === undefined
|
||||
})
|
||||
|
||||
const svgToast = { shown: false }
|
||||
createEffect(() => {
|
||||
if (!svgDecodeFailed()) return
|
||||
if (svgToast.shown) return
|
||||
svgToast.shown = true
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.file.loadFailed.title"),
|
||||
})
|
||||
})
|
||||
const svgPreviewUrl = createMemo(() => {
|
||||
if (!isSvg()) return
|
||||
const c = state()?.content
|
||||
if (!c) return
|
||||
if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
|
||||
})
|
||||
const imageDataUrl = createMemo(() => {
|
||||
if (!isImage()) return
|
||||
const c = state()?.content
|
||||
return `data:${c?.mimeType};base64,${c?.content}`
|
||||
})
|
||||
const selectedLines = createMemo(() => {
|
||||
const selectedLines = createMemo<SelectedLineRange | null>(() => {
|
||||
const p = path()
|
||||
if (!p) return null
|
||||
if (file.ready()) return file.selectedLines(p) ?? null
|
||||
return getSessionHandoff(sessionKey())?.files[p] ?? null
|
||||
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
|
||||
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
|
||||
})
|
||||
|
||||
const selectionPreview = (source: string, selection: FileSelection) => {
|
||||
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
|
||||
const end = Math.max(selection.startLine, selection.endLine)
|
||||
const lines = source.split("\n").slice(start - 1, end)
|
||||
if (lines.length === 0) return undefined
|
||||
return lines.slice(0, 2).join("\n")
|
||||
return previewSelectedLines(source, {
|
||||
start: selection.startLine,
|
||||
end: selection.endLine,
|
||||
})
|
||||
}
|
||||
|
||||
const addCommentToContext = (input: {
|
||||
@@ -145,7 +132,25 @@ export function FileTabContent(props: { tab: string }) {
|
||||
})
|
||||
}
|
||||
|
||||
let wrap: HTMLDivElement | undefined
|
||||
const updateCommentInContext = (input: {
|
||||
id: string
|
||||
file: string
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
}) => {
|
||||
comments.update(input.file, input.id, input.comment)
|
||||
const preview =
|
||||
input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined
|
||||
prompt.context.updateComment(input.file, input.id, {
|
||||
comment: input.comment,
|
||||
...(preview ? { preview } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
const removeCommentFromContext = (input: { id: string; file: string }) => {
|
||||
comments.remove(input.file, input.id)
|
||||
prompt.context.removeComment(input.file, input.id)
|
||||
}
|
||||
|
||||
const fileComments = createMemo(() => {
|
||||
const p = path()
|
||||
@@ -153,121 +158,105 @@ export function FileTabContent(props: { tab: string }) {
|
||||
return comments.list(p)
|
||||
})
|
||||
|
||||
const commentLayout = createMemo(() => {
|
||||
return fileComments()
|
||||
.map((comment) => `${comment.id}:${comment.selection.start}:${comment.selection.end}`)
|
||||
.join("|")
|
||||
})
|
||||
|
||||
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
|
||||
|
||||
const [note, setNote] = createStore({
|
||||
openedComment: null as string | null,
|
||||
commenting: null as SelectedLineRange | null,
|
||||
draft: "",
|
||||
positions: {} as Record<string, number>,
|
||||
draftTop: undefined as number | undefined,
|
||||
selected: null as SelectedLineRange | null,
|
||||
})
|
||||
|
||||
const setCommenting = (range: SelectedLineRange | null) => {
|
||||
setNote("commenting", range)
|
||||
scheduleComments()
|
||||
if (!range) return
|
||||
setNote("draft", "")
|
||||
const 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) => (
|
||||
<FileCommentMenu
|
||||
moreLabel={language.t("common.moreOptions")}
|
||||
editLabel={language.t("common.edit")}
|
||||
deleteLabel={language.t("common.delete")}
|
||||
onEdit={controls.edit}
|
||||
onDelete={controls.remove}
|
||||
/>
|
||||
),
|
||||
onDraftPopoverFocusOut: (e: FocusEvent) => {
|
||||
const current = e.currentTarget as HTMLDivElement
|
||||
const target = e.relatedTarget
|
||||
if (target instanceof Node && current.contains(target)) return
|
||||
|
||||
const root = host.shadowRoot
|
||||
if (!root) return
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
|
||||
const line = Math.max(range.start, range.end)
|
||||
const node = root.querySelector(`[data-line="${line}"]`)
|
||||
if (!(node instanceof HTMLElement)) return
|
||||
return node
|
||||
}
|
||||
|
||||
const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const rect = marker.getBoundingClientRect()
|
||||
return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
|
||||
}
|
||||
|
||||
const updateComments = () => {
|
||||
const el = wrap
|
||||
const root = getRoot()
|
||||
if (!el || !root) {
|
||||
setNote("positions", {})
|
||||
setNote("draftTop", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const estimateTop = (range: SelectedLineRange) => {
|
||||
const line = Math.max(range.start, range.end)
|
||||
const height = 24
|
||||
const offset = 2
|
||||
return Math.max(0, (line - 1) * height + offset)
|
||||
}
|
||||
|
||||
const large = contents().length > 500_000
|
||||
|
||||
const next: Record<string, number> = {}
|
||||
for (const comment of fileComments()) {
|
||||
const marker = findMarker(root, comment.selection)
|
||||
if (marker) next[comment.id] = markerTop(el, marker)
|
||||
else if (large) next[comment.id] = estimateTop(comment.selection)
|
||||
}
|
||||
|
||||
const removed = Object.keys(note.positions).filter((id) => next[id] === undefined)
|
||||
const changed = Object.entries(next).filter(([id, top]) => note.positions[id] !== top)
|
||||
if (removed.length > 0 || changed.length > 0) {
|
||||
setNote(
|
||||
"positions",
|
||||
produce((draft) => {
|
||||
for (const id of removed) {
|
||||
delete draft[id]
|
||||
}
|
||||
|
||||
for (const [id, top] of changed) {
|
||||
draft[id] = top
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const range = note.commenting
|
||||
if (!range) {
|
||||
setNote("draftTop", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const marker = findMarker(root, range)
|
||||
if (marker) {
|
||||
setNote("draftTop", markerTop(el, marker))
|
||||
return
|
||||
}
|
||||
|
||||
setNote("draftTop", large ? estimateTop(range) : undefined)
|
||||
}
|
||||
|
||||
const scheduleComments = () => {
|
||||
requestAnimationFrame(updateComments)
|
||||
}
|
||||
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) => (
|
||||
<div
|
||||
ref={(el) => {
|
||||
wrap = el
|
||||
scheduleComments()
|
||||
}}
|
||||
class={`relative overflow-hidden ${wrapperClass}`}
|
||||
>
|
||||
const renderFile = (source: string) => (
|
||||
<div class="relative overflow-hidden pb-40">
|
||||
<Dynamic
|
||||
component={codeComponent}
|
||||
component={fileComponent}
|
||||
mode="text"
|
||||
file={{
|
||||
name: path() ?? "",
|
||||
contents: source,
|
||||
cacheKey: cacheKey(),
|
||||
}}
|
||||
enableLineSelection
|
||||
selectedLines={selectedLines()}
|
||||
enableHoverUtility
|
||||
selectedLines={activeSelection()}
|
||||
commentedLines={commentedLines()}
|
||||
onRendered={() => {
|
||||
requestAnimationFrame(restoreScroll)
|
||||
requestAnimationFrame(scheduleComments)
|
||||
}}
|
||||
annotations={commentsUi.annotations()}
|
||||
renderAnnotation={commentsUi.renderAnnotation}
|
||||
renderHoverUtility={commentsUi.renderHoverUtility}
|
||||
onLineSelected={(range: SelectedLineRange | null) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
file.setSelectedLines(p, range)
|
||||
if (!range) setCommenting(null)
|
||||
commentsUi.onLineSelected(range)
|
||||
}}
|
||||
onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
|
||||
onLineSelectionEnd={(range: SelectedLineRange | null) => {
|
||||
if (!range) {
|
||||
setCommenting(null)
|
||||
return
|
||||
}
|
||||
|
||||
setNote("openedComment", null)
|
||||
setCommenting(range)
|
||||
commentsUi.onLineSelectionEnd(range)
|
||||
}}
|
||||
search={search}
|
||||
overflow="scroll"
|
||||
class="select-text"
|
||||
media={{
|
||||
mode: "auto",
|
||||
path: path(),
|
||||
current: state()?.content,
|
||||
onLoad: () => requestAnimationFrame(restoreScroll),
|
||||
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
||||
if (args.kind !== "svg") return
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.file.loadFailed.title"),
|
||||
})
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<For each={fileComments()}>
|
||||
{(comment) => (
|
||||
<LineCommentView
|
||||
id={comment.id}
|
||||
top={note.positions[comment.id]}
|
||||
open={note.openedComment === comment.id}
|
||||
comment={comment.comment}
|
||||
selection={formatCommentLabel(comment.selection)}
|
||||
onMouseEnter={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
file.setSelectedLines(p, comment.selection)
|
||||
}}
|
||||
onClick={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
setCommenting(null)
|
||||
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
|
||||
file.setSelectedLines(p, comment.selection)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={note.commenting}>
|
||||
{(range) => (
|
||||
<Show when={note.draftTop !== undefined}>
|
||||
<LineCommentEditor
|
||||
top={note.draftTop}
|
||||
value={note.draft}
|
||||
selection={formatCommentLabel(range())}
|
||||
onInput={(value) => setNote("draft", value)}
|
||||
onCancel={cancelCommenting}
|
||||
onSubmit={(value) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" })
|
||||
setCommenting(null)
|
||||
}}
|
||||
onPopoverFocusOut={(e: FocusEvent) => {
|
||||
const current = e.currentTarget as HTMLDivElement
|
||||
const target = e.relatedTarget
|
||||
if (target instanceof Node && current.contains(target)) return
|
||||
|
||||
setTimeout(() => {
|
||||
if (!document.activeElement || !current.contains(document.activeElement)) {
|
||||
cancelCommenting()
|
||||
}
|
||||
}, 0)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -526,36 +464,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
onScroll={handleScroll as any}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={state()?.loaded && isImage()}>
|
||||
<div class="px-6 py-4 pb-40">
|
||||
<img
|
||||
src={imageDataUrl()}
|
||||
alt={path()}
|
||||
class="max-w-full"
|
||||
onLoad={() => requestAnimationFrame(restoreScroll)}
|
||||
/>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={state()?.loaded && isSvg()}>
|
||||
<div class="flex flex-col gap-4 px-6 py-4">
|
||||
{renderCode(svgContent() ?? "", "")}
|
||||
<Show when={svgPreviewUrl()}>
|
||||
<div class="flex justify-center pb-40">
|
||||
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={state()?.loaded && isBinary()}>
|
||||
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="flex flex-col gap-2 max-w-md">
|
||||
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
|
||||
<div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
|
||||
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
|
||||
<Match when={state()?.loading}>
|
||||
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
|
||||
</Match>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { For, createEffect, createMemo, on, onCleanup, Show, type JSX } from "so
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
@@ -9,8 +10,9 @@ import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -18,6 +20,35 @@ import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||
|
||||
type MessageComment = {
|
||||
path: string
|
||||
comment: string
|
||||
selection?: {
|
||||
startLine: number
|
||||
endLine: number
|
||||
}
|
||||
}
|
||||
|
||||
const messageComments = (parts: Part[]): MessageComment[] =>
|
||||
parts.flatMap((part) => {
|
||||
if (part.type !== "text" || !(part as TextPart).synthetic) return []
|
||||
const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text)
|
||||
if (!next) return []
|
||||
return [
|
||||
{
|
||||
path: next.path,
|
||||
comment: next.comment,
|
||||
selection: next.selection
|
||||
? {
|
||||
startLine: next.selection.startLine,
|
||||
endLine: next.selection.endLine,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
|
||||
const current = target instanceof Element ? target : undefined
|
||||
@@ -522,34 +553,67 @@ export function MessageTimeline(props: {
|
||||
</div>
|
||||
</Show>
|
||||
<For each={props.renderedUserMessages}>
|
||||
{(message) => (
|
||||
<div
|
||||
id={props.anchor(message.id)}
|
||||
data-message-id={message.id}
|
||||
ref={(el) => {
|
||||
props.onRegisterMessage(el, message.id)
|
||||
onCleanup(() => props.onUnregisterMessage(message.id))
|
||||
}}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<SessionTurn
|
||||
sessionID={sessionID() ?? ""}
|
||||
messageID={message.id}
|
||||
lastUserMessageID={props.lastUserMessageID}
|
||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
|
||||
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
container: "w-full px-4 md:px-5",
|
||||
{(message) => {
|
||||
const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? []))
|
||||
return (
|
||||
<div
|
||||
id={props.anchor(message.id)}
|
||||
data-message-id={message.id}
|
||||
ref={(el) => {
|
||||
props.onRegisterMessage(el, message.id)
|
||||
onCleanup(() => props.onUnregisterMessage(message.id))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<Show when={comments().length > 0}>
|
||||
<div class="w-full px-4 md:px-5 pb-2">
|
||||
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||
<div class="flex w-max min-w-full justify-end gap-2">
|
||||
<For each={comments()}>
|
||||
{(comment) => (
|
||||
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
|
||||
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
||||
<FileIcon node={{ path: comment.path, type: "file" }} class="size-3.5 shrink-0" />
|
||||
<span class="truncate">{getFilename(comment.path)}</span>
|
||||
<Show when={comment.selection}>
|
||||
{(selection) => (
|
||||
<span class="shrink-0 text-text-weak">
|
||||
{selection().startLine === selection().endLine
|
||||
? `:${selection().startLine}`
|
||||
: `:${selection().startLine}-${selection().endLine}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
|
||||
{comment.comment}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<SessionTurn
|
||||
sessionID={sessionID() ?? ""}
|
||||
messageID={message.id}
|
||||
lastUserMessageID={props.lastUserMessageID}
|
||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
|
||||
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
container: "w-full px-4 md:px-5",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</ScrollView>
|
||||
|
||||
@@ -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}
|
||||
|
||||
88
packages/app/src/utils/comment-note.ts
Normal file
88
packages/app/src/utils/comment-note.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { FileSelection } from "@/context/file"
|
||||
|
||||
export type PromptComment = {
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment: string
|
||||
preview?: string
|
||||
origin?: "review" | "file"
|
||||
}
|
||||
|
||||
function selection(selection: unknown) {
|
||||
if (!selection || typeof selection !== "object") return undefined
|
||||
const startLine = Number((selection as FileSelection).startLine)
|
||||
const startChar = Number((selection as FileSelection).startChar)
|
||||
const endLine = Number((selection as FileSelection).endLine)
|
||||
const endChar = Number((selection as FileSelection).endChar)
|
||||
if (![startLine, startChar, endLine, endChar].every(Number.isFinite)) return undefined
|
||||
return {
|
||||
startLine,
|
||||
startChar,
|
||||
endLine,
|
||||
endChar,
|
||||
} satisfies FileSelection
|
||||
}
|
||||
|
||||
export function createCommentMetadata(input: PromptComment) {
|
||||
return {
|
||||
opencodeComment: {
|
||||
path: input.path,
|
||||
selection: input.selection,
|
||||
comment: input.comment,
|
||||
preview: input.preview,
|
||||
origin: input.origin,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function readCommentMetadata(value: unknown) {
|
||||
if (!value || typeof value !== "object") return
|
||||
const meta = (value as { opencodeComment?: unknown }).opencodeComment
|
||||
if (!meta || typeof meta !== "object") return
|
||||
const path = (meta as { path?: unknown }).path
|
||||
const comment = (meta as { comment?: unknown }).comment
|
||||
if (typeof path !== "string" || typeof comment !== "string") return
|
||||
const preview = (meta as { preview?: unknown }).preview
|
||||
const origin = (meta as { origin?: unknown }).origin
|
||||
return {
|
||||
path,
|
||||
selection: selection((meta as { selection?: unknown }).selection),
|
||||
comment,
|
||||
preview: typeof preview === "string" ? preview : undefined,
|
||||
origin: origin === "review" || origin === "file" ? origin : undefined,
|
||||
} satisfies PromptComment
|
||||
}
|
||||
|
||||
export function formatCommentNote(input: { path: string; selection?: FileSelection; comment: string }) {
|
||||
const start = input.selection ? Math.min(input.selection.startLine, input.selection.endLine) : undefined
|
||||
const end = input.selection ? Math.max(input.selection.startLine, input.selection.endLine) : undefined
|
||||
const range =
|
||||
start === undefined || end === undefined
|
||||
? "this file"
|
||||
: start === end
|
||||
? `line ${start}`
|
||||
: `lines ${start} through ${end}`
|
||||
return `The user made the following comment regarding ${range} of ${input.path}: ${input.comment}`
|
||||
}
|
||||
|
||||
export function parseCommentNote(text: string) {
|
||||
const match = text.match(
|
||||
/^The user made the following comment regarding (this file|line (\d+)|lines (\d+) through (\d+)) of (.+?): ([\s\S]+)$/,
|
||||
)
|
||||
if (!match) return
|
||||
const start = match[2] ? Number(match[2]) : match[3] ? Number(match[3]) : undefined
|
||||
const end = match[2] ? Number(match[2]) : match[4] ? Number(match[4]) : undefined
|
||||
return {
|
||||
path: match[5],
|
||||
selection:
|
||||
start !== undefined && end !== undefined
|
||||
? {
|
||||
startLine: start,
|
||||
startChar: 0,
|
||||
endLine: end,
|
||||
endChar: 0,
|
||||
}
|
||||
: undefined,
|
||||
comment: match[6],
|
||||
} satisfies PromptComment
|
||||
}
|
||||
@@ -2,8 +2,7 @@ import { FileDiff, Message, Model, Part, Session, SessionStatus, UserMessage } f
|
||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
import { SessionReview } from "@opencode-ai/ui/session-review"
|
||||
import { DataProvider } from "@opencode-ai/ui/context"
|
||||
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
|
||||
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
|
||||
@@ -22,14 +21,12 @@ import NotFound from "../[...404]"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { MessageNav } from "@opencode-ai/ui/message-nav"
|
||||
import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { Diff as SSRDiff } from "@opencode-ai/ui/diff-ssr"
|
||||
import { FileSSR } from "@opencode-ai/ui/file-ssr"
|
||||
import { clientOnly } from "@solidjs/start"
|
||||
import { type IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { Meta, Title } from "@solidjs/meta"
|
||||
import { Base64 } from "js-base64"
|
||||
|
||||
const ClientOnlyDiff = clientOnly(() => import("@opencode-ai/ui/diff").then((m) => ({ default: m.Diff })))
|
||||
const ClientOnlyCode = clientOnly(() => import("@opencode-ai/ui/code").then((m) => ({ default: m.Code })))
|
||||
const ClientOnlyWorkerPoolProvider = clientOnly(() =>
|
||||
import("@opencode-ai/ui/pierre/worker").then((m) => ({
|
||||
default: (props: { children: any }) => (
|
||||
@@ -218,252 +215,244 @@ export default function () {
|
||||
<Meta property="og:image" content={ogImage()} />
|
||||
<Meta name="twitter:image" content={ogImage()} />
|
||||
<ClientOnlyWorkerPoolProvider>
|
||||
<DiffComponentProvider component={ClientOnlyDiff}>
|
||||
<CodeComponentProvider component={ClientOnlyCode}>
|
||||
<DataProvider data={data()} directory={info().directory}>
|
||||
{iife(() => {
|
||||
const [store, setStore] = createStore({
|
||||
messageId: undefined as string | undefined,
|
||||
})
|
||||
const messages = createMemo(() =>
|
||||
data().sessionID
|
||||
? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
|
||||
(a, b) => a.time.created - b.time.created,
|
||||
)
|
||||
: [],
|
||||
)
|
||||
const firstUserMessage = createMemo(() => messages().at(0))
|
||||
const activeMessage = createMemo(
|
||||
() => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
|
||||
)
|
||||
function setActiveMessage(message: UserMessage | undefined) {
|
||||
if (message) {
|
||||
setStore("messageId", message.id)
|
||||
} else {
|
||||
setStore("messageId", undefined)
|
||||
}
|
||||
<FileComponentProvider component={FileSSR}>
|
||||
<DataProvider data={data()} directory={info().directory}>
|
||||
{iife(() => {
|
||||
const [store, setStore] = createStore({
|
||||
messageId: undefined as string | undefined,
|
||||
})
|
||||
const messages = createMemo(() =>
|
||||
data().sessionID
|
||||
? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
|
||||
(a, b) => a.time.created - b.time.created,
|
||||
)
|
||||
: [],
|
||||
)
|
||||
const firstUserMessage = createMemo(() => messages().at(0))
|
||||
const activeMessage = createMemo(
|
||||
() => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
|
||||
)
|
||||
function setActiveMessage(message: UserMessage | undefined) {
|
||||
if (message) {
|
||||
setStore("messageId", message.id)
|
||||
} else {
|
||||
setStore("messageId", undefined)
|
||||
}
|
||||
const provider = createMemo(() => activeMessage()?.model?.providerID)
|
||||
const modelID = createMemo(() => activeMessage()?.model?.modelID)
|
||||
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
|
||||
const diffs = createMemo(() => {
|
||||
const diffs = data().session_diff[data().sessionID] ?? []
|
||||
const preloaded = data().session_diff_preload[data().sessionID] ?? []
|
||||
return diffs.map((diff) => ({
|
||||
...diff,
|
||||
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
|
||||
}))
|
||||
})
|
||||
const splitDiffs = createMemo(() => {
|
||||
const diffs = data().session_diff[data().sessionID] ?? []
|
||||
const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
|
||||
return diffs.map((diff) => ({
|
||||
...diff,
|
||||
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
|
||||
}))
|
||||
})
|
||||
}
|
||||
const provider = createMemo(() => activeMessage()?.model?.providerID)
|
||||
const modelID = createMemo(() => activeMessage()?.model?.modelID)
|
||||
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
|
||||
const diffs = createMemo(() => {
|
||||
const diffs = data().session_diff[data().sessionID] ?? []
|
||||
const preloaded = data().session_diff_preload[data().sessionID] ?? []
|
||||
return diffs.map((diff) => ({
|
||||
...diff,
|
||||
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
|
||||
}))
|
||||
})
|
||||
const splitDiffs = createMemo(() => {
|
||||
const diffs = data().session_diff[data().sessionID] ?? []
|
||||
const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
|
||||
return diffs.map((diff) => ({
|
||||
...diff,
|
||||
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
|
||||
}))
|
||||
})
|
||||
|
||||
const title = () => (
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:gap-4 sm:items-center sm:h-8 justify-start self-stretch">
|
||||
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base w-fit">
|
||||
<Mark class="shrink-0 w-3 my-0.5" />
|
||||
<div class="text-12-mono text-text-base">v{info().version}</div>
|
||||
</div>
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="flex gap-2 items-center">
|
||||
<ProviderIcon
|
||||
id={provider() as IconName}
|
||||
class="size-3.5 shrink-0 text-icon-strong-base"
|
||||
/>
|
||||
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weaker">
|
||||
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
|
||||
</div>
|
||||
</div>
|
||||
const title = () => (
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:gap-4 sm:items-center sm:h-8 justify-start self-stretch">
|
||||
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base w-fit">
|
||||
<Mark class="shrink-0 w-3 my-0.5" />
|
||||
<div class="text-12-mono text-text-base">v{info().version}</div>
|
||||
</div>
|
||||
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const turns = () => (
|
||||
<div class="relative mt-2 pb-8 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
|
||||
<div class="px-4 py-6">{title()}</div>
|
||||
<div class="flex flex-col gap-15 items-start justify-start mt-4">
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<SessionTurn
|
||||
sessionID={data().sessionID}
|
||||
messageID={message.id}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
container: "px-4",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="px-4 flex items-center justify-center pt-20 pb-8 shrink-0">
|
||||
<Logo class="w-58.5 opacity-12" />
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="flex gap-2 items-center">
|
||||
<ProviderIcon
|
||||
id={provider() as IconName}
|
||||
class="size-3.5 shrink-0 text-icon-strong-base"
|
||||
/>
|
||||
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weaker">
|
||||
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const wide = createMemo(() => diffs().length === 0)
|
||||
const turns = () => (
|
||||
<div class="relative mt-2 pb-8 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
|
||||
<div class="px-4 py-6">{title()}</div>
|
||||
<div class="flex flex-col gap-15 items-start justify-start mt-4">
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<SessionTurn
|
||||
sessionID={data().sessionID}
|
||||
messageID={message.id}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
container: "px-4",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="px-4 flex items-center justify-center pt-20 pb-8 shrink-0">
|
||||
<Logo class="w-58.5 opacity-12" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
|
||||
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
|
||||
<div class="">
|
||||
<a href="https://opencode.ai">
|
||||
<Mark />
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex gap-3 items-center">
|
||||
<IconButton
|
||||
as={"a"}
|
||||
href="https://github.com/anomalyco/opencode"
|
||||
target="_blank"
|
||||
icon="github"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
as={"a"}
|
||||
href="https://opencode.ai/discord"
|
||||
target="_blank"
|
||||
icon="discord"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<div class="select-text flex flex-col flex-1 min-h-0">
|
||||
const wide = createMemo(() => diffs().length === 0)
|
||||
|
||||
return (
|
||||
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
|
||||
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
|
||||
<div class="">
|
||||
<a href="https://opencode.ai">
|
||||
<Mark />
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex gap-3 items-center">
|
||||
<IconButton
|
||||
as={"a"}
|
||||
href="https://github.com/anomalyco/opencode"
|
||||
target="_blank"
|
||||
icon="github"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
as={"a"}
|
||||
href="https://opencode.ai/discord"
|
||||
target="_blank"
|
||||
icon="discord"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<div class="select-text flex flex-col flex-1 min-h-0">
|
||||
<div
|
||||
classList={{
|
||||
"hidden w-full flex-1 min-h-0": true,
|
||||
"md:flex": wide(),
|
||||
"lg:flex": !wide(),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"hidden w-full flex-1 min-h-0": true,
|
||||
"md:flex": wide(),
|
||||
"lg:flex": !wide(),
|
||||
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
|
||||
"w-full flex justify-start items-start min-w-0 px-6": true,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"w-full flex justify-start items-start min-w-0 px-6": true,
|
||||
{title()}
|
||||
</div>
|
||||
<div class="flex items-start justify-start h-full min-h-0">
|
||||
<Show when={messages().length > 1}>
|
||||
<MessageNav
|
||||
class="sticky top-0 shrink-0 py-2 pl-4"
|
||||
messages={messages()}
|
||||
current={activeMessage()}
|
||||
size="compact"
|
||||
onMessageSelect={setActiveMessage}
|
||||
/>
|
||||
</Show>
|
||||
<SessionTurn
|
||||
sessionID={data().sessionID}
|
||||
messageID={store.messageId ?? firstUserMessage()!.id!}
|
||||
classes={{
|
||||
root: "grow",
|
||||
content: "flex flex-col justify-between",
|
||||
container: "w-full pb-20 px-6",
|
||||
}}
|
||||
>
|
||||
{title()}
|
||||
</div>
|
||||
<div class="flex items-start justify-start h-full min-h-0">
|
||||
<Show when={messages().length > 1}>
|
||||
<MessageNav
|
||||
class="sticky top-0 shrink-0 py-2 pl-4"
|
||||
messages={messages()}
|
||||
current={activeMessage()}
|
||||
size="compact"
|
||||
onMessageSelect={setActiveMessage}
|
||||
/>
|
||||
</Show>
|
||||
<SessionTurn
|
||||
sessionID={data().sessionID}
|
||||
messageID={store.messageId ?? firstUserMessage()!.id!}
|
||||
classes={{
|
||||
root: "grow",
|
||||
content: "flex flex-col justify-between",
|
||||
container: "w-full pb-20 px-6",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
classList={{ "w-full flex items-center justify-center pb-8 shrink-0": true }}
|
||||
>
|
||||
<Logo class="w-58.5 opacity-12" />
|
||||
</div>
|
||||
</SessionTurn>
|
||||
</div>
|
||||
<div classList={{ "w-full flex items-center justify-center pb-8 shrink-0": true }}>
|
||||
<Logo class="w-58.5 opacity-12" />
|
||||
</div>
|
||||
</SessionTurn>
|
||||
</div>
|
||||
<Show when={diffs().length > 0}>
|
||||
<DiffComponentProvider component={SSRDiff}>
|
||||
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
|
||||
</div>
|
||||
<Show when={diffs().length > 0}>
|
||||
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
|
||||
<SessionReview
|
||||
class="@4xl:hidden"
|
||||
diffs={diffs()}
|
||||
classes={{
|
||||
root: "pb-20",
|
||||
header: "px-6",
|
||||
container: "px-6",
|
||||
}}
|
||||
/>
|
||||
<SessionReview
|
||||
split
|
||||
class="hidden @4xl:flex"
|
||||
diffs={splitDiffs()}
|
||||
classes={{
|
||||
root: "pb-20",
|
||||
header: "px-6",
|
||||
container: "px-6",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Switch>
|
||||
<Match when={diffs().length > 0}>
|
||||
<Tabs classList={{ "md:hidden": wide(), "lg:hidden": !wide() }}>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
|
||||
Session
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="review"
|
||||
class="w-1/2 !border-r-0"
|
||||
classes={{ button: "w-full" }}
|
||||
>
|
||||
{diffs().length} Files Changed
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="session" class="!overflow-hidden">
|
||||
{turns()}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
forceMount
|
||||
value="review"
|
||||
class="!overflow-hidden hidden data-[selected]:block"
|
||||
>
|
||||
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
|
||||
<SessionReview
|
||||
class="@4xl:hidden"
|
||||
diffs={diffs()}
|
||||
classes={{
|
||||
root: "pb-20",
|
||||
header: "px-6",
|
||||
container: "px-6",
|
||||
}}
|
||||
/>
|
||||
<SessionReview
|
||||
split
|
||||
class="hidden @4xl:flex"
|
||||
diffs={splitDiffs()}
|
||||
classes={{
|
||||
root: "pb-20",
|
||||
header: "px-6",
|
||||
container: "px-6",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DiffComponentProvider>
|
||||
</Show>
|
||||
</div>
|
||||
<Switch>
|
||||
<Match when={diffs().length > 0}>
|
||||
<Tabs classList={{ "md:hidden": wide(), "lg:hidden": !wide() }}>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
|
||||
Session
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="review"
|
||||
class="w-1/2 !border-r-0"
|
||||
classes={{ button: "w-full" }}
|
||||
>
|
||||
{diffs().length} Files Changed
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="session" class="!overflow-hidden">
|
||||
{turns()}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
forceMount
|
||||
value="review"
|
||||
class="!overflow-hidden hidden data-[selected]:block"
|
||||
>
|
||||
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
|
||||
<DiffComponentProvider component={SSRDiff}>
|
||||
<SessionReview
|
||||
diffs={diffs()}
|
||||
classes={{
|
||||
root: "pb-20",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
}}
|
||||
/>
|
||||
</DiffComponentProvider>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div
|
||||
classList={{ "!overflow-hidden": true, "md:hidden": wide(), "lg:hidden": !wide() }}
|
||||
>
|
||||
{turns()}
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div
|
||||
classList={{ "!overflow-hidden": true, "md:hidden": wide(), "lg:hidden": !wide() }}
|
||||
>
|
||||
{turns()}
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</DataProvider>
|
||||
</CodeComponentProvider>
|
||||
</DiffComponentProvider>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</DataProvider>
|
||||
</FileComponentProvider>
|
||||
</ClientOnlyWorkerPoolProvider>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
[data-component="code"] {
|
||||
content-visibility: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,317 +0,0 @@
|
||||
import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
|
||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
|
||||
import { Dynamic, isServer } from "solid-js/web"
|
||||
import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre"
|
||||
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
||||
import { useWorkerPool } from "../context/worker-pool"
|
||||
|
||||
export type SSRDiffProps<T = {}> = DiffProps<T> & {
|
||||
preloadedDiff: PreloadMultiFileDiffResult<T>
|
||||
}
|
||||
|
||||
export function Diff<T>(props: SSRDiffProps<T>) {
|
||||
let container!: HTMLDivElement
|
||||
let fileDiffRef!: HTMLElement
|
||||
const [local, others] = splitProps(props, [
|
||||
"before",
|
||||
"after",
|
||||
"class",
|
||||
"classList",
|
||||
"annotations",
|
||||
"selectedLines",
|
||||
"commentedLines",
|
||||
])
|
||||
const workerPool = useWorkerPool(props.diffStyle)
|
||||
|
||||
let fileDiffInstance: FileDiff<T> | undefined
|
||||
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
||||
const cleanupFunctions: Array<() => void> = []
|
||||
|
||||
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
|
||||
|
||||
const getVirtualizer = () => {
|
||||
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
|
||||
|
||||
const result = acquireVirtualizer(container)
|
||||
if (!result) return
|
||||
|
||||
sharedVirtualizer = result
|
||||
return result.virtualizer
|
||||
}
|
||||
|
||||
const applyScheme = () => {
|
||||
const scheme = document.documentElement.dataset.colorScheme
|
||||
if (scheme === "dark" || scheme === "light") {
|
||||
fileDiffRef.dataset.colorScheme = scheme
|
||||
return
|
||||
}
|
||||
|
||||
fileDiffRef.removeAttribute("data-color-scheme")
|
||||
}
|
||||
|
||||
const lineIndex = (split: boolean, element: HTMLElement) => {
|
||||
const raw = element.dataset.lineIndex
|
||||
if (!raw) return
|
||||
const values = raw
|
||||
.split(",")
|
||||
.map((value) => parseInt(value, 10))
|
||||
.filter((value) => !Number.isNaN(value))
|
||||
if (values.length === 0) return
|
||||
if (!split) return values[0]
|
||||
if (values.length === 2) return values[1]
|
||||
return values[0]
|
||||
}
|
||||
|
||||
const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: "additions" | "deletions" | undefined) => {
|
||||
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (nodes.length === 0) return
|
||||
|
||||
const targetSide = side ?? "additions"
|
||||
|
||||
for (const node of nodes) {
|
||||
if (findSide(node) === targetSide) return lineIndex(split, node)
|
||||
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
|
||||
}
|
||||
}
|
||||
|
||||
const fixSelection = (range: SelectedLineRange | null) => {
|
||||
if (!range) return range
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
const diffs = root.querySelector("[data-diff]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.diffType === "split"
|
||||
|
||||
const start = rowIndex(root, split, range.start, range.side)
|
||||
const end = rowIndex(root, split, range.end, range.endSide ?? range.side)
|
||||
|
||||
if (start === undefined || end === undefined) {
|
||||
if (root.querySelector("[data-line], [data-alt-line]") == null) return
|
||||
return null
|
||||
}
|
||||
if (start <= end) return range
|
||||
|
||||
const side = range.endSide ?? range.side
|
||||
const swapped: SelectedLineRange = {
|
||||
start: range.end,
|
||||
end: range.start,
|
||||
}
|
||||
if (side) swapped.side = side
|
||||
if (range.endSide && range.side) swapped.endSide = range.side
|
||||
|
||||
return swapped
|
||||
}
|
||||
|
||||
const setSelectedLines = (range: SelectedLineRange | null, attempt = 0) => {
|
||||
const diff = fileDiffInstance
|
||||
if (!diff) return
|
||||
|
||||
const fixed = fixSelection(range)
|
||||
if (fixed === undefined) {
|
||||
if (attempt >= 120) return
|
||||
requestAnimationFrame(() => setSelectedLines(range, attempt + 1))
|
||||
return
|
||||
}
|
||||
|
||||
diff.setSelectedLines(fixed)
|
||||
}
|
||||
|
||||
const findSide = (element: HTMLElement): "additions" | "deletions" => {
|
||||
const line = element.closest("[data-line], [data-alt-line]")
|
||||
if (line instanceof HTMLElement) {
|
||||
const type = line.dataset.lineType
|
||||
if (type === "change-deletion") return "deletions"
|
||||
if (type === "change-addition" || type === "change-additions") return "additions"
|
||||
}
|
||||
|
||||
const code = element.closest("[data-code]")
|
||||
if (!(code instanceof HTMLElement)) return "additions"
|
||||
return code.hasAttribute("data-deletions") ? "deletions" : "additions"
|
||||
}
|
||||
|
||||
const applyCommentedLines = (ranges: SelectedLineRange[]) => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
|
||||
for (const node of existing) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
node.removeAttribute("data-comment-selected")
|
||||
}
|
||||
|
||||
const diffs = root.querySelector("[data-diff]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.diffType === "split"
|
||||
|
||||
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (rows.length === 0) return
|
||||
|
||||
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
const lineIndex = (element: HTMLElement) => {
|
||||
const raw = element.dataset.lineIndex
|
||||
if (!raw) return
|
||||
const values = raw
|
||||
.split(",")
|
||||
.map((value) => parseInt(value, 10))
|
||||
.filter((value) => !Number.isNaN(value))
|
||||
if (values.length === 0) return
|
||||
if (!split) return values[0]
|
||||
if (values.length === 2) return values[1]
|
||||
return values[0]
|
||||
}
|
||||
|
||||
const rowIndex = (line: number, side: "additions" | "deletions" | undefined) => {
|
||||
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (nodes.length === 0) return
|
||||
|
||||
const targetSide = side ?? "additions"
|
||||
|
||||
for (const node of nodes) {
|
||||
if (findSide(node) === targetSide) return lineIndex(node)
|
||||
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(node)
|
||||
}
|
||||
}
|
||||
|
||||
for (const range of ranges) {
|
||||
const start = rowIndex(range.start, range.side)
|
||||
if (start === undefined) continue
|
||||
|
||||
const end = (() => {
|
||||
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
|
||||
if (same) return start
|
||||
return rowIndex(range.end, range.endSide ?? range.side)
|
||||
})()
|
||||
if (end === undefined) continue
|
||||
|
||||
const first = Math.min(start, end)
|
||||
const last = Math.max(start, end)
|
||||
|
||||
for (const row of rows) {
|
||||
const idx = lineIndex(row)
|
||||
if (idx === undefined) continue
|
||||
if (idx < first || idx > last) continue
|
||||
row.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
|
||||
if (Number.isNaN(idx)) continue
|
||||
if (idx < first || idx > last) continue
|
||||
annotation.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (isServer || !props.preloadedDiff) return
|
||||
|
||||
applyScheme()
|
||||
|
||||
if (typeof MutationObserver !== "undefined") {
|
||||
const root = document.documentElement
|
||||
const monitor = new MutationObserver(() => applyScheme())
|
||||
monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
|
||||
onCleanup(() => monitor.disconnect())
|
||||
}
|
||||
|
||||
const virtualizer = getVirtualizer()
|
||||
|
||||
fileDiffInstance = virtualizer
|
||||
? new VirtualizedFileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...props.preloadedDiff,
|
||||
},
|
||||
virtualizer,
|
||||
virtualMetrics,
|
||||
workerPool,
|
||||
)
|
||||
: new FileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...props.preloadedDiff,
|
||||
},
|
||||
workerPool,
|
||||
)
|
||||
// @ts-expect-error - fileContainer is private but needed for SSR hydration
|
||||
fileDiffInstance.fileContainer = fileDiffRef
|
||||
fileDiffInstance.hydrate({
|
||||
oldFile: local.before,
|
||||
newFile: local.after,
|
||||
lineAnnotations: local.annotations,
|
||||
fileContainer: fileDiffRef,
|
||||
containerWrapper: container,
|
||||
})
|
||||
|
||||
setSelectedLines(local.selectedLines ?? null)
|
||||
|
||||
createEffect(() => {
|
||||
fileDiffInstance?.setLineAnnotations(local.annotations ?? [])
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
setSelectedLines(local.selectedLines ?? null)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const ranges = local.commentedLines ?? []
|
||||
requestAnimationFrame(() => applyCommentedLines(ranges))
|
||||
})
|
||||
|
||||
// Hydrate annotation slots with interactive SolidJS components
|
||||
// if (props.annotations.length > 0 && props.renderAnnotation != null) {
|
||||
// for (const annotation of props.annotations) {
|
||||
// const slotName = `annotation-${annotation.side}-${annotation.lineNumber}`;
|
||||
// const slotElement = fileDiffRef.querySelector(
|
||||
// `[slot="${slotName}"]`
|
||||
// ) as HTMLElement;
|
||||
//
|
||||
// if (slotElement != null) {
|
||||
// // Clear the static server-rendered content from the slot
|
||||
// slotElement.innerHTML = '';
|
||||
//
|
||||
// // Mount a fresh SolidJS component into this slot using render().
|
||||
// // This enables full SolidJS reactivity (signals, effects, etc.)
|
||||
// const dispose = render(
|
||||
// () => props.renderAnnotation!(annotation),
|
||||
// slotElement
|
||||
// );
|
||||
// cleanupFunctions.push(dispose);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
// Clean up FileDiff event handlers and dispose SolidJS components
|
||||
fileDiffInstance?.cleanUp()
|
||||
cleanupFunctions.forEach((dispose) => dispose())
|
||||
sharedVirtualizer?.release()
|
||||
sharedVirtualizer = undefined
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-component="diff" style={styleVariables} ref={container}>
|
||||
<Dynamic component={DIFFS_TAG_NAME} ref={fileDiffRef} id="ssr-diff">
|
||||
<Show when={isServer}>
|
||||
<template shadowrootmode="open" innerHTML={props.preloadedDiff.prerenderedHTML} />
|
||||
</Show>
|
||||
</Dynamic>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,652 +0,0 @@
|
||||
import { sampledChecksum } from "@opencode-ai/util/encode"
|
||||
import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
|
||||
import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
|
||||
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
||||
import { getWorkerPool } from "../pierre/worker"
|
||||
|
||||
type SelectionSide = "additions" | "deletions"
|
||||
|
||||
function findElement(node: Node | null): HTMLElement | undefined {
|
||||
if (!node) return
|
||||
if (node instanceof HTMLElement) return node
|
||||
return node.parentElement ?? undefined
|
||||
}
|
||||
|
||||
function findLineNumber(node: Node | null): number | undefined {
|
||||
const element = findElement(node)
|
||||
if (!element) return
|
||||
|
||||
const line = element.closest("[data-line], [data-alt-line]")
|
||||
if (!(line instanceof HTMLElement)) return
|
||||
|
||||
const value = (() => {
|
||||
const primary = parseInt(line.dataset.line ?? "", 10)
|
||||
if (!Number.isNaN(primary)) return primary
|
||||
|
||||
const alt = parseInt(line.dataset.altLine ?? "", 10)
|
||||
if (!Number.isNaN(alt)) return alt
|
||||
})()
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function findSide(node: Node | null): SelectionSide | undefined {
|
||||
const element = findElement(node)
|
||||
if (!element) return
|
||||
|
||||
const line = element.closest("[data-line], [data-alt-line]")
|
||||
if (line instanceof HTMLElement) {
|
||||
const type = line.dataset.lineType
|
||||
if (type === "change-deletion") return "deletions"
|
||||
if (type === "change-addition" || type === "change-additions") return "additions"
|
||||
}
|
||||
|
||||
const code = element.closest("[data-code]")
|
||||
if (!(code instanceof HTMLElement)) return
|
||||
|
||||
if (code.hasAttribute("data-deletions")) return "deletions"
|
||||
return "additions"
|
||||
}
|
||||
|
||||
export function Diff<T>(props: DiffProps<T>) {
|
||||
let container!: HTMLDivElement
|
||||
let observer: MutationObserver | undefined
|
||||
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
||||
let renderToken = 0
|
||||
let selectionFrame: number | undefined
|
||||
let dragFrame: number | undefined
|
||||
let dragStart: number | undefined
|
||||
let dragEnd: number | undefined
|
||||
let dragSide: SelectionSide | undefined
|
||||
let dragEndSide: SelectionSide | undefined
|
||||
let dragMoved = false
|
||||
let lastSelection: SelectedLineRange | null = null
|
||||
let pendingSelectionEnd = false
|
||||
|
||||
const [local, others] = splitProps(props, [
|
||||
"before",
|
||||
"after",
|
||||
"class",
|
||||
"classList",
|
||||
"annotations",
|
||||
"selectedLines",
|
||||
"commentedLines",
|
||||
"onRendered",
|
||||
])
|
||||
|
||||
const mobile = createMediaQuery("(max-width: 640px)")
|
||||
|
||||
const large = createMemo(() => {
|
||||
const before = typeof local.before?.contents === "string" ? local.before.contents : ""
|
||||
const after = typeof local.after?.contents === "string" ? local.after.contents : ""
|
||||
return Math.max(before.length, after.length) > 500_000
|
||||
})
|
||||
|
||||
const largeOptions = {
|
||||
lineDiffType: "none",
|
||||
maxLineDiffLength: 0,
|
||||
tokenizeMaxLineLength: 1,
|
||||
} satisfies Pick<FileDiffOptions<T>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
|
||||
|
||||
const options = createMemo<FileDiffOptions<T>>(() => {
|
||||
const base = {
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
}
|
||||
|
||||
const perf = large() ? { ...base, ...largeOptions } : base
|
||||
if (!mobile()) return perf
|
||||
|
||||
return {
|
||||
...perf,
|
||||
disableLineNumbers: true,
|
||||
}
|
||||
})
|
||||
|
||||
let instance: FileDiff<T> | undefined
|
||||
const [current, setCurrent] = createSignal<FileDiff<T> | undefined>(undefined)
|
||||
const [rendered, setRendered] = createSignal(0)
|
||||
|
||||
const getVirtualizer = () => {
|
||||
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
|
||||
|
||||
const result = acquireVirtualizer(container)
|
||||
if (!result) return
|
||||
|
||||
sharedVirtualizer = result
|
||||
return result.virtualizer
|
||||
}
|
||||
|
||||
const getRoot = () => {
|
||||
const host = container.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
|
||||
const root = host.shadowRoot
|
||||
if (!root) return
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
const applyScheme = () => {
|
||||
const host = container.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
|
||||
const scheme = document.documentElement.dataset.colorScheme
|
||||
if (scheme === "dark" || scheme === "light") {
|
||||
host.dataset.colorScheme = scheme
|
||||
return
|
||||
}
|
||||
|
||||
host.removeAttribute("data-color-scheme")
|
||||
}
|
||||
|
||||
const lineIndex = (split: boolean, element: HTMLElement) => {
|
||||
const raw = element.dataset.lineIndex
|
||||
if (!raw) return
|
||||
const values = raw
|
||||
.split(",")
|
||||
.map((value) => parseInt(value, 10))
|
||||
.filter((value) => !Number.isNaN(value))
|
||||
if (values.length === 0) return
|
||||
if (!split) return values[0]
|
||||
if (values.length === 2) return values[1]
|
||||
return values[0]
|
||||
}
|
||||
|
||||
const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: SelectionSide | undefined) => {
|
||||
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (nodes.length === 0) return
|
||||
|
||||
const targetSide = side ?? "additions"
|
||||
|
||||
for (const node of nodes) {
|
||||
if (findSide(node) === targetSide) return lineIndex(split, node)
|
||||
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
|
||||
}
|
||||
}
|
||||
|
||||
const fixSelection = (range: SelectedLineRange | null) => {
|
||||
if (!range) return range
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
const diffs = root.querySelector("[data-diff]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.diffType === "split"
|
||||
|
||||
const start = rowIndex(root, split, range.start, range.side)
|
||||
const end = rowIndex(root, split, range.end, range.endSide ?? range.side)
|
||||
if (start === undefined || end === undefined) {
|
||||
if (root.querySelector("[data-line], [data-alt-line]") == null) return
|
||||
return null
|
||||
}
|
||||
if (start <= end) return range
|
||||
|
||||
const side = range.endSide ?? range.side
|
||||
const swapped: SelectedLineRange = {
|
||||
start: range.end,
|
||||
end: range.start,
|
||||
}
|
||||
|
||||
if (side) swapped.side = side
|
||||
if (range.endSide && range.side) swapped.endSide = range.side
|
||||
|
||||
return swapped
|
||||
}
|
||||
|
||||
const notifyRendered = () => {
|
||||
observer?.disconnect()
|
||||
observer = undefined
|
||||
renderToken++
|
||||
|
||||
const token = renderToken
|
||||
let settle = 0
|
||||
|
||||
const isReady = (root: ShadowRoot) => root.querySelector("[data-line]") != null
|
||||
|
||||
const notify = () => {
|
||||
if (token !== renderToken) return
|
||||
|
||||
observer?.disconnect()
|
||||
observer = undefined
|
||||
requestAnimationFrame(() => {
|
||||
if (token !== renderToken) return
|
||||
setSelectedLines(lastSelection)
|
||||
local.onRendered?.()
|
||||
})
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
settle++
|
||||
const current = settle
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (token !== renderToken) return
|
||||
if (current !== settle) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (token !== renderToken) return
|
||||
if (current !== settle) return
|
||||
|
||||
notify()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const observeRoot = (root: ShadowRoot) => {
|
||||
observer?.disconnect()
|
||||
observer = new MutationObserver(() => {
|
||||
if (token !== renderToken) return
|
||||
if (!isReady(root)) return
|
||||
|
||||
schedule()
|
||||
})
|
||||
|
||||
observer.observe(root, { childList: true, subtree: true })
|
||||
|
||||
if (!isReady(root)) return
|
||||
schedule()
|
||||
}
|
||||
|
||||
const root = getRoot()
|
||||
if (typeof MutationObserver === "undefined") {
|
||||
if (!root || !isReady(root)) return
|
||||
setSelectedLines(lastSelection)
|
||||
local.onRendered?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (root) {
|
||||
observeRoot(root)
|
||||
return
|
||||
}
|
||||
|
||||
observer = new MutationObserver(() => {
|
||||
if (token !== renderToken) return
|
||||
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
observeRoot(root)
|
||||
})
|
||||
|
||||
observer.observe(container, { childList: true, subtree: true })
|
||||
}
|
||||
|
||||
const applyCommentedLines = (ranges: SelectedLineRange[]) => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
|
||||
for (const node of existing) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
node.removeAttribute("data-comment-selected")
|
||||
}
|
||||
|
||||
const diffs = root.querySelector("[data-diff]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.diffType === "split"
|
||||
|
||||
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (rows.length === 0) return
|
||||
|
||||
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
for (const range of ranges) {
|
||||
const start = rowIndex(root, split, range.start, range.side)
|
||||
if (start === undefined) continue
|
||||
|
||||
const end = (() => {
|
||||
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
|
||||
if (same) return start
|
||||
return rowIndex(root, split, range.end, range.endSide ?? range.side)
|
||||
})()
|
||||
if (end === undefined) continue
|
||||
|
||||
const first = Math.min(start, end)
|
||||
const last = Math.max(start, end)
|
||||
|
||||
for (const row of rows) {
|
||||
const idx = lineIndex(split, row)
|
||||
if (idx === undefined) continue
|
||||
if (idx < first || idx > last) continue
|
||||
row.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
|
||||
if (Number.isNaN(idx)) continue
|
||||
if (idx < first || idx > last) continue
|
||||
annotation.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setSelectedLines = (range: SelectedLineRange | null) => {
|
||||
const active = current()
|
||||
if (!active) return
|
||||
|
||||
const fixed = fixSelection(range)
|
||||
if (fixed === undefined) {
|
||||
lastSelection = range
|
||||
return
|
||||
}
|
||||
|
||||
lastSelection = fixed
|
||||
active.setSelectedLines(fixed)
|
||||
}
|
||||
|
||||
const updateSelection = () => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
const selection =
|
||||
(root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
|
||||
if (!selection || selection.isCollapsed) return
|
||||
|
||||
const domRange =
|
||||
(
|
||||
selection as unknown as {
|
||||
getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[]
|
||||
}
|
||||
).getComposedRanges?.({ shadowRoots: [root] })?.[0] ??
|
||||
(selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
|
||||
|
||||
const startNode = domRange?.startContainer ?? selection.anchorNode
|
||||
const endNode = domRange?.endContainer ?? selection.focusNode
|
||||
if (!startNode || !endNode) return
|
||||
|
||||
if (!root.contains(startNode) || !root.contains(endNode)) return
|
||||
|
||||
const start = findLineNumber(startNode)
|
||||
const end = findLineNumber(endNode)
|
||||
if (start === undefined || end === undefined) return
|
||||
|
||||
const startSide = findSide(startNode)
|
||||
const endSide = findSide(endNode)
|
||||
const side = startSide ?? endSide
|
||||
|
||||
const selected: SelectedLineRange = {
|
||||
start,
|
||||
end,
|
||||
}
|
||||
|
||||
if (side) selected.side = side
|
||||
if (endSide && side && endSide !== side) selected.endSide = endSide
|
||||
|
||||
setSelectedLines(selected)
|
||||
}
|
||||
|
||||
const scheduleSelectionUpdate = () => {
|
||||
if (selectionFrame !== undefined) return
|
||||
|
||||
selectionFrame = requestAnimationFrame(() => {
|
||||
selectionFrame = undefined
|
||||
updateSelection()
|
||||
|
||||
if (!pendingSelectionEnd) return
|
||||
pendingSelectionEnd = false
|
||||
props.onLineSelectionEnd?.(lastSelection)
|
||||
})
|
||||
}
|
||||
|
||||
const updateDragSelection = () => {
|
||||
if (dragStart === undefined || dragEnd === undefined) return
|
||||
|
||||
const selected: SelectedLineRange = {
|
||||
start: dragStart,
|
||||
end: dragEnd,
|
||||
}
|
||||
|
||||
if (dragSide) selected.side = dragSide
|
||||
if (dragEndSide && dragSide && dragEndSide !== dragSide) selected.endSide = dragEndSide
|
||||
|
||||
setSelectedLines(selected)
|
||||
}
|
||||
|
||||
const scheduleDragUpdate = () => {
|
||||
if (dragFrame !== undefined) return
|
||||
|
||||
dragFrame = requestAnimationFrame(() => {
|
||||
dragFrame = undefined
|
||||
updateDragSelection()
|
||||
})
|
||||
}
|
||||
|
||||
const lineFromMouseEvent = (event: MouseEvent) => {
|
||||
const path = event.composedPath()
|
||||
|
||||
let numberColumn = false
|
||||
let line: number | undefined
|
||||
let side: SelectionSide | undefined
|
||||
|
||||
for (const item of path) {
|
||||
if (!(item instanceof HTMLElement)) continue
|
||||
|
||||
numberColumn = numberColumn || item.dataset.columnNumber != null
|
||||
|
||||
if (side === undefined) {
|
||||
const type = item.dataset.lineType
|
||||
if (type === "change-deletion") side = "deletions"
|
||||
if (type === "change-addition" || type === "change-additions") side = "additions"
|
||||
}
|
||||
|
||||
if (side === undefined && item.dataset.code != null) {
|
||||
side = item.hasAttribute("data-deletions") ? "deletions" : "additions"
|
||||
}
|
||||
|
||||
if (line === undefined) {
|
||||
const primary = item.dataset.line ? parseInt(item.dataset.line, 10) : Number.NaN
|
||||
if (!Number.isNaN(primary)) {
|
||||
line = primary
|
||||
} else {
|
||||
const alt = item.dataset.altLine ? parseInt(item.dataset.altLine, 10) : Number.NaN
|
||||
if (!Number.isNaN(alt)) line = alt
|
||||
}
|
||||
}
|
||||
|
||||
if (numberColumn && line !== undefined && side !== undefined) break
|
||||
}
|
||||
|
||||
return { line, numberColumn, side }
|
||||
}
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
if (props.enableLineSelection !== true) return
|
||||
if (event.button !== 0) return
|
||||
|
||||
const { line, numberColumn, side } = lineFromMouseEvent(event)
|
||||
if (numberColumn) return
|
||||
if (line === undefined) return
|
||||
|
||||
dragStart = line
|
||||
dragEnd = line
|
||||
dragSide = side
|
||||
dragEndSide = side
|
||||
dragMoved = false
|
||||
}
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (props.enableLineSelection !== true) return
|
||||
if (dragStart === undefined) return
|
||||
|
||||
if ((event.buttons & 1) === 0) {
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragSide = undefined
|
||||
dragEndSide = undefined
|
||||
dragMoved = false
|
||||
return
|
||||
}
|
||||
|
||||
const { line, side } = lineFromMouseEvent(event)
|
||||
if (line === undefined) return
|
||||
|
||||
dragEnd = line
|
||||
dragEndSide = side
|
||||
dragMoved = true
|
||||
scheduleDragUpdate()
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (props.enableLineSelection !== true) return
|
||||
if (dragStart === undefined) return
|
||||
|
||||
if (!dragMoved) {
|
||||
pendingSelectionEnd = false
|
||||
const line = dragStart
|
||||
const selected: SelectedLineRange = {
|
||||
start: line,
|
||||
end: line,
|
||||
}
|
||||
if (dragSide) selected.side = dragSide
|
||||
setSelectedLines(selected)
|
||||
props.onLineSelectionEnd?.(lastSelection)
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragSide = undefined
|
||||
dragEndSide = undefined
|
||||
dragMoved = false
|
||||
return
|
||||
}
|
||||
|
||||
pendingSelectionEnd = true
|
||||
scheduleDragUpdate()
|
||||
scheduleSelectionUpdate()
|
||||
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragSide = undefined
|
||||
dragEndSide = undefined
|
||||
dragMoved = false
|
||||
}
|
||||
|
||||
const handleSelectionChange = () => {
|
||||
if (props.enableLineSelection !== true) return
|
||||
if (dragStart === undefined) return
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.isCollapsed) return
|
||||
|
||||
scheduleSelectionUpdate()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const opts = options()
|
||||
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
|
||||
const virtualizer = getVirtualizer()
|
||||
const annotations = local.annotations
|
||||
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
|
||||
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
|
||||
|
||||
const cacheKey = (contents: string) => {
|
||||
if (!large()) return sampledChecksum(contents, contents.length)
|
||||
return sampledChecksum(contents)
|
||||
}
|
||||
|
||||
instance?.cleanUp()
|
||||
instance = virtualizer
|
||||
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
|
||||
: new FileDiff<T>(opts, workerPool)
|
||||
setCurrent(instance)
|
||||
|
||||
container.innerHTML = ""
|
||||
instance.render({
|
||||
oldFile: {
|
||||
...local.before,
|
||||
contents: beforeContents,
|
||||
cacheKey: cacheKey(beforeContents),
|
||||
},
|
||||
newFile: {
|
||||
...local.after,
|
||||
contents: afterContents,
|
||||
cacheKey: cacheKey(afterContents),
|
||||
},
|
||||
lineAnnotations: annotations,
|
||||
containerWrapper: container,
|
||||
})
|
||||
|
||||
applyScheme()
|
||||
|
||||
setRendered((value) => value + 1)
|
||||
notifyRendered()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
if (typeof MutationObserver === "undefined") return
|
||||
|
||||
const root = document.documentElement
|
||||
const monitor = new MutationObserver(() => applyScheme())
|
||||
monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
|
||||
applyScheme()
|
||||
|
||||
onCleanup(() => monitor.disconnect())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
rendered()
|
||||
const ranges = local.commentedLines ?? []
|
||||
requestAnimationFrame(() => applyCommentedLines(ranges))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const selected = local.selectedLines ?? null
|
||||
setSelectedLines(selected)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.enableLineSelection !== true) return
|
||||
|
||||
container.addEventListener("mousedown", handleMouseDown)
|
||||
container.addEventListener("mousemove", handleMouseMove)
|
||||
window.addEventListener("mouseup", handleMouseUp)
|
||||
document.addEventListener("selectionchange", handleSelectionChange)
|
||||
|
||||
onCleanup(() => {
|
||||
container.removeEventListener("mousedown", handleMouseDown)
|
||||
container.removeEventListener("mousemove", handleMouseMove)
|
||||
window.removeEventListener("mouseup", handleMouseUp)
|
||||
document.removeEventListener("selectionchange", handleSelectionChange)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
observer?.disconnect()
|
||||
|
||||
if (selectionFrame !== undefined) {
|
||||
cancelAnimationFrame(selectionFrame)
|
||||
selectionFrame = undefined
|
||||
}
|
||||
|
||||
if (dragFrame !== undefined) {
|
||||
cancelAnimationFrame(dragFrame)
|
||||
dragFrame = undefined
|
||||
}
|
||||
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragSide = undefined
|
||||
dragEndSide = undefined
|
||||
dragMoved = false
|
||||
lastSelection = null
|
||||
pendingSelectionEnd = false
|
||||
|
||||
instance?.cleanUp()
|
||||
setCurrent(undefined)
|
||||
sharedVirtualizer?.release()
|
||||
sharedVirtualizer = undefined
|
||||
})
|
||||
|
||||
return <div data-component="diff" style={styleVariables} ref={container} />
|
||||
}
|
||||
265
packages/ui/src/components/file-media.tsx
Normal file
265
packages/ui/src/components/file-media.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
import { createEffect, createMemo, createResource, Match, on, Show, Switch, type JSX } from "solid-js"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import {
|
||||
dataUrlFromMediaValue,
|
||||
hasMediaValue,
|
||||
isBinaryContent,
|
||||
mediaKindFromPath,
|
||||
normalizeMimeType,
|
||||
svgTextFromValue,
|
||||
} from "../pierre/media"
|
||||
|
||||
export type FileMediaOptions = {
|
||||
mode?: "auto" | "off"
|
||||
path?: string
|
||||
current?: unknown
|
||||
before?: unknown
|
||||
after?: unknown
|
||||
readFile?: (path: string) => Promise<FileContent | undefined>
|
||||
onLoad?: () => void
|
||||
onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void
|
||||
}
|
||||
|
||||
function mediaValue(cfg: FileMediaOptions, mode: "image" | "audio") {
|
||||
if (cfg.current !== undefined) return cfg.current
|
||||
if (mode === "image") return cfg.after ?? cfg.before
|
||||
return cfg.after ?? cfg.before
|
||||
}
|
||||
|
||||
export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX.Element }) {
|
||||
const i18n = useI18n()
|
||||
const cfg = () => props.media
|
||||
const kind = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || media.mode === "off") return
|
||||
return mediaKindFromPath(media.path)
|
||||
})
|
||||
|
||||
const isBinary = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || media.mode === "off") return false
|
||||
if (kind()) return false
|
||||
return isBinaryContent(media.current as any)
|
||||
})
|
||||
|
||||
const onLoad = () => props.media?.onLoad?.()
|
||||
|
||||
const deleted = createMemo(() => {
|
||||
const media = cfg()
|
||||
const k = kind()
|
||||
if (!media || !k) return false
|
||||
if (k === "svg") return false
|
||||
if (media.current !== undefined) return false
|
||||
return !hasMediaValue(media.after as any) && hasMediaValue(media.before as any)
|
||||
})
|
||||
|
||||
const direct = createMemo(() => {
|
||||
const media = cfg()
|
||||
const k = kind()
|
||||
if (!media || (k !== "image" && k !== "audio")) return
|
||||
return dataUrlFromMediaValue(mediaValue(media, k), k)
|
||||
})
|
||||
|
||||
const request = createMemo(() => {
|
||||
const media = cfg()
|
||||
const k = kind()
|
||||
if (!media || (k !== "image" && k !== "audio")) return
|
||||
if (media.current !== undefined) return
|
||||
if (deleted()) return
|
||||
if (direct()) return
|
||||
if (!media.path || !media.readFile) return
|
||||
|
||||
return {
|
||||
key: `${k}:${media.path}`,
|
||||
kind: k,
|
||||
path: media.path,
|
||||
readFile: media.readFile,
|
||||
onError: media.onError,
|
||||
}
|
||||
})
|
||||
|
||||
const [loaded] = createResource(request, async (input) => {
|
||||
return input.readFile(input.path).then(
|
||||
(result) => {
|
||||
const src = dataUrlFromMediaValue(result as any, input.kind)
|
||||
if (!src) {
|
||||
input.onError?.({ kind: input.kind })
|
||||
return { key: input.key, error: true as const }
|
||||
}
|
||||
|
||||
return {
|
||||
key: input.key,
|
||||
src,
|
||||
mime: input.kind === "audio" ? normalizeMimeType(result?.mimeType) : undefined,
|
||||
}
|
||||
},
|
||||
() => {
|
||||
input.onError?.({ kind: input.kind })
|
||||
return { key: input.key, error: true as const }
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const remote = createMemo(() => {
|
||||
const input = request()
|
||||
const value = loaded()
|
||||
if (!input || !value || value.key !== input.key) return
|
||||
return value
|
||||
})
|
||||
|
||||
const src = createMemo(() => {
|
||||
const value = remote()
|
||||
return direct() ?? (value && "src" in value ? value.src : undefined)
|
||||
})
|
||||
const status = createMemo(() => {
|
||||
if (direct()) return "ready" as const
|
||||
if (!request()) return "idle" as const
|
||||
if (loaded.loading) return "loading" as const
|
||||
if (remote()?.error) return "error" as const
|
||||
if (src()) return "ready" as const
|
||||
return "idle" as const
|
||||
})
|
||||
const audioMime = createMemo(() => {
|
||||
const value = remote()
|
||||
return value && "mime" in value ? value.mime : undefined
|
||||
})
|
||||
|
||||
const svgSource = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || kind() !== "svg") return
|
||||
return svgTextFromValue(media.current as any)
|
||||
})
|
||||
const svgSrc = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || kind() !== "svg") return
|
||||
return dataUrlFromMediaValue(media.current as any, "svg")
|
||||
})
|
||||
const svgInvalid = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || kind() !== "svg") return
|
||||
if (svgSource() !== undefined) return
|
||||
if (!hasMediaValue(media.current as any)) return
|
||||
return [media.path, media.current] as const
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
svgInvalid,
|
||||
(value) => {
|
||||
if (!value) return
|
||||
cfg()?.onError?.({ kind: "svg" })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const kindLabel = (value: "image" | "audio") =>
|
||||
i18n.t(value === "image" ? "ui.fileMedia.kind.image" : "ui.fileMedia.kind.audio")
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={kind() === "image" || kind() === "audio"}>
|
||||
<Show
|
||||
when={src()}
|
||||
fallback={(() => {
|
||||
const media = cfg()
|
||||
const k = kind()
|
||||
if (!media || (k !== "image" && k !== "audio")) return props.fallback()
|
||||
const label = kindLabel(k)
|
||||
|
||||
if (deleted()) {
|
||||
return (
|
||||
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
|
||||
{i18n.t("ui.fileMedia.state.removed", { kind: label })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status() === "loading") {
|
||||
return (
|
||||
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
|
||||
{i18n.t("ui.fileMedia.state.loading", { kind: label })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status() === "error") {
|
||||
return (
|
||||
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
|
||||
{i18n.t("ui.fileMedia.state.error", { kind: label })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
|
||||
{i18n.t("ui.fileMedia.state.unavailable", { kind: label })}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
>
|
||||
{(value) => {
|
||||
const k = kind()
|
||||
if (k !== "image" && k !== "audio") return props.fallback()
|
||||
if (k === "image") {
|
||||
return (
|
||||
<div class="flex justify-center bg-background-stronger px-6 py-4">
|
||||
<img
|
||||
src={value()}
|
||||
alt={cfg()?.path}
|
||||
class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex justify-center bg-background-stronger px-6 py-4">
|
||||
<audio class="w-full max-w-xl" controls preload="metadata" onLoadedMetadata={onLoad}>
|
||||
<source src={value()} type={audioMime()} />
|
||||
</audio>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={kind() === "svg"}>
|
||||
{(() => {
|
||||
if (svgSource() === undefined && svgSrc() == null) return props.fallback()
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-4 px-6 py-4">
|
||||
<Show when={svgSource() !== undefined}>{props.fallback()}</Show>
|
||||
<Show when={svgSrc()}>
|
||||
{(value) => (
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
src={value()}
|
||||
alt={cfg()?.path}
|
||||
class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Match>
|
||||
<Match when={isBinary()}>
|
||||
<div class="flex min-h-56 flex-col items-center justify-center gap-2 px-6 py-10 text-center">
|
||||
<div class="text-14-semibold text-text-strong">
|
||||
{cfg()?.path?.split("/").pop() ?? i18n.t("ui.fileMedia.binary.title")}
|
||||
</div>
|
||||
<div class="text-14-regular text-text-weak">
|
||||
{(() => {
|
||||
const path = cfg()?.path
|
||||
if (!path) return i18n.t("ui.fileMedia.binary.description.default")
|
||||
return i18n.t("ui.fileMedia.binary.description.path", { path })
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>{props.fallback()}</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
69
packages/ui/src/components/file-search.tsx
Normal file
69
packages/ui/src/components/file-search.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Portal } from "solid-js/web"
|
||||
import { Icon } from "./icon"
|
||||
|
||||
export function FileSearchBar(props: {
|
||||
pos: () => { top: number; right: number }
|
||||
query: () => string
|
||||
index: () => number
|
||||
count: () => number
|
||||
setInput: (el: HTMLInputElement) => void
|
||||
onInput: (value: string) => void
|
||||
onKeyDown: (event: KeyboardEvent) => void
|
||||
onClose: () => void
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
}) {
|
||||
return (
|
||||
<Portal>
|
||||
<div
|
||||
class="fixed z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md"
|
||||
style={{
|
||||
top: `${props.pos().top}px`,
|
||||
right: `${props.pos().right}px`,
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" />
|
||||
<input
|
||||
ref={props.setInput}
|
||||
placeholder="Find"
|
||||
value={props.query()}
|
||||
class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak"
|
||||
onInput={(e) => props.onInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => props.onKeyDown(e as KeyboardEvent)}
|
||||
/>
|
||||
<div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}>
|
||||
{props.count() ? `${props.index() + 1}/${props.count()}` : "0/0"}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
|
||||
disabled={props.count() === 0}
|
||||
aria-label="Previous match"
|
||||
onClick={props.onPrev}
|
||||
>
|
||||
<Icon name="chevron-down" size="small" class="rotate-180" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
|
||||
disabled={props.count() === 0}
|
||||
aria-label="Next match"
|
||||
onClick={props.onNext}
|
||||
>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
|
||||
aria-label="Close search"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<Icon name="close-small" size="small" />
|
||||
</button>
|
||||
</div>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
178
packages/ui/src/components/file-ssr.tsx
Normal file
178
packages/ui/src/components/file-ssr.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs"
|
||||
import { type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
|
||||
import { Dynamic, isServer } from "solid-js/web"
|
||||
import { useWorkerPool } from "../context/worker-pool"
|
||||
import { createDefaultOptions, styleVariables } from "../pierre"
|
||||
import { markCommentedDiffLines } from "../pierre/commented-lines"
|
||||
import { fixDiffSelection } from "../pierre/diff-selection"
|
||||
import {
|
||||
applyViewerScheme,
|
||||
clearReadyWatcher,
|
||||
createReadyWatcher,
|
||||
notifyShadowReady,
|
||||
observeViewerScheme,
|
||||
} from "../pierre/file-runtime"
|
||||
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
||||
import { File, type DiffFileProps, type FileProps } from "./file"
|
||||
|
||||
type SSRDiffFileProps<T> = DiffFileProps<T> & {
|
||||
preloadedDiff: PreloadMultiFileDiffResult<T>
|
||||
}
|
||||
|
||||
function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
let container!: HTMLDivElement
|
||||
let fileDiffRef!: HTMLElement
|
||||
let fileDiffInstance: FileDiff<T> | undefined
|
||||
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
||||
|
||||
const ready = createReadyWatcher()
|
||||
const workerPool = useWorkerPool(props.diffStyle)
|
||||
|
||||
const [local, others] = splitProps(props, [
|
||||
"mode",
|
||||
"media",
|
||||
"before",
|
||||
"after",
|
||||
"class",
|
||||
"classList",
|
||||
"annotations",
|
||||
"selectedLines",
|
||||
"commentedLines",
|
||||
"onLineSelected",
|
||||
"onLineSelectionEnd",
|
||||
"onLineNumberSelectionEnd",
|
||||
"onRendered",
|
||||
"preloadedDiff",
|
||||
])
|
||||
|
||||
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
|
||||
|
||||
const getVirtualizer = () => {
|
||||
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
|
||||
const result = acquireVirtualizer(container)
|
||||
if (!result) return
|
||||
sharedVirtualizer = result
|
||||
return result.virtualizer
|
||||
}
|
||||
|
||||
const setSelectedLines = (range: DiffFileProps<T>["selectedLines"], attempt = 0) => {
|
||||
const diff = fileDiffInstance
|
||||
if (!diff) return
|
||||
|
||||
const fixed = fixDiffSelection(getRoot(), range ?? null)
|
||||
if (fixed === undefined) {
|
||||
if (attempt >= 120) return
|
||||
requestAnimationFrame(() => setSelectedLines(range ?? null, attempt + 1))
|
||||
return
|
||||
}
|
||||
|
||||
diff.setSelectedLines(fixed)
|
||||
}
|
||||
|
||||
const notifyRendered = () => {
|
||||
notifyShadowReady({
|
||||
state: ready,
|
||||
container,
|
||||
getRoot,
|
||||
isReady: (root) => root.querySelector("[data-line]") != null,
|
||||
settleFrames: 1,
|
||||
onReady: () => {
|
||||
setSelectedLines(local.selectedLines ?? null)
|
||||
local.onRendered?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (isServer) return
|
||||
|
||||
onCleanup(observeViewerScheme(() => fileDiffRef))
|
||||
|
||||
const virtualizer = getVirtualizer()
|
||||
fileDiffInstance = virtualizer
|
||||
? new VirtualizedFileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...local.preloadedDiff,
|
||||
},
|
||||
virtualizer,
|
||||
virtualMetrics,
|
||||
workerPool,
|
||||
)
|
||||
: new FileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...local.preloadedDiff,
|
||||
},
|
||||
workerPool,
|
||||
)
|
||||
|
||||
applyViewerScheme(fileDiffRef)
|
||||
|
||||
// @ts-expect-error private field required for hydration
|
||||
fileDiffInstance.fileContainer = fileDiffRef
|
||||
fileDiffInstance.hydrate({
|
||||
oldFile: local.before,
|
||||
newFile: local.after,
|
||||
lineAnnotations: local.annotations ?? [],
|
||||
fileContainer: fileDiffRef,
|
||||
containerWrapper: container,
|
||||
})
|
||||
|
||||
notifyRendered()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const diff = fileDiffInstance
|
||||
if (!diff) return
|
||||
diff.setLineAnnotations(local.annotations ?? [])
|
||||
diff.rerender()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
setSelectedLines(local.selectedLines ?? null)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const ranges = local.commentedLines ?? []
|
||||
requestAnimationFrame(() => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
markCommentedDiffLines(root, ranges)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
clearReadyWatcher(ready)
|
||||
fileDiffInstance?.cleanUp()
|
||||
sharedVirtualizer?.release()
|
||||
sharedVirtualizer = undefined
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="file"
|
||||
data-mode="diff"
|
||||
style={styleVariables}
|
||||
class={local.class}
|
||||
classList={local.classList}
|
||||
ref={container}
|
||||
>
|
||||
<Dynamic component={DIFFS_TAG_NAME} ref={fileDiffRef} id="ssr-diff">
|
||||
<Show when={isServer}>
|
||||
<template shadowrootmode="open" innerHTML={local.preloadedDiff.prerenderedHTML} />
|
||||
</Show>
|
||||
</Dynamic>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type FileSSRProps<T = {}> = FileProps<T>
|
||||
|
||||
export function FileSSR<T>(props: FileSSRProps<T>) {
|
||||
if (props.mode !== "diff" || !props.preloadedDiff) return File(props)
|
||||
return DiffSSRViewer(props as SSRDiffFileProps<T>)
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
[data-component="diff"] {
|
||||
[data-component="file"] {
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
[data-component="file"][data-mode="text"] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="file"][data-mode="diff"] {
|
||||
[data-slot="diff-hunk-separator-line-number"] {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
@@ -17,6 +23,7 @@
|
||||
color: var(--icon-strong-base);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="diff-hunk-separator-content"] {
|
||||
position: sticky;
|
||||
background-color: var(--surface-diff-hidden-base);
|
||||
1176
packages/ui/src/components/file.tsx
Normal file
1176
packages/ui/src/components/file.tsx
Normal file
File diff suppressed because it is too large
Load Diff
586
packages/ui/src/components/line-comment-annotations.tsx
Normal file
586
packages/ui/src/components/line-comment-annotations.tsx
Normal file
@@ -0,0 +1,586 @@
|
||||
import { type DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { render as renderSolid } from "solid-js/web"
|
||||
import { createHoverCommentUtility } from "../pierre/comment-hover"
|
||||
import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge"
|
||||
import { LineComment, LineCommentEditor } from "./line-comment"
|
||||
|
||||
export type LineCommentAnnotationMeta<T> =
|
||||
| { kind: "comment"; key: string; comment: T }
|
||||
| { kind: "draft"; key: string; range: SelectedLineRange }
|
||||
|
||||
export type LineCommentAnnotation<T> = {
|
||||
lineNumber: number
|
||||
side?: "additions" | "deletions"
|
||||
metadata: LineCommentAnnotationMeta<T>
|
||||
}
|
||||
|
||||
type LineCommentAnnotationsProps<T> = {
|
||||
comments: Accessor<T[]>
|
||||
getCommentId: (comment: T) => string
|
||||
getCommentSelection: (comment: T) => SelectedLineRange
|
||||
draftRange: Accessor<SelectedLineRange | null>
|
||||
draftKey: Accessor<string>
|
||||
}
|
||||
|
||||
type LineCommentAnnotationsWithSideProps<T> = LineCommentAnnotationsProps<T> & {
|
||||
getSide: (range: SelectedLineRange) => "additions" | "deletions"
|
||||
}
|
||||
|
||||
type HoverCommentLine = {
|
||||
lineNumber: number
|
||||
side?: "additions" | "deletions"
|
||||
}
|
||||
|
||||
type LineCommentStateProps<T> = {
|
||||
opened: Accessor<T | null>
|
||||
setOpened: (id: T | null) => void
|
||||
selected: Accessor<SelectedLineRange | null>
|
||||
setSelected: (range: SelectedLineRange | null) => void
|
||||
commenting: Accessor<SelectedLineRange | null>
|
||||
setCommenting: (range: SelectedLineRange | null) => void
|
||||
syncSelected?: (range: SelectedLineRange | null) => void
|
||||
hoverSelected?: (range: SelectedLineRange) => void
|
||||
}
|
||||
|
||||
type LineCommentShape = {
|
||||
id: string
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
}
|
||||
|
||||
type LineCommentControllerProps<T extends LineCommentShape> = {
|
||||
comments: Accessor<T[]>
|
||||
draftKey: Accessor<string>
|
||||
label: string
|
||||
state: LineCommentStateProps<string>
|
||||
onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
|
||||
onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
|
||||
onDelete?: (comment: T) => void
|
||||
renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element
|
||||
editSubmitLabel?: string
|
||||
onDraftPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
|
||||
getHoverSelectedRange?: Accessor<SelectedLineRange | null>
|
||||
cancelDraftOnCommentToggle?: boolean
|
||||
clearSelectionOnSelectionEndNull?: boolean
|
||||
}
|
||||
|
||||
type LineCommentControllerWithSideProps<T extends LineCommentShape> = LineCommentControllerProps<T> & {
|
||||
getSide: (range: SelectedLineRange) => "additions" | "deletions"
|
||||
}
|
||||
|
||||
type CommentProps = {
|
||||
id?: string
|
||||
open: boolean
|
||||
comment: JSX.Element
|
||||
selection: JSX.Element
|
||||
actions?: JSX.Element
|
||||
editor?: DraftProps
|
||||
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
}
|
||||
|
||||
type DraftProps = {
|
||||
value: string
|
||||
selection: JSX.Element
|
||||
onInput: (value: string) => void
|
||||
onCancel: VoidFunction
|
||||
onSubmit: (value: string) => void
|
||||
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
|
||||
cancelLabel?: string
|
||||
submitLabel?: string
|
||||
}
|
||||
|
||||
export function createLineCommentAnnotationRenderer<T>(props: {
|
||||
renderComment: (comment: T) => CommentProps
|
||||
renderDraft: (range: SelectedLineRange) => DraftProps
|
||||
}) {
|
||||
const nodes = new Map<
|
||||
string,
|
||||
{
|
||||
host: HTMLDivElement
|
||||
dispose: VoidFunction
|
||||
setMeta: (meta: LineCommentAnnotationMeta<T>) => void
|
||||
}
|
||||
>()
|
||||
|
||||
const mount = (meta: LineCommentAnnotationMeta<T>) => {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const host = document.createElement("div")
|
||||
host.setAttribute("data-prevent-autofocus", "")
|
||||
const [current, setCurrent] = createSignal(meta)
|
||||
|
||||
const dispose = renderSolid(() => {
|
||||
const active = current()
|
||||
if (active.kind === "comment") {
|
||||
const view = createMemo(() => {
|
||||
const next = current()
|
||||
if (next.kind !== "comment") return props.renderComment(active.comment)
|
||||
return props.renderComment(next.comment)
|
||||
})
|
||||
return (
|
||||
<Show
|
||||
when={view().editor}
|
||||
fallback={
|
||||
<LineComment
|
||||
inline
|
||||
id={view().id}
|
||||
open={view().open}
|
||||
comment={view().comment}
|
||||
selection={view().selection}
|
||||
actions={view().actions}
|
||||
onClick={view().onClick}
|
||||
onMouseEnter={view().onMouseEnter}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LineCommentEditor
|
||||
inline
|
||||
id={view().id}
|
||||
value={view().editor!.value}
|
||||
selection={view().editor!.selection}
|
||||
onInput={view().editor!.onInput}
|
||||
onCancel={view().editor!.onCancel}
|
||||
onSubmit={view().editor!.onSubmit}
|
||||
onPopoverFocusOut={view().editor!.onPopoverFocusOut}
|
||||
cancelLabel={view().editor!.cancelLabel}
|
||||
submitLabel={view().editor!.submitLabel}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const view = createMemo(() => {
|
||||
const next = current()
|
||||
if (next.kind !== "draft") return props.renderDraft(active.range)
|
||||
return props.renderDraft(next.range)
|
||||
})
|
||||
return (
|
||||
<LineCommentEditor
|
||||
inline
|
||||
value={view().value}
|
||||
selection={view().selection}
|
||||
onInput={view().onInput}
|
||||
onCancel={view().onCancel}
|
||||
onSubmit={view().onSubmit}
|
||||
onPopoverFocusOut={view().onPopoverFocusOut}
|
||||
/>
|
||||
)
|
||||
}, host)
|
||||
|
||||
const node = { host, dispose, setMeta: setCurrent }
|
||||
nodes.set(meta.key, node)
|
||||
return node
|
||||
}
|
||||
|
||||
const render = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotation: A) => {
|
||||
const meta = annotation.metadata
|
||||
const node = nodes.get(meta.key) ?? mount(meta)
|
||||
if (!node) return
|
||||
node.setMeta(meta)
|
||||
return node.host
|
||||
}
|
||||
|
||||
const reconcile = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotations: A[]) => {
|
||||
const next = new Set(annotations.map((annotation) => annotation.metadata.key))
|
||||
for (const [key, node] of nodes) {
|
||||
if (next.has(key)) continue
|
||||
node.dispose()
|
||||
nodes.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
for (const [, node] of nodes) node.dispose()
|
||||
nodes.clear()
|
||||
}
|
||||
|
||||
return { render, reconcile, cleanup }
|
||||
}
|
||||
|
||||
export function createLineCommentState<T>(props: LineCommentStateProps<T>) {
|
||||
const [draft, setDraft] = createSignal("")
|
||||
const [editing, setEditing] = createSignal<T | null>(null)
|
||||
|
||||
const toRange = (range: SelectedLineRange | null) => (range ? cloneSelectedLineRange(range) : null)
|
||||
const setSelected = (range: SelectedLineRange | null) => {
|
||||
const next = toRange(range)
|
||||
props.setSelected(next)
|
||||
props.syncSelected?.(toRange(next))
|
||||
return next
|
||||
}
|
||||
|
||||
const setCommenting = (range: SelectedLineRange | null) => {
|
||||
const next = toRange(range)
|
||||
props.setCommenting(next)
|
||||
return next
|
||||
}
|
||||
|
||||
const closeComment = () => {
|
||||
props.setOpened(null)
|
||||
}
|
||||
|
||||
const cancelDraft = () => {
|
||||
setDraft("")
|
||||
setEditing(null)
|
||||
setCommenting(null)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setDraft("")
|
||||
setEditing(null)
|
||||
props.setOpened(null)
|
||||
props.setSelected(null)
|
||||
props.setCommenting(null)
|
||||
}
|
||||
|
||||
const openComment = (id: T, range: SelectedLineRange, options?: { cancelDraft?: boolean }) => {
|
||||
if (options?.cancelDraft) cancelDraft()
|
||||
props.setOpened(id)
|
||||
setSelected(range)
|
||||
}
|
||||
|
||||
const toggleComment = (id: T, range: SelectedLineRange, options?: { cancelDraft?: boolean }) => {
|
||||
if (options?.cancelDraft) cancelDraft()
|
||||
const next = props.opened() === id ? null : id
|
||||
props.setOpened(next)
|
||||
setSelected(range)
|
||||
}
|
||||
|
||||
const openDraft = (range: SelectedLineRange) => {
|
||||
const next = toRange(range)
|
||||
setDraft("")
|
||||
setEditing(null)
|
||||
closeComment()
|
||||
setSelected(next)
|
||||
setCommenting(next)
|
||||
}
|
||||
|
||||
const openEditor = (id: T, range: SelectedLineRange, value: string) => {
|
||||
closeComment()
|
||||
setSelected(range)
|
||||
props.setCommenting(null)
|
||||
setEditing(() => id)
|
||||
setDraft(value)
|
||||
}
|
||||
|
||||
const hoverComment = (range: SelectedLineRange) => {
|
||||
const next = toRange(range)
|
||||
if (!next) return
|
||||
if (props.hoverSelected) {
|
||||
props.hoverSelected(next)
|
||||
return
|
||||
}
|
||||
|
||||
setSelected(next)
|
||||
}
|
||||
|
||||
const finishSelection = (range: SelectedLineRange) => {
|
||||
closeComment()
|
||||
setSelected(range)
|
||||
cancelDraft()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
props.commenting()
|
||||
setDraft("")
|
||||
})
|
||||
|
||||
return {
|
||||
draft,
|
||||
setDraft,
|
||||
editing,
|
||||
opened: props.opened,
|
||||
selected: props.selected,
|
||||
commenting: props.commenting,
|
||||
isOpen: (id: T) => props.opened() === id,
|
||||
isEditing: (id: T) => editing() === id,
|
||||
closeComment,
|
||||
openComment,
|
||||
toggleComment,
|
||||
openDraft,
|
||||
openEditor,
|
||||
hoverComment,
|
||||
cancelDraft,
|
||||
finishSelection,
|
||||
select: setSelected,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
export function createLineCommentController<T extends LineCommentShape>(
|
||||
props: LineCommentControllerWithSideProps<T>,
|
||||
): {
|
||||
note: ReturnType<typeof createLineCommentState<string>>
|
||||
annotations: Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
|
||||
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
|
||||
renderHoverUtility: ReturnType<typeof createLineCommentHoverRenderer>
|
||||
onLineSelected: (range: SelectedLineRange | null) => void
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
}
|
||||
export function createLineCommentController<T extends LineCommentShape>(
|
||||
props: LineCommentControllerProps<T>,
|
||||
): {
|
||||
note: ReturnType<typeof createLineCommentState<string>>
|
||||
annotations: Accessor<LineCommentAnnotation<T>[]>
|
||||
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
|
||||
renderHoverUtility: ReturnType<typeof createLineCommentHoverRenderer>
|
||||
onLineSelected: (range: SelectedLineRange | null) => void
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
}
|
||||
export function createLineCommentController<T extends LineCommentShape>(
|
||||
props: LineCommentControllerProps<T> | LineCommentControllerWithSideProps<T>,
|
||||
) {
|
||||
const note = createLineCommentState<string>(props.state)
|
||||
|
||||
const annotations =
|
||||
"getSide" in props
|
||||
? createLineCommentAnnotations({
|
||||
comments: props.comments,
|
||||
getCommentId: (comment) => comment.id,
|
||||
getCommentSelection: (comment) => comment.selection,
|
||||
draftRange: note.commenting,
|
||||
draftKey: props.draftKey,
|
||||
getSide: props.getSide,
|
||||
})
|
||||
: createLineCommentAnnotations({
|
||||
comments: props.comments,
|
||||
getCommentId: (comment) => comment.id,
|
||||
getCommentSelection: (comment) => comment.selection,
|
||||
draftRange: note.commenting,
|
||||
draftKey: props.draftKey,
|
||||
})
|
||||
|
||||
const { renderAnnotation } = createManagedLineCommentAnnotationRenderer<T>({
|
||||
annotations,
|
||||
renderComment: (comment) => {
|
||||
const edit = () => note.openEditor(comment.id, comment.selection, comment.comment)
|
||||
const remove = () => {
|
||||
note.reset()
|
||||
props.onDelete?.(comment)
|
||||
}
|
||||
|
||||
return {
|
||||
id: comment.id,
|
||||
get open() {
|
||||
return note.isOpen(comment.id) || note.isEditing(comment.id)
|
||||
},
|
||||
comment: comment.comment,
|
||||
selection: formatSelectedLineLabel(comment.selection),
|
||||
get actions() {
|
||||
return props.renderCommentActions?.(comment, { edit, remove })
|
||||
},
|
||||
get editor() {
|
||||
return note.isEditing(comment.id)
|
||||
? {
|
||||
get value() {
|
||||
return note.draft()
|
||||
},
|
||||
selection: formatSelectedLineLabel(comment.selection),
|
||||
onInput: note.setDraft,
|
||||
onCancel: note.cancelDraft,
|
||||
onSubmit: (value: string) => {
|
||||
props.onUpdate?.({
|
||||
id: comment.id,
|
||||
comment: value,
|
||||
selection: cloneSelectedLineRange(comment.selection),
|
||||
})
|
||||
note.cancelDraft()
|
||||
},
|
||||
submitLabel: props.editSubmitLabel,
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
onMouseEnter: () => note.hoverComment(comment.selection),
|
||||
onClick: () => {
|
||||
if (note.isEditing(comment.id)) return
|
||||
note.toggleComment(comment.id, comment.selection, { cancelDraft: props.cancelDraftOnCommentToggle })
|
||||
},
|
||||
}
|
||||
},
|
||||
renderDraft: (range) => ({
|
||||
get value() {
|
||||
return note.draft()
|
||||
},
|
||||
selection: formatSelectedLineLabel(range),
|
||||
onInput: note.setDraft,
|
||||
onCancel: note.cancelDraft,
|
||||
onSubmit: (comment) => {
|
||||
props.onSubmit({ comment, selection: cloneSelectedLineRange(range) })
|
||||
note.cancelDraft()
|
||||
},
|
||||
onPopoverFocusOut: props.onDraftPopoverFocusOut,
|
||||
}),
|
||||
})
|
||||
|
||||
const renderHoverUtility = createLineCommentHoverRenderer({
|
||||
label: props.label,
|
||||
getSelectedRange: () => {
|
||||
if (note.opened()) return null
|
||||
return props.getHoverSelectedRange?.() ?? note.selected()
|
||||
},
|
||||
onOpenDraft: note.openDraft,
|
||||
})
|
||||
|
||||
const onLineSelected = (range: SelectedLineRange | null) => {
|
||||
if (!range) {
|
||||
note.select(null)
|
||||
note.cancelDraft()
|
||||
return
|
||||
}
|
||||
|
||||
note.select(range)
|
||||
}
|
||||
|
||||
const onLineSelectionEnd = (range: SelectedLineRange | null) => {
|
||||
if (!range) {
|
||||
if (props.clearSelectionOnSelectionEndNull) note.select(null)
|
||||
note.cancelDraft()
|
||||
return
|
||||
}
|
||||
|
||||
note.finishSelection(range)
|
||||
}
|
||||
|
||||
const onLineNumberSelectionEnd = (range: SelectedLineRange | null) => {
|
||||
if (!range) return
|
||||
note.openDraft(range)
|
||||
}
|
||||
|
||||
return {
|
||||
note,
|
||||
annotations,
|
||||
renderAnnotation,
|
||||
renderHoverUtility,
|
||||
onLineSelected,
|
||||
onLineSelectionEnd,
|
||||
onLineNumberSelectionEnd,
|
||||
}
|
||||
}
|
||||
|
||||
export function createLineCommentAnnotations<T>(
|
||||
props: LineCommentAnnotationsWithSideProps<T>,
|
||||
): Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
|
||||
export function createLineCommentAnnotations<T>(
|
||||
props: LineCommentAnnotationsProps<T>,
|
||||
): Accessor<LineCommentAnnotation<T>[]>
|
||||
export function createLineCommentAnnotations<T>(
|
||||
props: LineCommentAnnotationsProps<T> | LineCommentAnnotationsWithSideProps<T>,
|
||||
) {
|
||||
const line = (range: SelectedLineRange) => Math.max(range.start, range.end)
|
||||
|
||||
if ("getSide" in props) {
|
||||
return createMemo<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>(() => {
|
||||
const list = props.comments().map((comment) => {
|
||||
const range = props.getCommentSelection(comment)
|
||||
return {
|
||||
side: props.getSide(range),
|
||||
lineNumber: line(range),
|
||||
metadata: {
|
||||
kind: "comment",
|
||||
key: `comment:${props.getCommentId(comment)}`,
|
||||
comment,
|
||||
} satisfies LineCommentAnnotationMeta<T>,
|
||||
}
|
||||
})
|
||||
|
||||
const range = props.draftRange()
|
||||
if (!range) return list
|
||||
|
||||
return [
|
||||
...list,
|
||||
{
|
||||
side: props.getSide(range),
|
||||
lineNumber: line(range),
|
||||
metadata: {
|
||||
kind: "draft",
|
||||
key: `draft:${props.draftKey()}`,
|
||||
range,
|
||||
} satisfies LineCommentAnnotationMeta<T>,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return createMemo<LineCommentAnnotation<T>[]>(() => {
|
||||
const list = props.comments().map((comment) => {
|
||||
const range = props.getCommentSelection(comment)
|
||||
const entry: LineCommentAnnotation<T> = {
|
||||
lineNumber: line(range),
|
||||
metadata: {
|
||||
kind: "comment",
|
||||
key: `comment:${props.getCommentId(comment)}`,
|
||||
comment,
|
||||
},
|
||||
}
|
||||
|
||||
return entry
|
||||
})
|
||||
|
||||
const range = props.draftRange()
|
||||
if (!range) return list
|
||||
|
||||
const draft: LineCommentAnnotation<T> = {
|
||||
lineNumber: line(range),
|
||||
metadata: {
|
||||
kind: "draft",
|
||||
key: `draft:${props.draftKey()}`,
|
||||
range,
|
||||
},
|
||||
}
|
||||
|
||||
return [...list, draft]
|
||||
})
|
||||
}
|
||||
|
||||
export function createManagedLineCommentAnnotationRenderer<T>(props: {
|
||||
annotations: Accessor<LineCommentAnnotation<T>[]>
|
||||
renderComment: (comment: T) => CommentProps
|
||||
renderDraft: (range: SelectedLineRange) => DraftProps
|
||||
}) {
|
||||
const renderer = createLineCommentAnnotationRenderer<T>({
|
||||
renderComment: props.renderComment,
|
||||
renderDraft: props.renderDraft,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
renderer.reconcile(props.annotations())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
renderer.cleanup()
|
||||
})
|
||||
|
||||
return {
|
||||
renderAnnotation: renderer.render,
|
||||
}
|
||||
}
|
||||
|
||||
export function createLineCommentHoverRenderer(props: {
|
||||
label: string
|
||||
getSelectedRange: Accessor<SelectedLineRange | null>
|
||||
onOpenDraft: (range: SelectedLineRange) => void
|
||||
}) {
|
||||
return (getHoveredLine: () => HoverCommentLine | undefined) =>
|
||||
createHoverCommentUtility({
|
||||
label: props.label,
|
||||
getHoveredLine,
|
||||
onSelect: (hovered) => {
|
||||
const current = props.getSelectedRange()
|
||||
if (current && lineInSelectedRange(current, hovered.lineNumber, hovered.side)) {
|
||||
props.onOpenDraft(cloneSelectedLineRange(current))
|
||||
return
|
||||
}
|
||||
|
||||
const range: SelectedLineRange = {
|
||||
start: hovered.lineNumber,
|
||||
end: hovered.lineNumber,
|
||||
}
|
||||
if (hovered.side) range.side = hovered.side
|
||||
props.onOpenDraft(range)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,9 +1,23 @@
|
||||
export const lineCommentStyles = `
|
||||
[data-annotation-slot] {
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
z-index: var(--line-comment-z, 30);
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline] {
|
||||
position: relative;
|
||||
right: auto;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-open] {
|
||||
z-index: var(--line-comment-open-z, 100);
|
||||
}
|
||||
@@ -21,10 +35,20 @@
|
||||
border: none;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-variant="add"] [data-slot="line-comment-button"] {
|
||||
background: var(--syntax-diff-add);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-component="icon"] {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-icon"] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-button"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
@@ -39,27 +63,56 @@
|
||||
right: -8px;
|
||||
z-index: var(--line-comment-popover-z, 40);
|
||||
min-width: 200px;
|
||||
max-width: min(320px, calc(100vw - 48px));
|
||||
max-width: none;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-raised-stronger-non-alpha);
|
||||
box-shadow: var(--shadow-lg-border-base);
|
||||
box-shadow: var(--shadow-xxs-border);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"] {
|
||||
position: relative;
|
||||
top: auto;
|
||||
right: auto;
|
||||
margin-left: 8px;
|
||||
flex: 0 1 600px;
|
||||
width: min(100%, 600px);
|
||||
max-width: min(100%, 600px);
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"][data-inline-body] {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline][data-variant="default"] [data-slot="line-comment-popover"][data-inline-body] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-variant="editor"] [data-slot="line-comment-popover"] {
|
||||
width: 380px;
|
||||
max-width: min(380px, calc(100vw - 48px));
|
||||
max-width: none;
|
||||
padding: 8px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline][data-variant="editor"] [data-slot="line-comment-popover"] {
|
||||
flex-basis: 600px;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-head"] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-text"] {
|
||||
flex: 1;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-regular);
|
||||
@@ -69,6 +122,13 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-tools"] {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-label"],
|
||||
[data-component="line-comment"] [data-slot="line-comment-editor-label"] {
|
||||
font-family: var(--font-family-sans);
|
||||
@@ -108,8 +168,56 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-editor-label"] {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"] {
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--surface-base);
|
||||
color: var(--text-strong);
|
||||
border-radius: var(--radius-md);
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="ghost"] {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="primary"] {
|
||||
background: var(--text-strong);
|
||||
border-color: var(--text-strong);
|
||||
color: var(--background-base);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"]:disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
`
|
||||
|
||||
let installed = false
|
||||
|
||||
export function installLineCommentStyles() {
|
||||
if (installed) return
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const id = "opencode-line-comment-styles"
|
||||
if (document.getElementById(id)) {
|
||||
installed = true
|
||||
return
|
||||
}
|
||||
|
||||
const style = document.createElement("style")
|
||||
style.id = id
|
||||
style.textContent = lineCommentStyles
|
||||
document.head.appendChild(style)
|
||||
installed = true
|
||||
}
|
||||
@@ -1,52 +1,121 @@
|
||||
import { onMount, Show, splitProps, type JSX } from "solid-js"
|
||||
import { createEffect, createSignal, onMount, Show, splitProps, type JSX } from "solid-js"
|
||||
import { Button } from "./button"
|
||||
import { Icon } from "./icon"
|
||||
import { installLineCommentStyles } from "./line-comment-styles"
|
||||
import { useI18n } from "../context/i18n"
|
||||
|
||||
export type LineCommentVariant = "default" | "editor"
|
||||
installLineCommentStyles()
|
||||
|
||||
export type LineCommentVariant = "default" | "editor" | "add"
|
||||
|
||||
function InlineGlyph(props: { icon: "comment" | "plus" }) {
|
||||
return (
|
||||
<svg data-slot="line-comment-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<Show
|
||||
when={props.icon === "comment"}
|
||||
fallback={
|
||||
<path
|
||||
d="M10 5.41699V10.0003M10 10.0003V14.5837M10 10.0003H5.4165M10 10.0003H14.5832"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square" />
|
||||
</Show>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export type LineCommentAnchorProps = {
|
||||
id?: string
|
||||
top?: number
|
||||
inline?: boolean
|
||||
hideButton?: boolean
|
||||
open: boolean
|
||||
variant?: LineCommentVariant
|
||||
icon?: "comment" | "plus"
|
||||
buttonLabel?: string
|
||||
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
|
||||
class?: string
|
||||
popoverClass?: string
|
||||
children: JSX.Element
|
||||
children?: JSX.Element
|
||||
}
|
||||
|
||||
export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
|
||||
const hidden = () => props.top === undefined
|
||||
const hidden = () => !props.inline && props.top === undefined
|
||||
const variant = () => props.variant ?? "default"
|
||||
const icon = () => props.icon ?? "comment"
|
||||
const inlineBody = () => props.inline && props.hideButton
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="line-comment"
|
||||
data-prevent-autofocus=""
|
||||
data-variant={variant()}
|
||||
data-comment-id={props.id}
|
||||
data-open={props.open ? "" : undefined}
|
||||
data-inline={props.inline ? "" : undefined}
|
||||
classList={{
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
style={{
|
||||
top: `${props.top ?? 0}px`,
|
||||
opacity: hidden() ? 0 : 1,
|
||||
"pointer-events": hidden() ? "none" : "auto",
|
||||
}}
|
||||
style={
|
||||
props.inline
|
||||
? undefined
|
||||
: {
|
||||
top: `${props.top ?? 0}px`,
|
||||
opacity: hidden() ? 0 : 1,
|
||||
"pointer-events": hidden() ? "none" : "auto",
|
||||
}
|
||||
}
|
||||
>
|
||||
<button type="button" data-slot="line-comment-button" onClick={props.onClick} onMouseEnter={props.onMouseEnter}>
|
||||
<Icon name="comment" size="small" />
|
||||
</button>
|
||||
<Show when={props.open}>
|
||||
<Show
|
||||
when={inlineBody()}
|
||||
fallback={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={props.buttonLabel}
|
||||
data-slot="line-comment-button"
|
||||
on:mousedown={(e) => e.stopPropagation()}
|
||||
on:mouseup={(e) => e.stopPropagation()}
|
||||
on:click={props.onClick as any}
|
||||
on:mouseenter={props.onMouseEnter as any}
|
||||
>
|
||||
<Show
|
||||
when={props.inline}
|
||||
fallback={<Icon name={icon() === "plus" ? "plus-small" : "comment"} size="small" />}
|
||||
>
|
||||
<InlineGlyph icon={icon()} />
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
data-slot="line-comment-popover"
|
||||
classList={{
|
||||
[props.popoverClass ?? ""]: !!props.popoverClass,
|
||||
}}
|
||||
on:mousedown={(e) => e.stopPropagation()}
|
||||
on:focusout={props.onPopoverFocusOut as any}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-slot="line-comment-popover"
|
||||
data-inline-body=""
|
||||
classList={{
|
||||
[props.popoverClass ?? ""]: !!props.popoverClass,
|
||||
}}
|
||||
onFocusOut={props.onPopoverFocusOut}
|
||||
on:mousedown={(e) => e.stopPropagation()}
|
||||
on:click={props.onClick as any}
|
||||
on:mouseenter={props.onMouseEnter as any}
|
||||
on:focusout={props.onPopoverFocusOut as any}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
@@ -58,16 +127,22 @@ export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
|
||||
export type LineCommentProps = Omit<LineCommentAnchorProps, "children" | "variant"> & {
|
||||
comment: JSX.Element
|
||||
selection: JSX.Element
|
||||
actions?: JSX.Element
|
||||
}
|
||||
|
||||
export const LineComment = (props: LineCommentProps) => {
|
||||
const i18n = useI18n()
|
||||
const [split, rest] = splitProps(props, ["comment", "selection"])
|
||||
const [split, rest] = splitProps(props, ["comment", "selection", "actions"])
|
||||
|
||||
return (
|
||||
<LineCommentAnchor {...rest} variant="default">
|
||||
<LineCommentAnchor {...rest} variant="default" hideButton={props.inline}>
|
||||
<div data-slot="line-comment-content">
|
||||
<div data-slot="line-comment-text">{split.comment}</div>
|
||||
<div data-slot="line-comment-head">
|
||||
<div data-slot="line-comment-text">{split.comment}</div>
|
||||
<Show when={split.actions}>
|
||||
<div data-slot="line-comment-tools">{split.actions}</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="line-comment-label">
|
||||
{i18n.t("ui.lineComment.label.prefix")}
|
||||
{split.selection}
|
||||
@@ -78,6 +153,25 @@ export const LineComment = (props: LineCommentProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
export type LineCommentAddProps = Omit<LineCommentAnchorProps, "children" | "variant" | "open" | "icon"> & {
|
||||
label?: string
|
||||
}
|
||||
|
||||
export const LineCommentAdd = (props: LineCommentAddProps) => {
|
||||
const [split, rest] = splitProps(props, ["label"])
|
||||
const i18n = useI18n()
|
||||
|
||||
return (
|
||||
<LineCommentAnchor
|
||||
{...rest}
|
||||
open={false}
|
||||
variant="add"
|
||||
icon="plus"
|
||||
buttonLabel={split.label ?? i18n.t("ui.lineComment.submit")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
|
||||
value: string
|
||||
selection: JSX.Element
|
||||
@@ -109,11 +203,16 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
const refs = {
|
||||
textarea: undefined as HTMLTextAreaElement | undefined,
|
||||
}
|
||||
const [text, setText] = createSignal(split.value)
|
||||
|
||||
const focus = () => refs.textarea?.focus()
|
||||
|
||||
createEffect(() => {
|
||||
setText(split.value)
|
||||
})
|
||||
|
||||
const submit = () => {
|
||||
const value = split.value.trim()
|
||||
const value = text().trim()
|
||||
if (!value) return
|
||||
split.onSubmit(value)
|
||||
}
|
||||
@@ -124,7 +223,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
})
|
||||
|
||||
return (
|
||||
<LineCommentAnchor {...rest} open={true} variant="editor" onClick={() => focus()}>
|
||||
<LineCommentAnchor {...rest} open={true} variant="editor" hideButton={props.inline} onClick={() => focus()}>
|
||||
<div data-slot="line-comment-editor">
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
@@ -133,19 +232,23 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
data-slot="line-comment-textarea"
|
||||
rows={split.rows ?? 3}
|
||||
placeholder={split.placeholder ?? i18n.t("ui.lineComment.placeholder")}
|
||||
value={split.value}
|
||||
onInput={(e) => split.onInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
value={text()}
|
||||
on:input={(e) => {
|
||||
const value = (e.currentTarget as HTMLTextAreaElement).value
|
||||
setText(value)
|
||||
split.onInput(value)
|
||||
}}
|
||||
on:keydown={(e) => {
|
||||
const event = e as KeyboardEvent
|
||||
event.stopPropagation()
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
event.preventDefault()
|
||||
split.onCancel()
|
||||
return
|
||||
}
|
||||
if (e.key !== "Enter") return
|
||||
if (e.shiftKey) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
/>
|
||||
@@ -155,12 +258,37 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
{split.selection}
|
||||
{i18n.t("ui.lineComment.editorLabel.suffix")}
|
||||
</div>
|
||||
<Button size="small" variant="ghost" onClick={split.onCancel}>
|
||||
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
|
||||
</Button>
|
||||
<Button size="small" variant="primary" disabled={split.value.trim().length === 0} onClick={submit}>
|
||||
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
|
||||
</Button>
|
||||
<Show
|
||||
when={!props.inline}
|
||||
fallback={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="line-comment-action"
|
||||
data-variant="ghost"
|
||||
on:click={split.onCancel as any}
|
||||
>
|
||||
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="line-comment-action"
|
||||
data-variant="primary"
|
||||
disabled={text().trim().length === 0}
|
||||
on:click={submit as any}
|
||||
>
|
||||
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button size="small" variant="ghost" onClick={split.onCancel}>
|
||||
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
|
||||
</Button>
|
||||
<Button size="small" variant="primary" disabled={text().trim().length === 0} onClick={submit}>
|
||||
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</LineCommentAnchor>
|
||||
|
||||
@@ -27,8 +27,7 @@ import {
|
||||
QuestionInfo,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { useData } from "../context"
|
||||
import { useDiffComponent } from "../context/diff"
|
||||
import { useCodeComponent } from "../context/code"
|
||||
import { useFileComponent } from "../context/file"
|
||||
import { useDialog } from "../context/dialog"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { BasicTool } from "./basic-tool"
|
||||
@@ -1452,7 +1451,7 @@ ToolRegistry.register({
|
||||
name: "edit",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const diffComponent = useDiffComponent()
|
||||
const fileComponent = useFileComponent()
|
||||
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
|
||||
const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "")
|
||||
const filename = () => getFilename(props.input.filePath ?? "")
|
||||
@@ -1499,7 +1498,8 @@ ToolRegistry.register({
|
||||
>
|
||||
<div data-component="edit-content">
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
before={{
|
||||
name: props.metadata?.filediff?.file || props.input.filePath,
|
||||
contents: props.metadata?.filediff?.before || props.input.oldString,
|
||||
@@ -1523,7 +1523,7 @@ ToolRegistry.register({
|
||||
name: "write",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const codeComponent = useCodeComponent()
|
||||
const fileComponent = useFileComponent()
|
||||
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
|
||||
const path = createMemo(() => props.input.filePath || "")
|
||||
const filename = () => getFilename(props.input.filePath ?? "")
|
||||
@@ -1561,7 +1561,8 @@ ToolRegistry.register({
|
||||
<ToolFileAccordion path={path()}>
|
||||
<div data-component="write-content">
|
||||
<Dynamic
|
||||
component={codeComponent}
|
||||
component={fileComponent}
|
||||
mode="text"
|
||||
file={{
|
||||
name: props.input.filePath,
|
||||
contents: props.input.content,
|
||||
@@ -1595,7 +1596,7 @@ ToolRegistry.register({
|
||||
name: "apply_patch",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const diffComponent = useDiffComponent()
|
||||
const fileComponent = useFileComponent()
|
||||
const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
|
||||
const pending = createMemo(() => props.status === "pending" || props.status === "running")
|
||||
const single = createMemo(() => {
|
||||
@@ -1703,7 +1704,8 @@ ToolRegistry.register({
|
||||
<Show when={visible()}>
|
||||
<div data-component="apply-patch-file-diff">
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
before={{ name: file.filePath, contents: file.before }}
|
||||
after={{ name: file.movePath ?? file.filePath, contents: file.after }}
|
||||
/>
|
||||
@@ -1780,7 +1782,8 @@ ToolRegistry.register({
|
||||
>
|
||||
<div data-component="apply-patch-file-diff">
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
before={{ name: file().filePath, contents: file().before }}
|
||||
after={{ name: file().movePath ?? file().filePath, contents: file().after }}
|
||||
/>
|
||||
|
||||
39
packages/ui/src/components/session-review-search.test.ts
Normal file
39
packages/ui/src/components/session-review-search.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildSessionSearchHits, stepSessionSearchIndex } from "./session-review-search"
|
||||
|
||||
describe("session review search", () => {
|
||||
test("builds hits with line, col, and side", () => {
|
||||
const hits = buildSessionSearchHits({
|
||||
query: "alpha",
|
||||
files: [
|
||||
{
|
||||
file: "a.txt",
|
||||
before: "alpha\nbeta alpha",
|
||||
after: "ALPHA",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(hits).toEqual([
|
||||
{ file: "a.txt", side: "deletions", line: 1, col: 1, len: 5 },
|
||||
{ file: "a.txt", side: "deletions", line: 2, col: 6, len: 5 },
|
||||
{ file: "a.txt", side: "additions", line: 1, col: 1, len: 5 },
|
||||
])
|
||||
})
|
||||
|
||||
test("uses non-overlapping matches", () => {
|
||||
const hits = buildSessionSearchHits({
|
||||
query: "aa",
|
||||
files: [{ file: "a.txt", after: "aaaa" }],
|
||||
})
|
||||
|
||||
expect(hits.map((hit) => hit.col)).toEqual([1, 3])
|
||||
})
|
||||
|
||||
test("wraps next and previous navigation", () => {
|
||||
expect(stepSessionSearchIndex(5, 0, -1)).toBe(4)
|
||||
expect(stepSessionSearchIndex(5, 4, 1)).toBe(0)
|
||||
expect(stepSessionSearchIndex(5, 2, 1)).toBe(3)
|
||||
expect(stepSessionSearchIndex(0, 0, 1)).toBe(0)
|
||||
})
|
||||
})
|
||||
59
packages/ui/src/components/session-review-search.ts
Normal file
59
packages/ui/src/components/session-review-search.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type SessionSearchHit = {
|
||||
file: string
|
||||
side: "additions" | "deletions"
|
||||
line: number
|
||||
col: number
|
||||
len: number
|
||||
}
|
||||
|
||||
type SessionSearchFile = {
|
||||
file: string
|
||||
before?: string
|
||||
after?: string
|
||||
}
|
||||
|
||||
function hitsForSide(args: { file: string; side: SessionSearchHit["side"]; text: string; needle: string }) {
|
||||
return args.text.split("\n").flatMap((line, i) => {
|
||||
if (!line) return []
|
||||
|
||||
const hay = line.toLowerCase()
|
||||
let at = hay.indexOf(args.needle)
|
||||
if (at < 0) return []
|
||||
|
||||
const out: SessionSearchHit[] = []
|
||||
while (at >= 0) {
|
||||
out.push({
|
||||
file: args.file,
|
||||
side: args.side,
|
||||
line: i + 1,
|
||||
col: at + 1,
|
||||
len: args.needle.length,
|
||||
})
|
||||
at = hay.indexOf(args.needle, at + args.needle.length)
|
||||
}
|
||||
|
||||
return out
|
||||
})
|
||||
}
|
||||
|
||||
export function buildSessionSearchHits(args: { query: string; files: SessionSearchFile[] }) {
|
||||
const value = args.query.trim().toLowerCase()
|
||||
if (!value) return []
|
||||
|
||||
return args.files.flatMap((file) => {
|
||||
const out: SessionSearchHit[] = []
|
||||
if (typeof file.before === "string") {
|
||||
out.push(...hitsForSide({ file: file.file, side: "deletions", text: file.before, needle: value }))
|
||||
}
|
||||
if (typeof file.after === "string") {
|
||||
out.push(...hitsForSide({ file: file.file, side: "additions", text: file.after, needle: value }))
|
||||
}
|
||||
return out
|
||||
})
|
||||
}
|
||||
|
||||
export function stepSessionSearchIndex(total: number, current: number, dir: 1 | -1) {
|
||||
if (total <= 0) return 0
|
||||
if (current < 0 || current >= total) return dir > 0 ? 0 : total - 1
|
||||
return (current + dir + total) % total
|
||||
}
|
||||
@@ -200,50 +200,6 @@
|
||||
color: var(--icon-diff-modified-base);
|
||||
}
|
||||
|
||||
[data-slot="session-review-file-container"] {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[data-slot="session-review-image-container"] {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: var(--background-stronger);
|
||||
}
|
||||
|
||||
[data-slot="session-review-image"] {
|
||||
max-width: 100%;
|
||||
max-height: 60vh;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-weak-base);
|
||||
background: var(--background-base);
|
||||
}
|
||||
|
||||
[data-slot="session-review-image-placeholder"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="session-review-audio-container"] {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: var(--background-stronger);
|
||||
}
|
||||
|
||||
[data-slot="session-review-audio"] {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
[data-slot="session-review-audio-placeholder"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="session-review-diff-wrapper"] {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { AssistantMessage, type FileDiff, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client"
|
||||
import { useData } from "../context"
|
||||
import { useDiffComponent } from "../context/diff"
|
||||
import { useFileComponent } from "../context/file"
|
||||
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
@@ -152,7 +152,7 @@ export function SessionTurn(
|
||||
) {
|
||||
const data = useData()
|
||||
const i18n = useI18n()
|
||||
const diffComponent = useDiffComponent()
|
||||
const fileComponent = useFileComponent()
|
||||
|
||||
const emptyMessages: MessageType[] = []
|
||||
const emptyParts: PartType[] = []
|
||||
@@ -465,7 +465,8 @@ export function SessionTurn(
|
||||
<Show when={visible()}>
|
||||
<div data-slot="session-turn-diff-view" data-scrollable>
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
before={{ name: diff.file, contents: diff.before }}
|
||||
after={{ name: diff.file, contents: diff.after }}
|
||||
/>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { ValidComponent } from "solid-js"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
||||
const ctx = createSimpleContext<ValidComponent, { component: ValidComponent }>({
|
||||
name: "DiffComponent",
|
||||
init: (props) => props.component,
|
||||
})
|
||||
|
||||
export const DiffComponentProvider = ctx.provider
|
||||
export const useDiffComponent = ctx.use
|
||||
@@ -2,9 +2,9 @@ import type { ValidComponent } from "solid-js"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
||||
const ctx = createSimpleContext<ValidComponent, { component: ValidComponent }>({
|
||||
name: "CodeComponent",
|
||||
name: "FileComponent",
|
||||
init: (props) => props.component,
|
||||
})
|
||||
|
||||
export const CodeComponentProvider = ctx.provider
|
||||
export const useCodeComponent = ctx.use
|
||||
export const FileComponentProvider = ctx.provider
|
||||
export const useFileComponent = ctx.use
|
||||
@@ -1,5 +1,5 @@
|
||||
export * from "./helper"
|
||||
export * from "./data"
|
||||
export * from "./diff"
|
||||
export * from "./file"
|
||||
export * from "./dialog"
|
||||
export * from "./i18n"
|
||||
|
||||
@@ -13,6 +13,15 @@ export const dict = {
|
||||
"ui.sessionReview.largeDiff.title": "Diff كبير جدا لعرضه",
|
||||
"ui.sessionReview.largeDiff.meta": "الحد: {{limit}} سطرًا متغيرًا. الحالي: {{current}} سطرًا متغيرًا.",
|
||||
"ui.sessionReview.largeDiff.renderAnyway": "اعرض على أي حال",
|
||||
"ui.fileMedia.kind.image": "صورة",
|
||||
"ui.fileMedia.kind.audio": "صوت",
|
||||
"ui.fileMedia.state.removed": "تمت إزالة {{kind}}",
|
||||
"ui.fileMedia.state.loading": "جاري تحميل {{kind}}...",
|
||||
"ui.fileMedia.state.error": "خطأ في تحميل {{kind}}",
|
||||
"ui.fileMedia.state.unavailable": "{{kind}} غير متوفر",
|
||||
"ui.fileMedia.binary.title": "ملف ثنائي",
|
||||
"ui.fileMedia.binary.description.path": "{{path}} عبارة عن ملف ثنائي ولا يمكن عرضه.",
|
||||
"ui.fileMedia.binary.description.default": "هذا ملف ثنائي ولا يمكن عرضه.",
|
||||
|
||||
"ui.lineComment.label.prefix": "تعليق على ",
|
||||
"ui.lineComment.label.suffix": "",
|
||||
|
||||
@@ -13,6 +13,15 @@ export const dict = {
|
||||
"ui.sessionReview.largeDiff.title": "Diff grande demais para renderizar",
|
||||
"ui.sessionReview.largeDiff.meta": "Limite: {{limit}} linhas alteradas. Atual: {{current}} linhas alteradas.",
|
||||
"ui.sessionReview.largeDiff.renderAnyway": "Renderizar mesmo assim",
|
||||
"ui.fileMedia.kind.image": "imagem",
|
||||
"ui.fileMedia.kind.audio": "áudio",
|
||||
"ui.fileMedia.state.removed": "Removido: {{kind}}",
|
||||
"ui.fileMedia.state.loading": "Carregando {{kind}}...",
|
||||
"ui.fileMedia.state.error": "Erro ao carregar {{kind}}",
|
||||
"ui.fileMedia.state.unavailable": "{{kind}} indisponível",
|
||||
"ui.fileMedia.binary.title": "Arquivo binário",
|
||||
"ui.fileMedia.binary.description.path": "Não é possível exibir {{path}} porque é um arquivo binário.",
|
||||
"ui.fileMedia.binary.description.default": "Não é possível exibir o arquivo porque ele é binário.",
|
||||
|
||||
"ui.lineComment.label.prefix": "Comentar em ",
|
||||
"ui.lineComment.label.suffix": "",
|
||||
|
||||
@@ -17,6 +17,15 @@ export const dict = {
|
||||
"ui.sessionReview.largeDiff.title": "Diff je prevelik za prikaz",
|
||||
"ui.sessionReview.largeDiff.meta": "Limit: {{limit}} izmijenjenih linija. Trenutno: {{current}} izmijenjenih linija.",
|
||||
"ui.sessionReview.largeDiff.renderAnyway": "Prikaži svejedno",
|
||||
"ui.fileMedia.kind.image": "slika",
|
||||
"ui.fileMedia.kind.audio": "audio",
|
||||
"ui.fileMedia.state.removed": "Uklonjeno: {{kind}}",
|
||||
"ui.fileMedia.state.loading": "Učitavanje: {{kind}}...",
|
||||
"ui.fileMedia.state.error": "Greška pri učitavanju: {{kind}}",
|
||||
"ui.fileMedia.state.unavailable": "Nedostupno: {{kind}}",
|
||||
"ui.fileMedia.binary.title": "Binarni fajl",
|
||||
"ui.fileMedia.binary.description.path": "{{path}} se ne može prikazati jer je binarni fajl.",
|
||||
"ui.fileMedia.binary.description.default": "Ovaj fajl se ne može prikazati jer je binarni.",
|
||||
|
||||
"ui.lineComment.label.prefix": "Komentar na ",
|
||||
"ui.lineComment.label.suffix": "",
|
||||
|
||||
@@ -14,6 +14,15 @@ export const dict = {
|
||||
"ui.sessionReview.largeDiff.title": "Diff er for stor til at blive vist",
|
||||
"ui.sessionReview.largeDiff.meta": "Grænse: {{limit}} ændrede linjer. Nuværende: {{current}} ændrede linjer.",
|
||||
"ui.sessionReview.largeDiff.renderAnyway": "Vis alligevel",
|
||||
"ui.fileMedia.kind.image": "billede",
|
||||
"ui.fileMedia.kind.audio": "lyd",
|
||||
"ui.fileMedia.state.removed": "Fjernet: {{kind}}",
|
||||
"ui.fileMedia.state.loading": "Indlæser {{kind}}...",
|
||||
"ui.fileMedia.state.error": "Fejl ved indlæsning: {{kind}}",
|
||||
"ui.fileMedia.state.unavailable": "Utilgængelig: {{kind}}",
|
||||
"ui.fileMedia.binary.title": "Binær fil",
|
||||
"ui.fileMedia.binary.description.path": "{{path}} kan ikke vises, fordi det er en binær fil.",
|
||||
"ui.fileMedia.binary.description.default": "Denne fil kan ikke vises, fordi det er en binær fil.",
|
||||
"ui.lineComment.label.prefix": "Kommenter på ",
|
||||
"ui.lineComment.label.suffix": "",
|
||||
"ui.lineComment.editorLabel.prefix": "Kommenterer på ",
|
||||
|
||||
@@ -18,6 +18,17 @@ export const dict = {
|
||||
"ui.sessionReview.largeDiff.title": "Diff zu groß zum Rendern",
|
||||
"ui.sessionReview.largeDiff.meta": "Limit: {{limit}} geänderte Zeilen. Aktuell: {{current}} geänderte Zeilen.",
|
||||
"ui.sessionReview.largeDiff.renderAnyway": "Trotzdem rendern",
|
||||
"ui.fileMedia.kind.image": "bild",
|
||||
"ui.fileMedia.kind.audio": "audio",
|
||||
"ui.fileMedia.state.removed": "{{kind}} entfernt",
|
||||
"ui.fileMedia.state.loading": "{{kind}} wird geladen",
|
||||
"ui.fileMedia.state.error": "Fehler bei {{kind}}",
|
||||
"ui.fileMedia.state.unavailable": "{{kind}} nicht verfügbar",
|
||||
"ui.fileMedia.binary.title": "Binärdatei",
|
||||
"ui.fileMedia.binary.description.path":
|
||||
"{{path}} kann nicht angezeigt werden, da es sich um eine Binärdatei handelt.",
|
||||
"ui.fileMedia.binary.description.default":
|
||||
"Diese Datei kann nicht angezeigt werden, da es sich um eine Binärdatei handelt.",
|
||||
"ui.lineComment.label.prefix": "Kommentar zu ",
|
||||
"ui.lineComment.label.suffix": "",
|
||||
"ui.lineComment.editorLabel.prefix": "Kommentiere ",
|
||||
|
||||
@@ -14,6 +14,16 @@ export const dict = {
|
||||
"ui.sessionReview.largeDiff.meta": "Limit: {{limit}} changed lines. Current: {{current}} changed lines.",
|
||||
"ui.sessionReview.largeDiff.renderAnyway": "Render anyway",
|
||||
|
||||
"ui.fileMedia.kind.image": "image",
|
||||
"ui.fileMedia.kind.audio": "audio",
|
||||
"ui.fileMedia.state.removed": "Removed {{kind}} file.",
|
||||
"ui.fileMedia.state.loading": "Loading {{kind}}...",
|
||||
"ui.fileMedia.state.error": "Unable to load {{kind}}.",
|
||||
"ui.fileMedia.state.unavailable": "{{kind}} preview unavailable.",
|
||||
"ui.fileMedia.binary.title": "Binary file",
|
||||
"ui.fileMedia.binary.description.path": "{{path}} is binary.",
|
||||
"ui.fileMedia.binary.description.default": "Binary content",
|
||||
|
||||
"ui.lineComment.label.prefix": "Comment on ",
|
||||
"ui.lineComment.label.suffix": "",
|
||||
"ui.lineComment.editorLabel.prefix": "Commenting on ",
|
||||
|
||||
@@ -13,6 +13,15 @@ export const dict = {
|
||||
"ui.sessionReview.largeDiff.title": "Diff demasiado grande para renderizar",
|
||||
"ui.sessionReview.largeDiff.meta": "Límite: {{limit}} líneas modificadas. Actual: {{current}} líneas modificadas.",
|
||||
"ui.sessionReview.largeDiff.renderAnyway": "Renderizar de todos modos",
|
||||
"ui.fileMedia.kind.image": "imagen",
|
||||
"ui.fileMedia.kind.audio": "audio",
|
||||
"ui.fileMedia.state.removed": "Archivo de {{kind}} eliminado",
|
||||
"ui.fileMedia.state.loading": "Cargando archivo de {{kind}}",
|
||||
"ui.fileMedia.state.error": "Error en el archivo de {{kind}}",
|
||||
"ui.fileMedia.state.unavailable": "Archivo de {{kind}} no disponible",
|
||||
"ui.fileMedia.binary.title": "Archivo binario",
|
||||
"ui.fileMedia.binary.description.path": "No se puede mostrar {{path}} porque es un archivo binario.",
|
||||
"ui.fileMedia.binary.description.default": "No se puede mostrar este archivo porque es un archivo binario.",
|
||||
|
||||
"ui.lineComment.label.prefix": "Comentar en ",
|
||||
"ui.lineComment.label.suffix": "",
|
||||
|
||||
@@ -13,6 +13,15 @@ export const dict = {
|
||||
"ui.sessionReview.largeDiff.title": "Diff trop volumineux pour être affiché",
|
||||
"ui.sessionReview.largeDiff.meta": "Limite : {{limit}} lignes modifiées. Actuel : {{current}} lignes modifiées.",
|
||||
"ui.sessionReview.largeDiff.renderAnyway": "Afficher quand même",
|
||||
"ui.fileMedia.kind.image": "image",
|
||||
"ui.fileMedia.kind.audio": "audio",
|
||||
"ui.fileMedia.state.removed": "Fichier {{kind}} supprimé",
|
||||
"ui.fileMedia.state.loading": "Chargement du fichier {{kind}}",
|
||||
"ui.fileMedia.state.error": "Erreur avec le fichier {{kind}}",
|
||||
"ui.fileMedia.state.unavailable": "Fichier {{kind}} indisponible",
|
||||
"ui.fileMedia.binary.title": "Fichier binaire",
|
||||
"ui.fileMedia.binary.description.path": "Impossible d'afficher {{path}} car il s'agit d'un fichier binaire.",
|
||||
"ui.fileMedia.binary.description.default": "Impossible d'afficher ce fichier car il s'agit d'un fichier binaire.",
|
||||
|
||||
"ui.lineComment.label.prefix": "Commenter sur ",
|
||||
"ui.lineComment.label.suffix": "",
|
||||
|
||||
@@ -14,6 +14,15 @@ export const dict = {
|
||||
"ui.sessionReview.largeDiff.title": "差分が大きすぎて表示できません",
|
||||
"ui.sessionReview.largeDiff.meta": "上限: {{limit}} 変更行。現在: {{current}} 変更行。",
|
||||
"ui.sessionReview.largeDiff.renderAnyway": "それでも表示する",
|
||||
"ui.fileMedia.kind.image": "画像",
|
||||
"ui.fileMedia.kind.audio": "音声",
|
||||
"ui.fileMedia.state.removed": "{{kind}}は削除されました",
|
||||
"ui.fileMedia.state.loading": "{{kind}}を読み込んでいます...",
|
||||
"ui.fileMedia.state.error": "{{kind}}の読み込みに失敗しました",
|
||||
"ui.fileMedia.state.unavailable": "{{kind}}は表示できません",
|
||||
"ui.fileMedia.binary.title": "バイナリファイル",
|
||||
"ui.fileMedia.binary.description.path": "{{path}} はバイナリファイルのため表示できません。",
|
||||
"ui.fileMedia.binary.description.default": "このファイルはバイナリファイルのため表示できません。",
|
||||
"ui.lineComment.label.prefix": "",
|
||||
"ui.lineComment.label.suffix": "へのコメント",
|
||||
"ui.lineComment.editorLabel.prefix": "",
|
||||
|
||||
@@ -13,6 +13,15 @@ export const dict = {
|
||||
"ui.sessionReview.largeDiff.title": "차이가 너무 커서 렌더링할 수 없습니다",
|
||||
"ui.sessionReview.largeDiff.meta": "제한: {{limit}} 변경 줄. 현재: {{current}} 변경 줄.",
|
||||
"ui.sessionReview.largeDiff.renderAnyway": "그래도 렌더링",
|
||||
"ui.fileMedia.kind.image": "이미지",
|
||||
"ui.fileMedia.kind.audio": "오디오",
|
||||
"ui.fileMedia.state.removed": "{{kind}} 제거됨",
|
||||
"ui.fileMedia.state.loading": "{{kind}} 로드 중...",
|
||||
"ui.fileMedia.state.error": "{{kind}} 로드 오류",
|
||||
"ui.fileMedia.state.unavailable": "{{kind}} 사용 불가",
|
||||
"ui.fileMedia.binary.title": "바이너리 파일",
|
||||
"ui.fileMedia.binary.description.path": "{{path}}은(는) 바이너리 파일이므로 표시할 수 없습니다.",
|
||||
"ui.fileMedia.binary.description.default": "바이너리 파일이므로 표시할 수 없습니다.",
|
||||
|
||||
"ui.lineComment.label.prefix": "",
|
||||
"ui.lineComment.label.suffix": "에 댓글 달기",
|
||||
|
||||
@@ -16,6 +16,15 @@ export const dict: Record<Keys, string> = {
|
||||
"ui.sessionReview.largeDiff.title": "Diff er for stor til å gjengi",
|
||||
"ui.sessionReview.largeDiff.meta": "Grense: {{limit}} endrede linjer. Nåværende: {{current}} endrede linjer.",
|
||||
"ui.sessionReview.largeDiff.renderAnyway": "Gjengi likevel",
|
||||
"ui.fileMedia.kind.image": "bilde",
|
||||
"ui.fileMedia.kind.audio": "lyd",
|
||||
"ui.fileMedia.state.removed": "Fjernet: {{kind}}",
|
||||
"ui.fileMedia.state.loading": "Laster inn {{kind}}...",
|
||||
"ui.fileMedia.state.error": "Feil ved innlasting: {{kind}}",
|
||||
"ui.fileMedia.state.unavailable": "Ikke tilgjengelig: {{kind}}",
|
||||
"ui.fileMedia.binary.title": "Binærfil",
|
||||
"ui.fileMedia.binary.description.path": "{{path}} kan ikke vises fordi det er en binærfil.",
|
||||
"ui.fileMedia.binary.description.default": "Denne filen kan ikke vises fordi det er en binærfil.",
|
||||
|
||||
"ui.lineComment.label.prefix": "Kommenter på ",
|
||||
"ui.lineComment.label.suffix": "",
|
||||
|
||||
@@ -14,6 +14,15 @@ export const dict = {
|
||||
"ui.sessionReview.largeDiff.title": "Diff jest zbyt duży, aby go wyrenderować",
|
||||
"ui.sessionReview.largeDiff.meta": "Limit: {{limit}} zmienionych linii. Obecnie: {{current}} zmienionych linii.",
|
||||
"ui.sessionReview.largeDiff.renderAnyway": "Renderuj mimo to",
|
||||
"ui.fileMedia.kind.image": "obraz",
|
||||
"ui.fileMedia.kind.audio": "dźwięk",
|
||||
"ui.fileMedia.state.removed": "{{kind}} usunięty",
|
||||
"ui.fileMedia.state.loading": "Wczytywanie: {{kind}}...",
|
||||
"ui.fileMedia.state.error": "Błąd wczytywania: {{kind}}",
|
||||
"ui.fileMedia.state.unavailable": "{{kind}} niedostępny",
|
||||
"ui.fileMedia.binary.title": "Plik binarny",
|
||||
"ui.fileMedia.binary.description.path": "Nie można wyświetlić pliku {{path}}, ponieważ jest to plik binarny.",
|
||||
"ui.fileMedia.binary.description.default": "Nie można wyświetlić tego pliku, ponieważ jest to plik binarny.",
|
||||
"ui.lineComment.label.prefix": "Komentarz do ",
|
||||
"ui.lineComment.label.suffix": "",
|
||||
"ui.lineComment.editorLabel.prefix": "Komentowanie: ",
|
||||
|
||||
@@ -14,6 +14,15 @@ export const dict = {
|
||||
"ui.sessionReview.largeDiff.title": "Diff слишком большой для отображения",
|
||||
"ui.sessionReview.largeDiff.meta": "Лимит: {{limit}} изменённых строк. Текущий: {{current}} изменённых строк.",
|
||||
"ui.sessionReview.largeDiff.renderAnyway": "Отобразить всё равно",
|
||||
"ui.fileMedia.kind.image": "изображение",
|
||||
"ui.fileMedia.kind.audio": "аудио",
|
||||
"ui.fileMedia.state.removed": "{{kind}} удалено",
|
||||
"ui.fileMedia.state.loading": "Загружается {{kind}}...",
|
||||
"ui.fileMedia.state.error": "Не удалось загрузить {{kind}}",
|
||||
"ui.fileMedia.state.unavailable": "{{kind}} недоступно",
|
||||
"ui.fileMedia.binary.title": "Бинарный файл",
|
||||
"ui.fileMedia.binary.description.path": "Невозможно отобразить {{path}}, так как это бинарный файл.",
|
||||
"ui.fileMedia.binary.description.default": "Невозможно отобразить этот файл, так как он бинарный.",
|
||||
"ui.lineComment.label.prefix": "Комментарий к ",
|
||||
"ui.lineComment.label.suffix": "",
|
||||
"ui.lineComment.editorLabel.prefix": "Комментирование: ",
|
||||
|
||||
@@ -14,6 +14,15 @@ export const dict = {
|
||||
"ui.sessionReview.largeDiff.meta":
|
||||
"ขีดจำกัด: {{limit}} บรรทัดที่เปลี่ยนแปลง. ปัจจุบัน: {{current}} บรรทัดที่เปลี่ยนแปลง.",
|
||||
"ui.sessionReview.largeDiff.renderAnyway": "แสดงผลต่อไป",
|
||||
"ui.fileMedia.kind.image": "รูปภาพ",
|
||||
"ui.fileMedia.kind.audio": "เสียง",
|
||||
"ui.fileMedia.state.removed": "ลบ{{kind}}แล้ว",
|
||||
"ui.fileMedia.state.loading": "กำลังโหลด{{kind}}...",
|
||||
"ui.fileMedia.state.error": "เกิดข้อผิดพลาดในการโหลด{{kind}}",
|
||||
"ui.fileMedia.state.unavailable": "{{kind}}ไม่พร้อมใช้งาน",
|
||||
"ui.fileMedia.binary.title": "ไฟล์ไบนารี",
|
||||
"ui.fileMedia.binary.description.path": "{{path}} เป็นไฟล์ไบนารีและไม่สามารถแสดงผลได้",
|
||||
"ui.fileMedia.binary.description.default": "ไฟล์ไบนารีไม่สามารถแสดงผลได้",
|
||||
|
||||
"ui.lineComment.label.prefix": "แสดงความคิดเห็นบน ",
|
||||
"ui.lineComment.label.suffix": "",
|
||||
|
||||
@@ -17,6 +17,15 @@ export const dict = {
|
||||
"ui.sessionReview.largeDiff.title": "差异过大,无法渲染",
|
||||
"ui.sessionReview.largeDiff.meta": "限制:{{limit}} 行变更。当前:{{current}} 行变更。",
|
||||
"ui.sessionReview.largeDiff.renderAnyway": "仍然渲染",
|
||||
"ui.fileMedia.kind.image": "图片",
|
||||
"ui.fileMedia.kind.audio": "音频",
|
||||
"ui.fileMedia.state.removed": "{{kind}}已移除",
|
||||
"ui.fileMedia.state.loading": "正在加载{{kind}}...",
|
||||
"ui.fileMedia.state.error": "加载{{kind}}失败",
|
||||
"ui.fileMedia.state.unavailable": "{{kind}}不可预览",
|
||||
"ui.fileMedia.binary.title": "二进制文件",
|
||||
"ui.fileMedia.binary.description.path": "无法显示 {{path}},因为它是二进制文件。",
|
||||
"ui.fileMedia.binary.description.default": "无法显示此文件,因为它是二进制文件。",
|
||||
|
||||
"ui.lineComment.label.prefix": "评论 ",
|
||||
"ui.lineComment.label.suffix": "",
|
||||
|
||||
@@ -17,6 +17,15 @@ export const dict = {
|
||||
"ui.sessionReview.largeDiff.title": "差異過大,無法渲染",
|
||||
"ui.sessionReview.largeDiff.meta": "限制:{{limit}} 行變更。目前:{{current}} 行變更。",
|
||||
"ui.sessionReview.largeDiff.renderAnyway": "仍然渲染",
|
||||
"ui.fileMedia.kind.image": "圖片",
|
||||
"ui.fileMedia.kind.audio": "音訊",
|
||||
"ui.fileMedia.state.removed": "{{kind}}已移除",
|
||||
"ui.fileMedia.state.loading": "正在載入{{kind}}...",
|
||||
"ui.fileMedia.state.error": "載入{{kind}}失敗",
|
||||
"ui.fileMedia.state.unavailable": "{{kind}}無法預覽",
|
||||
"ui.fileMedia.binary.title": "二進位檔案",
|
||||
"ui.fileMedia.binary.description.path": "無法顯示 {{path}},因為它是二進位檔案。",
|
||||
"ui.fileMedia.binary.description.default": "無法顯示此檔案,因為它是二進位檔案。",
|
||||
|
||||
"ui.lineComment.label.prefix": "評論 ",
|
||||
"ui.lineComment.label.suffix": "",
|
||||
|
||||
74
packages/ui/src/pierre/comment-hover.ts
Normal file
74
packages/ui/src/pierre/comment-hover.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export type HoverCommentLine = {
|
||||
lineNumber: number
|
||||
side?: "additions" | "deletions"
|
||||
}
|
||||
|
||||
export function createHoverCommentUtility(props: {
|
||||
label: string
|
||||
getHoveredLine: () => HoverCommentLine | undefined
|
||||
onSelect: (line: HoverCommentLine) => void
|
||||
}) {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const button = document.createElement("button")
|
||||
button.type = "button"
|
||||
button.ariaLabel = props.label
|
||||
button.textContent = "+"
|
||||
button.style.width = "20px"
|
||||
button.style.height = "20px"
|
||||
button.style.display = "flex"
|
||||
button.style.alignItems = "center"
|
||||
button.style.justifyContent = "center"
|
||||
button.style.border = "none"
|
||||
button.style.borderRadius = "var(--radius-md)"
|
||||
button.style.background = "var(--icon-interactive-base)"
|
||||
button.style.color = "var(--white)"
|
||||
button.style.boxShadow = "var(--shadow-xs)"
|
||||
button.style.fontSize = "14px"
|
||||
button.style.lineHeight = "1"
|
||||
button.style.cursor = "pointer"
|
||||
button.style.position = "relative"
|
||||
button.style.left = "30px"
|
||||
button.style.top = "calc((var(--diffs-line-height, 24px) - 20px) / 2)"
|
||||
|
||||
let line: HoverCommentLine | undefined
|
||||
|
||||
const sync = () => {
|
||||
const next = props.getHoveredLine()
|
||||
if (!next) return
|
||||
line = next
|
||||
}
|
||||
|
||||
const loop = () => {
|
||||
if (!button.isConnected) return
|
||||
sync()
|
||||
requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
const next = props.getHoveredLine() ?? line
|
||||
if (!next) return
|
||||
props.onSelect(next)
|
||||
}
|
||||
|
||||
requestAnimationFrame(loop)
|
||||
button.addEventListener("mouseenter", sync)
|
||||
button.addEventListener("mousemove", sync)
|
||||
button.addEventListener("pointerdown", (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
sync()
|
||||
})
|
||||
button.addEventListener("mousedown", (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
sync()
|
||||
})
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
open()
|
||||
})
|
||||
|
||||
return button
|
||||
}
|
||||
91
packages/ui/src/pierre/commented-lines.ts
Normal file
91
packages/ui/src/pierre/commented-lines.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
import { diffLineIndex, diffRowIndex, findDiffSide } from "./diff-selection"
|
||||
|
||||
export type CommentSide = "additions" | "deletions"
|
||||
|
||||
function annotationIndex(node: HTMLElement) {
|
||||
const value = node.dataset.lineAnnotation?.split(",")[1]
|
||||
if (!value) return
|
||||
const line = parseInt(value, 10)
|
||||
if (Number.isNaN(line)) return
|
||||
return line
|
||||
}
|
||||
|
||||
function clear(root: ShadowRoot) {
|
||||
const marked = Array.from(root.querySelectorAll("[data-comment-selected]"))
|
||||
for (const node of marked) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
node.removeAttribute("data-comment-selected")
|
||||
}
|
||||
}
|
||||
|
||||
export function markCommentedDiffLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
|
||||
clear(root)
|
||||
|
||||
const diffs = root.querySelector("[data-diff]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.diffType === "split"
|
||||
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (rows.length === 0) return
|
||||
|
||||
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
for (const range of ranges) {
|
||||
const start = diffRowIndex(root, split, range.start, range.side as CommentSide | undefined)
|
||||
if (start === undefined) continue
|
||||
|
||||
const end = (() => {
|
||||
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
|
||||
if (same) return start
|
||||
return diffRowIndex(root, split, range.end, (range.endSide ?? range.side) as CommentSide | undefined)
|
||||
})()
|
||||
if (end === undefined) continue
|
||||
|
||||
const first = Math.min(start, end)
|
||||
const last = Math.max(start, end)
|
||||
|
||||
for (const row of rows) {
|
||||
const idx = diffLineIndex(split, row)
|
||||
if (idx === undefined || idx < first || idx > last) continue
|
||||
row.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const idx = annotationIndex(annotation)
|
||||
if (idx === undefined || idx < first || idx > last) continue
|
||||
annotation.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function markCommentedFileLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
|
||||
clear(root)
|
||||
|
||||
const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
for (const range of ranges) {
|
||||
const start = Math.max(1, Math.min(range.start, range.end))
|
||||
const end = Math.max(range.start, range.end)
|
||||
|
||||
for (let line = start; line <= end; line++) {
|
||||
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`))
|
||||
for (const node of nodes) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
node.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const line = annotationIndex(annotation)
|
||||
if (line === undefined || line < start || line > end) continue
|
||||
annotation.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
71
packages/ui/src/pierre/diff-selection.ts
Normal file
71
packages/ui/src/pierre/diff-selection.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
|
||||
export type DiffSelectionSide = "additions" | "deletions"
|
||||
|
||||
export function findDiffSide(node: HTMLElement): DiffSelectionSide {
|
||||
const line = node.closest("[data-line], [data-alt-line]")
|
||||
if (line instanceof HTMLElement) {
|
||||
const type = line.dataset.lineType
|
||||
if (type === "change-deletion") return "deletions"
|
||||
if (type === "change-addition" || type === "change-additions") return "additions"
|
||||
}
|
||||
|
||||
const code = node.closest("[data-code]")
|
||||
if (!(code instanceof HTMLElement)) return "additions"
|
||||
return code.hasAttribute("data-deletions") ? "deletions" : "additions"
|
||||
}
|
||||
|
||||
export function diffLineIndex(split: boolean, node: HTMLElement) {
|
||||
const raw = node.dataset.lineIndex
|
||||
if (!raw) return
|
||||
|
||||
const values = raw
|
||||
.split(",")
|
||||
.map((x) => parseInt(x, 10))
|
||||
.filter((x) => !Number.isNaN(x))
|
||||
if (values.length === 0) return
|
||||
if (!split) return values[0]
|
||||
if (values.length === 2) return values[1]
|
||||
return values[0]
|
||||
}
|
||||
|
||||
export function diffRowIndex(root: ShadowRoot, split: boolean, line: number, side: DiffSelectionSide | undefined) {
|
||||
const rows = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (rows.length === 0) return
|
||||
|
||||
const target = side ?? "additions"
|
||||
for (const row of rows) {
|
||||
if (findDiffSide(row) === target) return diffLineIndex(split, row)
|
||||
if (parseInt(row.dataset.altLine ?? "", 10) === line) return diffLineIndex(split, row)
|
||||
}
|
||||
}
|
||||
|
||||
export function fixDiffSelection(root: ShadowRoot | undefined, range: SelectedLineRange | null) {
|
||||
if (!range) return range
|
||||
if (!root) return
|
||||
|
||||
const diffs = root.querySelector("[data-diff]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.diffType === "split"
|
||||
const start = diffRowIndex(root, split, range.start, range.side)
|
||||
const end = diffRowIndex(root, split, range.end, range.endSide ?? range.side)
|
||||
|
||||
if (start === undefined || end === undefined) {
|
||||
if (root.querySelector("[data-line], [data-alt-line]") == null) return
|
||||
return null
|
||||
}
|
||||
if (start <= end) return range
|
||||
|
||||
const side = range.endSide ?? range.side
|
||||
const swapped: SelectedLineRange = {
|
||||
start: range.end,
|
||||
end: range.start,
|
||||
}
|
||||
|
||||
if (side) swapped.side = side
|
||||
if (range.endSide && range.side) swapped.endSide = range.side
|
||||
return swapped
|
||||
}
|
||||
576
packages/ui/src/pierre/file-find.ts
Normal file
576
packages/ui/src/pierre/file-find.ts
Normal file
@@ -0,0 +1,576 @@
|
||||
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
|
||||
export type FindHost = {
|
||||
element: () => HTMLElement | undefined
|
||||
open: () => void
|
||||
close: () => void
|
||||
next: (dir: 1 | -1) => void
|
||||
isOpen: () => boolean
|
||||
}
|
||||
|
||||
type FileFindSide = "additions" | "deletions"
|
||||
|
||||
export type FileFindReveal = {
|
||||
side: FileFindSide
|
||||
line: number
|
||||
col: number
|
||||
len: number
|
||||
}
|
||||
|
||||
type FileFindHit = FileFindReveal & {
|
||||
range: Range
|
||||
alt?: number
|
||||
}
|
||||
|
||||
const hosts = new Set<FindHost>()
|
||||
let target: FindHost | undefined
|
||||
let current: FindHost | undefined
|
||||
let installed = false
|
||||
|
||||
function isEditable(node: unknown): boolean {
|
||||
if (!(node instanceof HTMLElement)) return false
|
||||
if (node.closest("[data-prevent-autofocus]")) return true
|
||||
if (node.isContentEditable) return true
|
||||
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName)
|
||||
}
|
||||
|
||||
function hostForNode(node: unknown) {
|
||||
if (!(node instanceof Node)) return
|
||||
for (const host of hosts) {
|
||||
const el = host.element()
|
||||
if (el && el.isConnected && el.contains(node)) return host
|
||||
}
|
||||
}
|
||||
|
||||
function installShortcuts() {
|
||||
if (installed) return
|
||||
if (typeof window === "undefined") return
|
||||
installed = true
|
||||
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.defaultPrevented) return
|
||||
if (isEditable(event.target)) return
|
||||
|
||||
const mod = event.metaKey || event.ctrlKey
|
||||
if (!mod) return
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
if (key === "g") {
|
||||
const host = current
|
||||
if (!host || !host.isOpen()) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
host.next(event.shiftKey ? -1 : 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (key !== "f") return
|
||||
|
||||
const active = current
|
||||
if (active && active.isOpen()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
active.open()
|
||||
return
|
||||
}
|
||||
|
||||
const host = hostForNode(document.activeElement) ?? hostForNode(event.target) ?? target ?? Array.from(hosts)[0]
|
||||
if (!host) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
host.open()
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
}
|
||||
|
||||
function clearHighlightFind() {
|
||||
const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights
|
||||
if (!api) return
|
||||
api.delete("opencode-find")
|
||||
api.delete("opencode-find-current")
|
||||
}
|
||||
|
||||
function supportsHighlights() {
|
||||
const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown }
|
||||
return typeof g.Highlight === "function" && g.CSS?.highlights != null
|
||||
}
|
||||
|
||||
function scrollParent(el: HTMLElement): HTMLElement | undefined {
|
||||
let parent = el.parentElement
|
||||
while (parent) {
|
||||
const style = getComputedStyle(parent)
|
||||
if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
|
||||
parent = parent.parentElement
|
||||
}
|
||||
}
|
||||
|
||||
type CreateFileFindOptions = {
|
||||
wrapper: () => HTMLElement | undefined
|
||||
overlay: () => HTMLDivElement | undefined
|
||||
getRoot: () => ShadowRoot | undefined
|
||||
shortcuts?: "global" | "disabled"
|
||||
}
|
||||
|
||||
export function createFileFind(opts: CreateFileFindOptions) {
|
||||
let input: HTMLInputElement | undefined
|
||||
let overlayFrame: number | undefined
|
||||
let overlayScroll: HTMLElement[] = []
|
||||
let mode: "highlights" | "overlay" = "overlay"
|
||||
let hits: FileFindHit[] = []
|
||||
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [index, setIndex] = createSignal(0)
|
||||
const [count, setCount] = createSignal(0)
|
||||
const [pos, setPos] = createSignal({ top: 8, right: 8 })
|
||||
|
||||
const clearOverlayScroll = () => {
|
||||
for (const el of overlayScroll) el.removeEventListener("scroll", scheduleOverlay)
|
||||
overlayScroll = []
|
||||
}
|
||||
|
||||
const clearOverlay = () => {
|
||||
const el = opts.overlay()
|
||||
if (!el) return
|
||||
if (overlayFrame !== undefined) {
|
||||
cancelAnimationFrame(overlayFrame)
|
||||
overlayFrame = undefined
|
||||
}
|
||||
el.innerHTML = ""
|
||||
}
|
||||
|
||||
const renderOverlay = () => {
|
||||
if (mode !== "overlay") {
|
||||
clearOverlay()
|
||||
return
|
||||
}
|
||||
|
||||
const wrapper = opts.wrapper()
|
||||
const overlay = opts.overlay()
|
||||
if (!wrapper || !overlay) return
|
||||
|
||||
clearOverlay()
|
||||
if (hits.length === 0) return
|
||||
|
||||
const base = wrapper.getBoundingClientRect()
|
||||
const currentIndex = index()
|
||||
const frag = document.createDocumentFragment()
|
||||
|
||||
for (let i = 0; i < hits.length; i++) {
|
||||
const range = hits[i].range
|
||||
const active = i === currentIndex
|
||||
for (const rect of Array.from(range.getClientRects())) {
|
||||
if (!rect.width || !rect.height) continue
|
||||
|
||||
const mark = document.createElement("div")
|
||||
mark.style.position = "absolute"
|
||||
mark.style.left = `${Math.round(rect.left - base.left)}px`
|
||||
mark.style.top = `${Math.round(rect.top - base.top)}px`
|
||||
mark.style.width = `${Math.round(rect.width)}px`
|
||||
mark.style.height = `${Math.round(rect.height)}px`
|
||||
mark.style.borderRadius = "2px"
|
||||
mark.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)"
|
||||
mark.style.opacity = active ? "0.55" : "0.35"
|
||||
if (active) mark.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)"
|
||||
frag.appendChild(mark)
|
||||
}
|
||||
}
|
||||
|
||||
overlay.appendChild(frag)
|
||||
}
|
||||
|
||||
function scheduleOverlay() {
|
||||
if (mode !== "overlay") return
|
||||
if (!open()) return
|
||||
if (overlayFrame !== undefined) return
|
||||
|
||||
overlayFrame = requestAnimationFrame(() => {
|
||||
overlayFrame = undefined
|
||||
renderOverlay()
|
||||
})
|
||||
}
|
||||
|
||||
const syncOverlayScroll = () => {
|
||||
if (mode !== "overlay") return
|
||||
const root = opts.getRoot()
|
||||
|
||||
const next = root
|
||||
? Array.from(root.querySelectorAll("[data-code]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
: []
|
||||
if (next.length === overlayScroll.length && next.every((el, i) => el === overlayScroll[i])) return
|
||||
|
||||
clearOverlayScroll()
|
||||
overlayScroll = next
|
||||
for (const el of overlayScroll) el.addEventListener("scroll", scheduleOverlay, { passive: true })
|
||||
}
|
||||
|
||||
const clearFind = () => {
|
||||
clearHighlightFind()
|
||||
clearOverlay()
|
||||
clearOverlayScroll()
|
||||
hits = []
|
||||
setCount(0)
|
||||
setIndex(0)
|
||||
}
|
||||
|
||||
const positionBar = () => {
|
||||
if (typeof window === "undefined") return
|
||||
const wrapper = opts.wrapper()
|
||||
if (!wrapper) return
|
||||
|
||||
const root = scrollParent(wrapper) ?? wrapper
|
||||
const rect = root.getBoundingClientRect()
|
||||
const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
|
||||
const header = Number.isNaN(title) ? 0 : title
|
||||
|
||||
setPos({
|
||||
top: Math.round(rect.top) + header - 4,
|
||||
right: Math.round(window.innerWidth - rect.right) + 8,
|
||||
})
|
||||
}
|
||||
|
||||
const scan = (root: ShadowRoot, value: string) => {
|
||||
const needle = value.toLowerCase()
|
||||
const ranges: FileFindHit[] = []
|
||||
const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
for (const col of cols) {
|
||||
const text = col.textContent
|
||||
if (!text) continue
|
||||
|
||||
const hay = text.toLowerCase()
|
||||
let at = hay.indexOf(needle)
|
||||
if (at === -1) continue
|
||||
|
||||
const row = col.closest("[data-line], [data-alt-line]")
|
||||
if (!(row instanceof HTMLElement)) continue
|
||||
|
||||
const primary = parseInt(row.dataset.line ?? "", 10)
|
||||
const alt = parseInt(row.dataset.altLine ?? "", 10)
|
||||
const line = (() => {
|
||||
if (!Number.isNaN(primary)) return primary
|
||||
if (!Number.isNaN(alt)) return alt
|
||||
})()
|
||||
if (line === undefined) continue
|
||||
|
||||
const side = (() => {
|
||||
const code = col.closest("[data-code]")
|
||||
if (code instanceof HTMLElement) return code.hasAttribute("data-deletions") ? "deletions" : "additions"
|
||||
|
||||
const row = col.closest("[data-line-type]")
|
||||
if (!(row instanceof HTMLElement)) return "additions"
|
||||
const type = row.dataset.lineType
|
||||
if (type === "change-deletion") return "deletions"
|
||||
return "additions"
|
||||
})() as FileFindSide
|
||||
|
||||
const nodes: Text[] = []
|
||||
const ends: number[] = []
|
||||
const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT)
|
||||
let node = walker.nextNode()
|
||||
let pos = 0
|
||||
while (node) {
|
||||
if (node instanceof Text) {
|
||||
pos += node.data.length
|
||||
nodes.push(node)
|
||||
ends.push(pos)
|
||||
}
|
||||
node = walker.nextNode()
|
||||
}
|
||||
if (nodes.length === 0) continue
|
||||
|
||||
const locate = (offset: number) => {
|
||||
let lo = 0
|
||||
let hi = ends.length - 1
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (ends[mid] >= offset) hi = mid
|
||||
else lo = mid + 1
|
||||
}
|
||||
const prev = lo === 0 ? 0 : ends[lo - 1]
|
||||
return { node: nodes[lo], offset: offset - prev }
|
||||
}
|
||||
|
||||
while (at !== -1) {
|
||||
const start = locate(at)
|
||||
const end = locate(at + value.length)
|
||||
const range = document.createRange()
|
||||
range.setStart(start.node, start.offset)
|
||||
range.setEnd(end.node, end.offset)
|
||||
ranges.push({
|
||||
range,
|
||||
side,
|
||||
line,
|
||||
alt: Number.isNaN(alt) ? undefined : alt,
|
||||
col: at + 1,
|
||||
len: value.length,
|
||||
})
|
||||
at = hay.indexOf(needle, at + value.length)
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
const scrollToRange = (range: Range) => {
|
||||
const scroll = () => {
|
||||
const start = range.startContainer
|
||||
const el = start instanceof Element ? start : start.parentElement
|
||||
el?.scrollIntoView({ block: "center", inline: "center" })
|
||||
}
|
||||
|
||||
scroll()
|
||||
requestAnimationFrame(scroll)
|
||||
}
|
||||
|
||||
const setHighlights = (ranges: FileFindHit[], currentIndex: number) => {
|
||||
const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights
|
||||
const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight
|
||||
if (!api || typeof Highlight !== "function") return false
|
||||
|
||||
api.delete("opencode-find")
|
||||
api.delete("opencode-find-current")
|
||||
|
||||
const active = ranges[currentIndex]?.range
|
||||
if (active) api.set("opencode-find-current", new Highlight(active))
|
||||
|
||||
const rest = ranges.flatMap((hit, i) => (i === currentIndex ? [] : [hit.range]))
|
||||
if (rest.length > 0) api.set("opencode-find", new Highlight(...rest))
|
||||
return true
|
||||
}
|
||||
|
||||
const select = (currentIndex: number, scroll: boolean) => {
|
||||
const active = hits[currentIndex]?.range
|
||||
if (!active) return false
|
||||
|
||||
setIndex(currentIndex)
|
||||
|
||||
if (mode === "highlights") {
|
||||
if (!setHighlights(hits, currentIndex)) {
|
||||
mode = "overlay"
|
||||
apply({ reset: true, scroll })
|
||||
return false
|
||||
}
|
||||
if (scroll) scrollToRange(active)
|
||||
return true
|
||||
}
|
||||
|
||||
clearHighlightFind()
|
||||
syncOverlayScroll()
|
||||
if (scroll) scrollToRange(active)
|
||||
scheduleOverlay()
|
||||
return true
|
||||
}
|
||||
|
||||
const apply = (args?: { reset?: boolean; scroll?: boolean }) => {
|
||||
if (!open()) return
|
||||
|
||||
const value = query().trim()
|
||||
if (!value) {
|
||||
clearFind()
|
||||
return
|
||||
}
|
||||
|
||||
const root = opts.getRoot()
|
||||
if (!root) return
|
||||
|
||||
mode = supportsHighlights() ? "highlights" : "overlay"
|
||||
|
||||
const ranges = scan(root, value)
|
||||
const total = ranges.length
|
||||
const desired = args?.reset ? 0 : index()
|
||||
const currentIndex = total ? Math.min(desired, total - 1) : 0
|
||||
|
||||
hits = ranges
|
||||
setCount(total)
|
||||
setIndex(currentIndex)
|
||||
|
||||
const active = ranges[currentIndex]?.range
|
||||
if (mode === "highlights") {
|
||||
clearOverlay()
|
||||
clearOverlayScroll()
|
||||
if (!setHighlights(ranges, currentIndex)) {
|
||||
mode = "overlay"
|
||||
clearHighlightFind()
|
||||
syncOverlayScroll()
|
||||
scheduleOverlay()
|
||||
}
|
||||
if (args?.scroll && active) scrollToRange(active)
|
||||
return
|
||||
}
|
||||
|
||||
clearHighlightFind()
|
||||
syncOverlayScroll()
|
||||
if (args?.scroll && active) scrollToRange(active)
|
||||
scheduleOverlay()
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
setOpen(false)
|
||||
setQuery("")
|
||||
clearFind()
|
||||
if (current === host) current = undefined
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
setQuery("")
|
||||
clearFind()
|
||||
}
|
||||
|
||||
const activate = () => {
|
||||
if (opts.shortcuts !== "disabled") {
|
||||
if (current && current !== host) current.close()
|
||||
current = host
|
||||
target = host
|
||||
}
|
||||
|
||||
if (!open()) setOpen(true)
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
activate()
|
||||
requestAnimationFrame(() => {
|
||||
apply({ scroll: true })
|
||||
input?.focus()
|
||||
input?.select()
|
||||
})
|
||||
}
|
||||
|
||||
const next = (dir: 1 | -1) => {
|
||||
if (!open()) return
|
||||
const total = count()
|
||||
if (total <= 0) return
|
||||
|
||||
const currentIndex = (index() + dir + total) % total
|
||||
select(currentIndex, true)
|
||||
}
|
||||
|
||||
const reveal = (targetHit: FileFindReveal) => {
|
||||
if (!open()) return false
|
||||
if (hits.length === 0) return false
|
||||
|
||||
const exact = hits.findIndex(
|
||||
(hit) =>
|
||||
hit.side === targetHit.side &&
|
||||
hit.line === targetHit.line &&
|
||||
hit.col === targetHit.col &&
|
||||
hit.len === targetHit.len,
|
||||
)
|
||||
const fallback = hits.findIndex(
|
||||
(hit) =>
|
||||
(hit.line === targetHit.line || hit.alt === targetHit.line) &&
|
||||
hit.col === targetHit.col &&
|
||||
hit.len === targetHit.len,
|
||||
)
|
||||
|
||||
const nextIndex = exact >= 0 ? exact : fallback
|
||||
if (nextIndex < 0) return false
|
||||
return select(nextIndex, true)
|
||||
}
|
||||
|
||||
const host: FindHost = {
|
||||
element: opts.wrapper,
|
||||
isOpen: () => open(),
|
||||
next,
|
||||
open: focus,
|
||||
close,
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mode = supportsHighlights() ? "highlights" : "overlay"
|
||||
if (opts.shortcuts !== "disabled") {
|
||||
installShortcuts()
|
||||
hosts.add(host)
|
||||
if (!target) target = host
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (opts.shortcuts !== "disabled") {
|
||||
hosts.delete(host)
|
||||
if (current === host) {
|
||||
current = undefined
|
||||
clearHighlightFind()
|
||||
}
|
||||
if (target === host) target = undefined
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!open()) return
|
||||
|
||||
const update = () => positionBar()
|
||||
requestAnimationFrame(update)
|
||||
window.addEventListener("resize", update, { passive: true })
|
||||
|
||||
const wrapper = opts.wrapper()
|
||||
if (!wrapper) return
|
||||
const root = scrollParent(wrapper) ?? wrapper
|
||||
const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(() => update())
|
||||
observer?.observe(root)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("resize", update)
|
||||
observer?.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
clearOverlayScroll()
|
||||
clearOverlay()
|
||||
if (current === host) {
|
||||
current = undefined
|
||||
clearHighlightFind()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
open,
|
||||
query,
|
||||
count,
|
||||
index,
|
||||
pos,
|
||||
setInput: (el: HTMLInputElement) => {
|
||||
input = el
|
||||
},
|
||||
setQuery: (value: string, args?: { scroll?: boolean }) => {
|
||||
setQuery(value)
|
||||
setIndex(0)
|
||||
apply({ reset: true, scroll: args?.scroll ?? true })
|
||||
},
|
||||
clear,
|
||||
activate,
|
||||
focus,
|
||||
close,
|
||||
next,
|
||||
reveal,
|
||||
refresh: (args?: { reset?: boolean; scroll?: boolean }) => apply(args),
|
||||
onPointerDown: () => {
|
||||
if (opts.shortcuts === "disabled") return
|
||||
target = host
|
||||
opts.wrapper()?.focus({ preventScroll: true })
|
||||
},
|
||||
onFocus: () => {
|
||||
if (opts.shortcuts === "disabled") return
|
||||
target = host
|
||||
},
|
||||
onInputKeyDown: (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Enter") return
|
||||
event.preventDefault()
|
||||
next(event.shiftKey ? -1 : 1)
|
||||
},
|
||||
}
|
||||
}
|
||||
114
packages/ui/src/pierre/file-runtime.ts
Normal file
114
packages/ui/src/pierre/file-runtime.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
type ReadyWatcher = {
|
||||
observer?: MutationObserver
|
||||
token: number
|
||||
}
|
||||
|
||||
export function createReadyWatcher(): ReadyWatcher {
|
||||
return { token: 0 }
|
||||
}
|
||||
|
||||
export function clearReadyWatcher(state: ReadyWatcher) {
|
||||
state.observer?.disconnect()
|
||||
state.observer = undefined
|
||||
}
|
||||
|
||||
export function getViewerHost(container: HTMLElement | undefined) {
|
||||
if (!container) return
|
||||
const host = container.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
return host
|
||||
}
|
||||
|
||||
export function getViewerRoot(container: HTMLElement | undefined) {
|
||||
return getViewerHost(container)?.shadowRoot ?? undefined
|
||||
}
|
||||
|
||||
export function applyViewerScheme(host: HTMLElement | undefined) {
|
||||
if (!host) return
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const scheme = document.documentElement.dataset.colorScheme
|
||||
if (scheme === "dark" || scheme === "light") {
|
||||
host.dataset.colorScheme = scheme
|
||||
return
|
||||
}
|
||||
|
||||
host.removeAttribute("data-color-scheme")
|
||||
}
|
||||
|
||||
export function observeViewerScheme(getHost: () => HTMLElement | undefined) {
|
||||
if (typeof document === "undefined") return () => {}
|
||||
|
||||
applyViewerScheme(getHost())
|
||||
if (typeof MutationObserver === "undefined") return () => {}
|
||||
|
||||
const root = document.documentElement
|
||||
const monitor = new MutationObserver(() => applyViewerScheme(getHost()))
|
||||
monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
|
||||
return () => monitor.disconnect()
|
||||
}
|
||||
|
||||
export function notifyShadowReady(opts: {
|
||||
state: ReadyWatcher
|
||||
container: HTMLElement
|
||||
getRoot: () => ShadowRoot | undefined
|
||||
isReady: (root: ShadowRoot) => boolean
|
||||
onReady: () => void
|
||||
settleFrames?: number
|
||||
}) {
|
||||
clearReadyWatcher(opts.state)
|
||||
opts.state.token += 1
|
||||
|
||||
const token = opts.state.token
|
||||
const settle = Math.max(0, opts.settleFrames ?? 0)
|
||||
|
||||
const runReady = () => {
|
||||
const step = (left: number) => {
|
||||
if (token !== opts.state.token) return
|
||||
if (left <= 0) {
|
||||
opts.onReady()
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => step(left - 1))
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => step(settle))
|
||||
}
|
||||
|
||||
const observeRoot = (root: ShadowRoot) => {
|
||||
if (opts.isReady(root)) {
|
||||
runReady()
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof MutationObserver === "undefined") return
|
||||
|
||||
clearReadyWatcher(opts.state)
|
||||
opts.state.observer = new MutationObserver(() => {
|
||||
if (token !== opts.state.token) return
|
||||
if (!opts.isReady(root)) return
|
||||
|
||||
clearReadyWatcher(opts.state)
|
||||
runReady()
|
||||
})
|
||||
opts.state.observer.observe(root, { childList: true, subtree: true })
|
||||
}
|
||||
|
||||
const root = opts.getRoot()
|
||||
if (!root) {
|
||||
if (typeof MutationObserver === "undefined") return
|
||||
|
||||
opts.state.observer = new MutationObserver(() => {
|
||||
if (token !== opts.state.token) return
|
||||
|
||||
const next = opts.getRoot()
|
||||
if (!next) return
|
||||
|
||||
observeRoot(next)
|
||||
})
|
||||
opts.state.observer.observe(opts.container, { childList: true, subtree: true })
|
||||
return
|
||||
}
|
||||
|
||||
observeRoot(root)
|
||||
}
|
||||
85
packages/ui/src/pierre/file-selection.ts
Normal file
85
packages/ui/src/pierre/file-selection.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
import { toRange } from "./selection-bridge"
|
||||
|
||||
export function findElement(node: Node | null): HTMLElement | undefined {
|
||||
if (!node) return
|
||||
if (node instanceof HTMLElement) return node
|
||||
return node.parentElement ?? undefined
|
||||
}
|
||||
|
||||
export function findFileLineNumber(node: Node | null): number | undefined {
|
||||
const el = findElement(node)
|
||||
if (!el) return
|
||||
|
||||
const line = el.closest("[data-line]")
|
||||
if (!(line instanceof HTMLElement)) return
|
||||
|
||||
const value = parseInt(line.dataset.line ?? "", 10)
|
||||
if (Number.isNaN(value)) return
|
||||
return value
|
||||
}
|
||||
|
||||
export function findDiffLineNumber(node: Node | null): number | undefined {
|
||||
const el = findElement(node)
|
||||
if (!el) return
|
||||
|
||||
const line = el.closest("[data-line], [data-alt-line]")
|
||||
if (!(line instanceof HTMLElement)) return
|
||||
|
||||
const primary = parseInt(line.dataset.line ?? "", 10)
|
||||
if (!Number.isNaN(primary)) return primary
|
||||
|
||||
const alt = parseInt(line.dataset.altLine ?? "", 10)
|
||||
if (!Number.isNaN(alt)) return alt
|
||||
}
|
||||
|
||||
export function findCodeSelectionSide(node: Node | null): SelectedLineRange["side"] {
|
||||
const el = findElement(node)
|
||||
if (!el) return
|
||||
|
||||
const code = el.closest("[data-code]")
|
||||
if (!(code instanceof HTMLElement)) return
|
||||
if (code.hasAttribute("data-deletions")) return "deletions"
|
||||
return "additions"
|
||||
}
|
||||
|
||||
export function readShadowLineSelection(opts: {
|
||||
root: ShadowRoot
|
||||
lineForNode: (node: Node | null) => number | undefined
|
||||
sideForNode?: (node: Node | null) => SelectedLineRange["side"]
|
||||
preserveTextSelection?: boolean
|
||||
}) {
|
||||
const selection =
|
||||
(opts.root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
|
||||
if (!selection || selection.isCollapsed) return
|
||||
|
||||
const domRange =
|
||||
(
|
||||
selection as unknown as {
|
||||
getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => StaticRange[]
|
||||
}
|
||||
).getComposedRanges?.({ shadowRoots: [opts.root] })?.[0] ??
|
||||
(selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
|
||||
|
||||
const startNode = domRange?.startContainer ?? selection.anchorNode
|
||||
const endNode = domRange?.endContainer ?? selection.focusNode
|
||||
if (!startNode || !endNode) return
|
||||
if (!opts.root.contains(startNode) || !opts.root.contains(endNode)) return
|
||||
|
||||
const start = opts.lineForNode(startNode)
|
||||
const end = opts.lineForNode(endNode)
|
||||
if (start === undefined || end === undefined) return
|
||||
|
||||
const startSide = opts.sideForNode?.(startNode)
|
||||
const endSide = opts.sideForNode?.(endNode)
|
||||
const side = startSide ?? endSide
|
||||
|
||||
const range: SelectedLineRange = { start, end }
|
||||
if (side) range.side = side
|
||||
if (endSide && side && endSide !== side) range.endSide = endSide
|
||||
|
||||
return {
|
||||
range,
|
||||
text: opts.preserveTextSelection && domRange ? toRange(domRange).cloneRange() : undefined,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs"
|
||||
import { ComponentProps } from "solid-js"
|
||||
import { lineCommentStyles } from "../components/line-comment-styles"
|
||||
|
||||
export type DiffProps<T = {}> = FileDiffOptions<T> & {
|
||||
before: FileContents
|
||||
@@ -7,13 +8,15 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
|
||||
annotations?: DiffLineAnnotation<T>[]
|
||||
selectedLines?: SelectedLineRange | null
|
||||
commentedLines?: SelectedLineRange[]
|
||||
onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
|
||||
onRendered?: () => void
|
||||
class?: string
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
}
|
||||
|
||||
const unsafeCSS = `
|
||||
[data-diff] {
|
||||
[data-diff],
|
||||
[data-file] {
|
||||
--diffs-bg: light-dark(var(--diffs-light-bg), var(--diffs-dark-bg));
|
||||
--diffs-bg-buffer: var(--diffs-bg-buffer-override, light-dark( color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer))));
|
||||
--diffs-bg-hover: var(--diffs-bg-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 97%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-mixer))));
|
||||
@@ -44,7 +47,8 @@ const unsafeCSS = `
|
||||
--diffs-bg-selection-text: rgb(from var(--surface-warning-strong) r g b / 0.2);
|
||||
}
|
||||
|
||||
:host([data-color-scheme='dark']) [data-diff] {
|
||||
:host([data-color-scheme='dark']) [data-diff],
|
||||
:host([data-color-scheme='dark']) [data-file] {
|
||||
--diffs-selection-number-fg: #fdfbfb;
|
||||
--diffs-bg-selection: var(--diffs-bg-selection-override, rgb(from var(--solaris-dark-6) r g b / 0.65));
|
||||
--diffs-bg-selection-number: var(
|
||||
@@ -53,7 +57,8 @@ const unsafeCSS = `
|
||||
);
|
||||
}
|
||||
|
||||
[data-diff] ::selection {
|
||||
[data-diff] ::selection,
|
||||
[data-file] ::selection {
|
||||
background-color: var(--diffs-bg-selection-text);
|
||||
}
|
||||
|
||||
@@ -69,25 +74,48 @@ const unsafeCSS = `
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
|
||||
}
|
||||
|
||||
[data-file] [data-line][data-comment-selected]:not([data-selected-line]) {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
|
||||
}
|
||||
|
||||
[data-diff] [data-column-number][data-comment-selected]:not([data-selected-line]) {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
[data-file] [data-column-number][data-comment-selected]:not([data-selected-line]) {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
[data-diff] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
|
||||
}
|
||||
|
||||
[data-file] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
|
||||
}
|
||||
|
||||
[data-diff] [data-line][data-selected-line] {
|
||||
background-color: var(--diffs-bg-selection);
|
||||
box-shadow: inset 2px 0 0 var(--diffs-selection-border);
|
||||
}
|
||||
|
||||
[data-file] [data-line][data-selected-line] {
|
||||
background-color: var(--diffs-bg-selection);
|
||||
box-shadow: inset 2px 0 0 var(--diffs-selection-border);
|
||||
}
|
||||
|
||||
[data-diff] [data-column-number][data-selected-line] {
|
||||
background-color: var(--diffs-bg-selection-number);
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
[data-file] [data-column-number][data-selected-line] {
|
||||
background-color: var(--diffs-bg-selection-number);
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
[data-diff] [data-column-number][data-line-type='context'][data-selected-line],
|
||||
[data-diff] [data-column-number][data-line-type='context-expanded'][data-selected-line],
|
||||
[data-diff] [data-column-number][data-line-type='change-addition'][data-selected-line],
|
||||
@@ -123,9 +151,13 @@ const unsafeCSS = `
|
||||
}
|
||||
[data-code] {
|
||||
overflow-x: auto !important;
|
||||
overflow-y: hidden !important;
|
||||
overflow-y: clip !important;
|
||||
}
|
||||
}`
|
||||
}
|
||||
|
||||
${lineCommentStyles}
|
||||
|
||||
`
|
||||
|
||||
export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) {
|
||||
return {
|
||||
|
||||
110
packages/ui/src/pierre/media.ts
Normal file
110
packages/ui/src/pierre/media.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export type MediaKind = "image" | "audio" | "svg"
|
||||
|
||||
const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"])
|
||||
const audioExtensions = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"])
|
||||
|
||||
type MediaValue = unknown
|
||||
|
||||
function mediaRecord(value: unknown) {
|
||||
if (!value || typeof value !== "object") return
|
||||
return value as Partial<FileContent> & {
|
||||
content?: unknown
|
||||
encoding?: unknown
|
||||
mimeType?: unknown
|
||||
type?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeMimeType(type: string | undefined) {
|
||||
if (!type) return
|
||||
const mime = type.split(";", 1)[0]?.trim().toLowerCase()
|
||||
if (!mime) return
|
||||
if (mime === "audio/x-aac") return "audio/aac"
|
||||
if (mime === "audio/x-m4a") return "audio/mp4"
|
||||
return mime
|
||||
}
|
||||
|
||||
export function fileExtension(path: string | undefined) {
|
||||
if (!path) return ""
|
||||
const idx = path.lastIndexOf(".")
|
||||
if (idx === -1) return ""
|
||||
return path.slice(idx + 1).toLowerCase()
|
||||
}
|
||||
|
||||
export function mediaKindFromPath(path: string | undefined): MediaKind | undefined {
|
||||
const ext = fileExtension(path)
|
||||
if (ext === "svg") return "svg"
|
||||
if (imageExtensions.has(ext)) return "image"
|
||||
if (audioExtensions.has(ext)) return "audio"
|
||||
}
|
||||
|
||||
export function isBinaryContent(value: MediaValue) {
|
||||
return mediaRecord(value)?.type === "binary"
|
||||
}
|
||||
|
||||
function validDataUrl(value: string, kind: MediaKind) {
|
||||
if (kind === "svg") return value.startsWith("data:image/svg+xml") ? value : undefined
|
||||
if (kind === "image") return value.startsWith("data:image/") ? value : undefined
|
||||
if (value.startsWith("data:audio/x-aac;")) return value.replace("data:audio/x-aac;", "data:audio/aac;")
|
||||
if (value.startsWith("data:audio/x-m4a;")) return value.replace("data:audio/x-m4a;", "data:audio/mp4;")
|
||||
if (value.startsWith("data:audio/")) return value
|
||||
}
|
||||
|
||||
export function dataUrlFromMediaValue(value: MediaValue, kind: MediaKind) {
|
||||
if (!value) return
|
||||
|
||||
if (typeof value === "string") {
|
||||
return validDataUrl(value, kind)
|
||||
}
|
||||
|
||||
const record = mediaRecord(value)
|
||||
if (!record) return
|
||||
|
||||
if (typeof record.content !== "string") return
|
||||
|
||||
const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined)
|
||||
if (!mime) return
|
||||
|
||||
if (kind === "svg") {
|
||||
if (mime !== "image/svg+xml") return
|
||||
if (record.encoding === "base64") return `data:image/svg+xml;base64,${record.content}`
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(record.content)}`
|
||||
}
|
||||
|
||||
if (kind === "image" && !mime.startsWith("image/")) return
|
||||
if (kind === "audio" && !mime.startsWith("audio/")) return
|
||||
if (record.encoding !== "base64") return
|
||||
|
||||
return `data:${mime};base64,${record.content}`
|
||||
}
|
||||
|
||||
function decodeBase64Utf8(value: string) {
|
||||
if (typeof atob !== "function") return
|
||||
|
||||
try {
|
||||
const raw = atob(value)
|
||||
const bytes = Uint8Array.from(raw, (x) => x.charCodeAt(0))
|
||||
if (typeof TextDecoder === "function") return new TextDecoder().decode(bytes)
|
||||
return raw
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function svgTextFromValue(value: MediaValue) {
|
||||
const record = mediaRecord(value)
|
||||
if (!record) return
|
||||
if (typeof record.content !== "string") return
|
||||
|
||||
const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined)
|
||||
if (mime !== "image/svg+xml") return
|
||||
if (record.encoding === "base64") return decodeBase64Utf8(record.content)
|
||||
return record.content
|
||||
}
|
||||
|
||||
export function hasMediaValue(value: MediaValue) {
|
||||
if (typeof value === "string") return value.length > 0
|
||||
const record = mediaRecord(value)
|
||||
if (!record) return false
|
||||
return typeof record.content === "string" && record.content.length > 0
|
||||
}
|
||||
129
packages/ui/src/pierre/selection-bridge.ts
Normal file
129
packages/ui/src/pierre/selection-bridge.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
|
||||
type PointerMode = "none" | "text" | "numbers"
|
||||
type Side = SelectedLineRange["side"]
|
||||
type LineSpan = Pick<SelectedLineRange, "start" | "end">
|
||||
|
||||
export function formatSelectedLineLabel(range: LineSpan) {
|
||||
const start = Math.min(range.start, range.end)
|
||||
const end = Math.max(range.start, range.end)
|
||||
if (start === end) return `line ${start}`
|
||||
return `lines ${start}-${end}`
|
||||
}
|
||||
|
||||
export function previewSelectedLines(source: string, range: LineSpan) {
|
||||
const start = Math.max(1, Math.min(range.start, range.end))
|
||||
const end = Math.max(range.start, range.end)
|
||||
const lines = source.split("\n").slice(start - 1, end)
|
||||
if (lines.length === 0) return
|
||||
return lines.slice(0, 2).join("\n")
|
||||
}
|
||||
|
||||
export function cloneSelectedLineRange(range: SelectedLineRange): SelectedLineRange {
|
||||
const next: SelectedLineRange = {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
}
|
||||
|
||||
if (range.side) next.side = range.side
|
||||
if (range.endSide) next.endSide = range.endSide
|
||||
return next
|
||||
}
|
||||
|
||||
export function lineInSelectedRange(range: SelectedLineRange | null | undefined, line: number, side?: Side) {
|
||||
if (!range) return false
|
||||
|
||||
const start = Math.min(range.start, range.end)
|
||||
const end = Math.max(range.start, range.end)
|
||||
if (line < start || line > end) return false
|
||||
if (!side) return true
|
||||
|
||||
const first = range.side
|
||||
const last = range.endSide ?? first
|
||||
if (!first && !last) return true
|
||||
if (!first || !last) return (first ?? last) === side
|
||||
if (first === last) return first === side
|
||||
if (line === start) return first === side
|
||||
if (line === end) return last === side
|
||||
return true
|
||||
}
|
||||
|
||||
export function isSingleLineSelection(range: SelectedLineRange | null) {
|
||||
if (!range) return false
|
||||
return range.start === range.end && (range.endSide == null || range.endSide === range.side)
|
||||
}
|
||||
|
||||
export function toRange(source: Range | StaticRange): Range {
|
||||
if (source instanceof Range) return source
|
||||
const range = new Range()
|
||||
range.setStart(source.startContainer, source.startOffset)
|
||||
range.setEnd(source.endContainer, source.endOffset)
|
||||
return range
|
||||
}
|
||||
|
||||
export function restoreShadowTextSelection(root: ShadowRoot | undefined, range: Range | undefined) {
|
||||
if (!root || !range) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const selection =
|
||||
(root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
try {
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
} catch {}
|
||||
})
|
||||
}
|
||||
|
||||
export function createLineNumberSelectionBridge() {
|
||||
let mode: PointerMode = "none"
|
||||
let line: number | undefined
|
||||
let moved = false
|
||||
let pending = false
|
||||
|
||||
const clear = () => {
|
||||
mode = "none"
|
||||
line = undefined
|
||||
moved = false
|
||||
}
|
||||
|
||||
return {
|
||||
begin(numberColumn: boolean, next: number | undefined) {
|
||||
if (!numberColumn) {
|
||||
mode = "text"
|
||||
return
|
||||
}
|
||||
|
||||
mode = "numbers"
|
||||
line = next
|
||||
moved = false
|
||||
},
|
||||
track(buttons: number, next: number | undefined) {
|
||||
if (mode !== "numbers") return false
|
||||
|
||||
if ((buttons & 1) === 0) {
|
||||
clear()
|
||||
return true
|
||||
}
|
||||
|
||||
if (next !== undefined && line !== undefined && next !== line) moved = true
|
||||
return true
|
||||
},
|
||||
finish() {
|
||||
const current = mode
|
||||
pending = current === "numbers" && moved
|
||||
clear()
|
||||
return current
|
||||
},
|
||||
consume(range: SelectedLineRange | null) {
|
||||
const result = pending && !isSingleLineSelection(range)
|
||||
pending = false
|
||||
return result
|
||||
},
|
||||
reset() {
|
||||
pending = false
|
||||
clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,8 @@
|
||||
@import "../components/button.css" layer(components);
|
||||
@import "../components/card.css" layer(components);
|
||||
@import "../components/checkbox.css" layer(components);
|
||||
@import "../components/code.css" layer(components);
|
||||
@import "../components/file.css" layer(components);
|
||||
@import "../components/collapsible.css" layer(components);
|
||||
@import "../components/diff.css" layer(components);
|
||||
@import "../components/diff-changes.css" layer(components);
|
||||
@import "../components/context-menu.css" layer(components);
|
||||
@import "../components/dropdown-menu.css" layer(components);
|
||||
@@ -28,7 +27,6 @@
|
||||
@import "../components/icon-button.css" layer(components);
|
||||
@import "../components/image-preview.css" layer(components);
|
||||
@import "../components/keybind.css" layer(components);
|
||||
@import "../components/line-comment.css" layer(components);
|
||||
@import "../components/text-field.css" layer(components);
|
||||
@import "../components/inline-input.css" layer(components);
|
||||
@import "../components/list.css" layer(components);
|
||||
|
||||
426
specs/file-component-unification-plan.md
Normal file
426
specs/file-component-unification-plan.md
Normal file
@@ -0,0 +1,426 @@
|
||||
# File Component Unification Plan
|
||||
|
||||
Single path for text, diff, and media
|
||||
|
||||
---
|
||||
|
||||
## Define goal
|
||||
|
||||
Introduce one public UI component API that renders plain text files or diffs from the same entry point, so selection, comments, search, theming, and media behavior are maintained once.
|
||||
|
||||
### Goal
|
||||
|
||||
- Add a unified `File` component in `packages/ui/src/components/file.tsx` that chooses plain or diff rendering from props.
|
||||
- Centralize shared behavior now split between `packages/ui/src/components/code.tsx` and `packages/ui/src/components/diff.tsx`.
|
||||
- Bring the existing find/search UX to diff rendering through a shared engine.
|
||||
- Consolidate media rendering logic currently split across `packages/ui/src/components/session-review.tsx` and `packages/app/src/pages/session/file-tabs.tsx`.
|
||||
- Provide a clear SSR path for preloaded diffs without keeping a third independent implementation.
|
||||
|
||||
### Non-goal
|
||||
|
||||
- Do not change `@pierre/diffs` behavior or fork its internals.
|
||||
- Do not redesign line comment UX, diff visuals, or keyboard shortcuts.
|
||||
- Do not remove legacy `Code`/`Diff` APIs in the first pass.
|
||||
- Do not add new media types beyond parity unless explicitly approved.
|
||||
- Do not refactor unrelated session review or file tab layout code outside integration points.
|
||||
|
||||
---
|
||||
|
||||
## Audit duplication
|
||||
|
||||
The current split duplicates runtime logic and makes feature parity drift likely.
|
||||
|
||||
### Duplicate categories
|
||||
|
||||
- Rendering lifecycle is duplicated in `code.tsx` and `diff.tsx`, including instance creation, cleanup, `onRendered` readiness, and shadow root lookup.
|
||||
- Theme sync is duplicated in `code.tsx`, `diff.tsx`, and `diff-ssr.tsx` through similar `applyScheme` and `MutationObserver` code.
|
||||
- Line selection wiring is duplicated in `code.tsx` and `diff.tsx`, including drag state, shadow selection reads, and line-number bridge integration.
|
||||
- Comment annotation rerender flow is duplicated in `code.tsx`, `diff.tsx`, and `diff-ssr.tsx`.
|
||||
- Commented line marking is split across `markCommentedFileLines` and `markCommentedDiffLines`, with similar timing and effect wiring.
|
||||
- Diff selection normalization (`fixSelection`) exists twice in `diff.tsx` and `diff-ssr.tsx`.
|
||||
- Search exists only in `code.tsx`, so diff lacks find and the feature cannot be maintained in one place.
|
||||
- Contexts are split (`context/code.tsx`, `context/diff.tsx`), which forces consumers to choose paths early.
|
||||
- Media rendering is duplicated outside the core viewers in `session-review.tsx` and `file-tabs.tsx`.
|
||||
|
||||
### Drift pain points
|
||||
|
||||
- Any change to comments, theming, or selection requires touching multiple files.
|
||||
- Diff SSR and client diff can drift because they carry separate normalization and marking code.
|
||||
- Search cannot be added to diff cleanly without more duplication unless the viewer runtime is unified.
|
||||
|
||||
---
|
||||
|
||||
## Design architecture
|
||||
|
||||
Use one public component with a discriminated prop shape and split shared behavior into small runtime modules.
|
||||
|
||||
### Public API proposal
|
||||
|
||||
- Add `packages/ui/src/components/file.tsx` as the primary client entry point.
|
||||
- Export a single `File` component that accepts a discriminated union with two primary modes.
|
||||
- Use an explicit `mode` prop (`"text"` or `"diff"`) to avoid ambiguous prop inference and keep type errors clear.
|
||||
|
||||
### Proposed prop shape
|
||||
|
||||
- Shared props:
|
||||
- `annotations`
|
||||
- `selectedLines`
|
||||
- `commentedLines`
|
||||
- `onLineSelected`
|
||||
- `onLineSelectionEnd`
|
||||
- `onLineNumberSelectionEnd`
|
||||
- `onRendered`
|
||||
- `class`
|
||||
- `classList`
|
||||
- selection and hover flags already supported by current viewers
|
||||
- Text mode props:
|
||||
- `mode: "text"`
|
||||
- `file` (`FileContents`)
|
||||
- text renderer options from `@pierre/diffs` `FileOptions`
|
||||
- Diff mode props:
|
||||
- `mode: "diff"`
|
||||
- `before`
|
||||
- `after`
|
||||
- `diffStyle`
|
||||
- diff renderer options from `FileDiffOptions`
|
||||
- optional `preloadedDiff` only for SSR-aware entry or hydration adapter
|
||||
- Media props (shared, optional):
|
||||
- `media` config for `"auto" | "off"` behavior
|
||||
- path/name metadata
|
||||
- optional lazy loader (`readFile`) for session review use
|
||||
- optional custom placeholders for binary or removed content
|
||||
|
||||
### Internal module split
|
||||
|
||||
- `packages/ui/src/components/file.tsx`
|
||||
Public unified component and mode routing.
|
||||
- `packages/ui/src/components/file-ssr.tsx`
|
||||
Unified SSR entry for preloaded diff hydration.
|
||||
- `packages/ui/src/components/file-search.tsx`
|
||||
Shared find bar UI and host registration.
|
||||
- `packages/ui/src/components/file-media.tsx`
|
||||
Shared image/audio/svg/binary rendering shell.
|
||||
- `packages/ui/src/pierre/file-runtime.ts`
|
||||
Common render lifecycle, instance setup, cleanup, scheme sync, and readiness notification.
|
||||
- `packages/ui/src/pierre/file-selection.ts`
|
||||
Shared selection/drag/line-number bridge controller with mode adapters.
|
||||
- `packages/ui/src/pierre/diff-selection.ts`
|
||||
Diff-specific `fixSelection` and row/side normalization reused by client and SSR.
|
||||
- `packages/ui/src/pierre/file-find.ts`
|
||||
Shared find engine (scan, highlight API, overlay fallback, match navigation).
|
||||
- `packages/ui/src/pierre/media.ts`
|
||||
MIME normalization, data URL helpers, and media type detection.
|
||||
|
||||
### Wrapper strategy
|
||||
|
||||
- Keep `packages/ui/src/components/code.tsx` as a thin compatibility wrapper over unified `File` in text mode.
|
||||
- Keep `packages/ui/src/components/diff.tsx` as a thin compatibility wrapper over unified `File` in diff mode.
|
||||
- Keep `packages/ui/src/components/diff-ssr.tsx` as a thin compatibility wrapper over unified SSR entry.
|
||||
|
||||
---
|
||||
|
||||
## Phase delivery
|
||||
|
||||
Ship this in small phases so each step is reviewable and reversible.
|
||||
|
||||
### Phase 0: Align interfaces
|
||||
|
||||
- Document the final prop contract and adapter behavior before moving logic.
|
||||
- Add a short migration note in the plan PR description so reviewers know wrappers stay in place.
|
||||
|
||||
#### Acceptance
|
||||
|
||||
- Final prop names and mode shape are agreed up front.
|
||||
- No runtime code changes land yet.
|
||||
|
||||
### Phase 1: Extract shared runtime pieces
|
||||
|
||||
- Move duplicated theme sync and render readiness logic from `code.tsx` and `diff.tsx` into shared runtime helpers.
|
||||
- Move diff selection normalization (`fixSelection` and helpers) out of both `diff.tsx` and `diff-ssr.tsx` into `packages/ui/src/pierre/diff-selection.ts`.
|
||||
- Extract shared selection controller flow into `packages/ui/src/pierre/file-selection.ts` with mode callbacks for line parsing and normalization.
|
||||
- Keep `code.tsx`, `diff.tsx`, and `diff-ssr.tsx` behavior unchanged from the outside.
|
||||
|
||||
#### Acceptance
|
||||
|
||||
- `code.tsx`, `diff.tsx`, and `diff-ssr.tsx` are smaller and call shared helpers.
|
||||
- Line selection, comments, and theme sync still work in current consumers.
|
||||
- No consumer imports change yet.
|
||||
|
||||
### Phase 2: Introduce unified client entry
|
||||
|
||||
- Create `packages/ui/src/components/file.tsx` and wire it to shared runtime pieces.
|
||||
- Route text mode to `@pierre/diffs` `File` or `VirtualizedFile` and diff mode to `FileDiff` or `VirtualizedFileDiff`.
|
||||
- Preserve current performance rules, including virtualization thresholds and large-diff options.
|
||||
- Keep search out of this phase if it risks scope creep, but leave extension points in place.
|
||||
|
||||
#### Acceptance
|
||||
|
||||
- New unified component renders text and diff with parity to existing components.
|
||||
- `code.tsx` and `diff.tsx` can be rewritten as thin adapters without behavior changes.
|
||||
- Existing consumers still work through old `Code` and `Diff` exports.
|
||||
|
||||
### Phase 3: Add unified context path
|
||||
|
||||
- Add `packages/ui/src/context/file.tsx` with `FileComponentProvider` and `useFileComponent`.
|
||||
- Update `packages/ui/src/context/index.ts` to export the new context.
|
||||
- Keep `context/code.tsx` and `context/diff.tsx` as compatibility shims that adapt to `useFileComponent`.
|
||||
- Migrate `packages/app/src/app.tsx` and `packages/enterprise/src/routes/share/[shareID].tsx` to provide the unified component once wrappers are stable.
|
||||
|
||||
#### Acceptance
|
||||
|
||||
- New consumers can use one context path.
|
||||
- Existing `useCodeComponent` and `useDiffComponent` hooks still resolve and render correctly.
|
||||
- Provider wiring in app and enterprise stays compatible during transition.
|
||||
|
||||
### Phase 4: Share find and enable diff search
|
||||
|
||||
- Extract the find engine and find bar UI from `code.tsx` into shared modules.
|
||||
- Hook the shared find host into unified `File` for both text and diff modes.
|
||||
- Keep current shortcuts (`Ctrl/Cmd+F`, `Ctrl/Cmd+G`, `Shift+Ctrl/Cmd+G`) and active-host behavior.
|
||||
- Preserve CSS Highlight API support with overlay fallback.
|
||||
|
||||
#### Acceptance
|
||||
|
||||
- Text mode search behaves the same as today.
|
||||
- Diff mode now supports the same find UI and shortcuts.
|
||||
- Multiple viewer instances still route shortcuts to the focused/active host correctly.
|
||||
|
||||
### Phase 5: Consolidate media rendering
|
||||
|
||||
- Extract media type detection and data URL helpers from `session-review.tsx` and `file-tabs.tsx` into shared UI helpers.
|
||||
- Add `file-media.tsx` and let unified `File` optionally render media or binary placeholders before falling back to text/diff.
|
||||
- Migrate `session-review.tsx` and `file-tabs.tsx` to pass media props instead of owning media-specific branches.
|
||||
- Keep session-specific layout and i18n strings in the consumer where they are not generic.
|
||||
|
||||
#### Acceptance
|
||||
|
||||
- Image/audio/svg/binary handling no longer duplicates core detection and load state logic.
|
||||
- Session review and file tabs still render the same media states and placeholders.
|
||||
- Text/diff comment and selection behavior is unchanged when media is not shown.
|
||||
|
||||
### Phase 6: Align SSR and preloaded diffs
|
||||
|
||||
- Create `packages/ui/src/components/file-ssr.tsx` with the same unified prop shape plus `preloadedDiff`.
|
||||
- Reuse shared diff normalization, theme sync, and commented-line marking helpers.
|
||||
- Convert `packages/ui/src/components/diff-ssr.tsx` into a thin adapter that forwards to the unified SSR entry in diff mode.
|
||||
- Migrate enterprise share page imports to `@opencode-ai/ui/file-ssr` when convenient, but keep `diff-ssr` export working.
|
||||
|
||||
#### Acceptance
|
||||
|
||||
- Preloaded diff hydration still works in `packages/enterprise/src/routes/share/[shareID].tsx`.
|
||||
- SSR diff and client diff now share normalization and comment marking helpers.
|
||||
- No duplicate `fixSelection` implementation remains.
|
||||
|
||||
### Phase 7: Clean up and document
|
||||
|
||||
- Remove dead internal helpers left behind in `code.tsx` and `diff.tsx`.
|
||||
- Add a short migration doc for downstream consumers that want to switch from `Code`/`Diff` to unified `File`.
|
||||
- Mark `Code`/`Diff` contexts and components as compatibility APIs in comments or docs.
|
||||
|
||||
#### Acceptance
|
||||
|
||||
- No stale duplicate helpers remain in legacy wrappers.
|
||||
- Unified path is the default recommendation for new UI work.
|
||||
|
||||
---
|
||||
|
||||
## Preserve compatibility
|
||||
|
||||
Keep old APIs working while moving internals under them.
|
||||
|
||||
### Context migration strategy
|
||||
|
||||
- Introduce `FileComponentProvider` without deleting `CodeComponentProvider` or `DiffComponentProvider`.
|
||||
- Implement `useCodeComponent` and `useDiffComponent` as adapters around the unified context where possible.
|
||||
- If full adapter reuse is messy at first, keep old contexts and providers as thin wrappers that internally provide mapped unified props.
|
||||
|
||||
### Consumer migration targets
|
||||
|
||||
- `packages/app/src/pages/session/file-tabs.tsx` should move from `useCodeComponent` to `useFileComponent`.
|
||||
- `packages/ui/src/components/session-review.tsx`, `session-turn.tsx`, and `message-part.tsx` should move from `useDiffComponent` to `useFileComponent`.
|
||||
- `packages/app/src/app.tsx` and `packages/enterprise/src/routes/share/[shareID].tsx` should eventually provide only the unified provider.
|
||||
- Keep legacy hooks available until all call sites are migrated and reviewed.
|
||||
|
||||
### Compatibility checkpoints
|
||||
|
||||
- `@opencode-ai/ui/code`, `@opencode-ai/ui/diff`, and `@opencode-ai/ui/diff-ssr` imports must keep working during migration.
|
||||
- Existing prop names on `Code` and `Diff` wrappers should remain stable to avoid broad app changes in one PR.
|
||||
|
||||
---
|
||||
|
||||
## Unify search
|
||||
|
||||
Port the current find feature into a shared engine and attach it to both modes.
|
||||
|
||||
### Shared engine plan
|
||||
|
||||
- Move keyboard host registry and active-target logic out of `code.tsx` into `packages/ui/src/pierre/file-find.ts`.
|
||||
- Move the find bar UI into `packages/ui/src/components/file-search.tsx`.
|
||||
- Keep DOM-based scanning and highlight/overlay rendering shared, since both text and diff render into the same shadow-root patterns.
|
||||
|
||||
### Diff-specific handling
|
||||
|
||||
- Search should scan both unified and split diff columns through the same selectors used in the current code find feature.
|
||||
- Match navigation should scroll the active range into view without interfering with line selection state.
|
||||
- Search refresh should run after `onRendered`, diff style changes, annotation rerenders, and query changes.
|
||||
|
||||
### Scope guard
|
||||
|
||||
- Preserve the current DOM-scan behavior first, even if virtualized search is limited to mounted rows.
|
||||
- If full-document virtualized search is required, treat it as a follow-up with a text-index layer rather than blocking the core refactor.
|
||||
|
||||
---
|
||||
|
||||
## Consolidate media
|
||||
|
||||
Move media rendering logic into shared UI so text, diff, and media routing live behind one entry.
|
||||
|
||||
### Ownership plan
|
||||
|
||||
- Put media detection and normalization helpers in `packages/ui/src/pierre/media.ts`.
|
||||
- Put shared rendering UI in `packages/ui/src/components/file-media.tsx`.
|
||||
- Keep layout-specific wrappers in `session-review.tsx` and `file-tabs.tsx`, but remove duplicated media branching and load-state code from them.
|
||||
|
||||
### Proposed media props
|
||||
|
||||
- `media.mode`: `"auto"` or `"off"` for default behavior.
|
||||
- `media.path`: file path for extension checks and labels.
|
||||
- `media.current`: loaded file content for plain-file views.
|
||||
- `media.before` and `media.after`: diff-side values for image/audio previews.
|
||||
- `media.readFile`: optional lazy loader for session review expansion.
|
||||
- `media.renderBinaryPlaceholder`: optional consumer override for binary states.
|
||||
- `media.renderLoading` and `media.renderError`: optional consumer overrides when generic text is not enough.
|
||||
|
||||
### Parity targets
|
||||
|
||||
- Keep current image and audio support from session review.
|
||||
- Keep current SVG and binary handling from file tabs.
|
||||
- Defer video or PDF support unless explicitly requested.
|
||||
|
||||
---
|
||||
|
||||
## Align SSR
|
||||
|
||||
Make SSR diff hydration a mode of the unified viewer instead of a parallel implementation.
|
||||
|
||||
### SSR plan
|
||||
|
||||
- Add `packages/ui/src/components/file-ssr.tsx` as the unified SSR entry with a diff-only path in phase one.
|
||||
- Reuse shared diff helpers for `fixSelection`, theme sync, and commented-line marking.
|
||||
- Keep the private `fileContainer` hydration workaround isolated in the SSR module so client code stays clean.
|
||||
|
||||
### Integration plan
|
||||
|
||||
- Keep `packages/ui/src/components/diff-ssr.tsx` as a forwarding adapter for compatibility.
|
||||
- Update enterprise share route to the unified SSR import after client and context migrations are stable.
|
||||
- Align prop names with the client `File` component so `SessionReview` can swap client/SSR providers without branching logic.
|
||||
|
||||
### Defer item
|
||||
|
||||
- Plain-file SSR hydration is not needed for this refactor and can stay out of scope.
|
||||
|
||||
---
|
||||
|
||||
## Verify behavior
|
||||
|
||||
Use typechecks and targeted UI checks after each phase, and avoid repo-root runs.
|
||||
|
||||
### Typecheck plan
|
||||
|
||||
- Run `bun run typecheck` from `packages/ui` after phases 1-7 changes there.
|
||||
- Run `bun run typecheck` from `packages/app` after migrating file tabs or app provider wiring.
|
||||
- Run `bun run typecheck` from `packages/enterprise` after SSR/provider changes on the share route.
|
||||
|
||||
### Targeted UI checks
|
||||
|
||||
- Text mode:
|
||||
- small file render
|
||||
- virtualized large file render
|
||||
- drag selection and line-number selection
|
||||
- comment annotations and commented-line marks
|
||||
- find shortcuts and match navigation
|
||||
- Diff mode:
|
||||
- unified and split styles
|
||||
- large diff fallback options
|
||||
- diff selection normalization across sides
|
||||
- comments and commented-line marks
|
||||
- new find UX parity
|
||||
- Media:
|
||||
- image, audio, SVG, and binary states in file tabs
|
||||
- image and audio diff previews in session review
|
||||
- lazy load and error placeholders
|
||||
- SSR:
|
||||
- enterprise share page preloaded diffs hydrate correctly
|
||||
- theme switching still updates hydrated diffs
|
||||
|
||||
### Regression focus
|
||||
|
||||
- Watch scroll restore behavior in `packages/app/src/pages/session/file-tabs.tsx`.
|
||||
- Watch multi-instance find shortcut routing in screens with many viewers.
|
||||
- Watch cleanup paths for listeners and virtualizers to avoid leaks.
|
||||
|
||||
---
|
||||
|
||||
## Manage risk
|
||||
|
||||
Keep wrappers and adapters in place until the unified path is proven.
|
||||
|
||||
### Key risks
|
||||
|
||||
- Selection regressions are the highest risk because text and diff have similar but not identical line semantics.
|
||||
- SSR hydration can break subtly if client and SSR prop shapes drift.
|
||||
- Shared find host state can misroute shortcuts when many viewers are mounted.
|
||||
- Media consolidation can accidentally change placeholder timing or load behavior.
|
||||
|
||||
### Rollback strategy
|
||||
|
||||
- Land each phase in separate PRs or clearly separated commits on `dev`.
|
||||
- If a phase regresses behavior, revert only that phase and keep earlier extractions.
|
||||
- Keep `code.tsx`, `diff.tsx`, and `diff-ssr.tsx` wrappers intact until final verification, so a rollback only changes internals.
|
||||
- If diff search is unstable, disable it behind the unified component while keeping the rest of the refactor.
|
||||
|
||||
---
|
||||
|
||||
## Order implementation
|
||||
|
||||
Follow this sequence to keep reviews small and reduce merge risk.
|
||||
|
||||
1. Finalize prop shape and file names for the unified component and context.
|
||||
2. Extract shared diff normalization, theme sync, and render-ready helpers with no public API changes.
|
||||
3. Extract shared selection controller and migrate `code.tsx` and `diff.tsx` to it.
|
||||
4. Add the unified client `File` component and convert `code.tsx`/`diff.tsx` into wrappers.
|
||||
5. Add `FileComponentProvider` and migrate provider wiring in `app.tsx` and enterprise share route.
|
||||
6. Migrate consumer hooks (`file-tabs`, `session-review`, `message-part`, `session-turn`) to the unified context.
|
||||
7. Extract and share find engine/UI, then enable search in diff mode.
|
||||
8. Extract media helpers/UI and migrate `session-review.tsx` and `file-tabs.tsx`.
|
||||
9. Add unified `file-ssr.tsx`, convert `diff-ssr.tsx` to a wrapper, and migrate enterprise imports.
|
||||
10. Remove dead duplication and write a short migration note for future consumers.
|
||||
|
||||
---
|
||||
|
||||
## Decide open items
|
||||
|
||||
Resolve these before coding to avoid rework mid-refactor.
|
||||
|
||||
### API decisions
|
||||
|
||||
- Should the unified component require `mode`, or should it infer mode from props for convenience.
|
||||
- Should the public export be named `File` only, or also ship a temporary alias like `UnifiedFile` for migration clarity.
|
||||
- Should `preloadedDiff` live on the main `File` props or only on `file-ssr.tsx`.
|
||||
|
||||
### Search decisions
|
||||
|
||||
- Is DOM-only search acceptable for virtualized content in the first pass.
|
||||
- Should find state reset on every rerender, or preserve query and index across diff style toggles.
|
||||
|
||||
### Media decisions
|
||||
|
||||
- Which placeholders and strings should stay consumer-owned versus shared in UI.
|
||||
- Whether SVG should be treated as media-only, text-only, or a mixed mode with both preview and source.
|
||||
- Whether video support should be included now or explicitly deferred.
|
||||
|
||||
### Migration decisions
|
||||
|
||||
- How long `CodeComponentProvider` and `DiffComponentProvider` should remain supported.
|
||||
- Whether to migrate all consumers in one PR after wrappers land, or in follow-up PRs by surface area.
|
||||
- Whether `diff-ssr` should remain as a permanent alias for compatibility.
|
||||
234
specs/session-review-cross-diff-search-plan.md
Normal file
234
specs/session-review-cross-diff-search-plan.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# Session Review Cross-Diff Search Plan
|
||||
|
||||
One search input for all diffs in the review pane
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add a single search UI to `SessionReview` that searches across all diff files in the accordion and supports next/previous navigation across files.
|
||||
|
||||
Navigation should auto-open the target accordion item and reveal the active match inside the existing unified `File` diff viewer.
|
||||
|
||||
---
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not change diff rendering visuals, line comments, or file selection behavior.
|
||||
- Do not add regex, fuzzy search, or replace.
|
||||
- Do not change `@pierre/diffs` internals.
|
||||
|
||||
---
|
||||
|
||||
## Current behavior
|
||||
|
||||
- `SessionReview` renders one `File` diff viewer per accordion item, but only mounts the viewer when that item is expanded.
|
||||
- Large diffs may be blocked behind the `MAX_DIFF_CHANGED_LINES` gate until the user clicks "render anyway".
|
||||
- `File` owns a local search engine (`createFileFind`) with:
|
||||
- query state
|
||||
- hit counting
|
||||
- current match index
|
||||
- highlighting (CSS Highlight API or overlay fallback)
|
||||
- `Cmd/Ctrl+F` and `Cmd/Ctrl+G` keyboard handling
|
||||
- `FileSearchBar` is currently rendered per viewer.
|
||||
- There is no parent-level search state in `SessionReview`.
|
||||
|
||||
---
|
||||
|
||||
## UX requirements
|
||||
|
||||
- Add one search bar in the `SessionReview` header (input, total count, prev, next, close).
|
||||
- Show a global count like `3/17` across all searchable diffs.
|
||||
- `Cmd/Ctrl+F` inside the session review pane opens the session-level search.
|
||||
- `Cmd/Ctrl+G`, `Shift+Cmd/Ctrl+G`, `Enter`, and `Shift+Enter` navigate globally.
|
||||
- Navigating to a match in a collapsed file auto-expands that file.
|
||||
- The active match scrolls into view and is highlighted in the target viewer.
|
||||
- Media/binary diffs are excluded from search.
|
||||
- Empty query clears highlights and resets to `0/0`.
|
||||
|
||||
---
|
||||
|
||||
## Architecture proposal
|
||||
|
||||
Use a hybrid model:
|
||||
|
||||
- A **session-level match index** for global searching/counting/navigation across all diffs.
|
||||
- The existing **per-viewer search engine** for local highlighting and scrolling in the active file.
|
||||
|
||||
This avoids mounting every accordion item just to search while reusing the existing DOM highlight behavior.
|
||||
|
||||
### High-level pieces
|
||||
|
||||
- `SessionReview` owns the global query, hit list, and active hit index.
|
||||
- `File` exposes a small controlled search handle (register, set query, clear, reveal hit).
|
||||
- `SessionReview` keeps a map of mounted file viewers and their search handles.
|
||||
- `SessionReview` resolves next/prev hits, expands files as needed, then tells the target viewer to reveal the hit.
|
||||
|
||||
---
|
||||
|
||||
## Data model and interfaces
|
||||
|
||||
```ts
|
||||
type SessionSearchHit = {
|
||||
file: string
|
||||
side: "additions" | "deletions"
|
||||
line: number
|
||||
col: number
|
||||
len: number
|
||||
}
|
||||
|
||||
type SessionSearchState = {
|
||||
query: string
|
||||
hits: SessionSearchHit[]
|
||||
active: number
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
type FileSearchReveal = {
|
||||
side: "additions" | "deletions"
|
||||
line: number
|
||||
col: number
|
||||
len: number
|
||||
}
|
||||
|
||||
type FileSearchHandle = {
|
||||
setQuery: (value: string) => void
|
||||
clear: () => void
|
||||
reveal: (hit: FileSearchReveal) => boolean
|
||||
refresh: () => void
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
type FileSearchControl = {
|
||||
shortcuts?: "global" | "disabled"
|
||||
showBar?: boolean
|
||||
register: (handle: FileSearchHandle | null) => void
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration steps
|
||||
|
||||
### Phase 1: Expose controlled search on `File`
|
||||
|
||||
- Extend `createFileFind` and `File` to support a controlled search handle.
|
||||
- Keep existing per-viewer search behavior as the default path.
|
||||
- Add a way to disable per-viewer global shortcuts when hosted inside `SessionReview`.
|
||||
|
||||
#### Acceptance
|
||||
|
||||
- `File` still supports local search unchanged by default.
|
||||
- `File` can optionally register a search handle and accept controlled reveal calls.
|
||||
|
||||
### Phase 2: Add session-level search state in `SessionReview`
|
||||
|
||||
- Add a single search UI in the `SessionReview` header (can reuse `FileSearchBar` visuals or extract shared presentational pieces).
|
||||
- Build a global hit list from `props.diffs` string content.
|
||||
- Index hits by file/side/line/column/length.
|
||||
|
||||
#### Acceptance
|
||||
|
||||
- Header search appears once for the pane.
|
||||
- Global hit count updates as query changes.
|
||||
- Media/binary diffs are excluded.
|
||||
|
||||
### Phase 3: Wire global navigation to viewers
|
||||
|
||||
- Register a `FileSearchHandle` per mounted diff viewer.
|
||||
- On next/prev, resolve the active global hit and:
|
||||
1. expand the target file if needed
|
||||
2. wait for the viewer to mount/render
|
||||
3. call `handle.setQuery(query)` and `handle.reveal(hit)`
|
||||
|
||||
#### Acceptance
|
||||
|
||||
- Next/prev moves across files.
|
||||
- Collapsed targets auto-open.
|
||||
- Active match is highlighted in the target diff.
|
||||
|
||||
### Phase 4: Handle large-diff gating
|
||||
|
||||
- Lift `render anyway` state from local accordion item state into a file-keyed map in `SessionReview`.
|
||||
- If navigation targets a gated file, force-render it before reveal.
|
||||
|
||||
#### Acceptance
|
||||
|
||||
- Global search can navigate into a large diff without manual user expansion/render.
|
||||
|
||||
### Phase 5: Keyboard and race-condition polish
|
||||
|
||||
- Route `Cmd/Ctrl+F`, `Cmd/Ctrl+G`, `Shift+Cmd/Ctrl+G` to session search when focus is in the review pane.
|
||||
- Add token/cancel guards so fast navigation does not reveal stale targets after async mounts.
|
||||
|
||||
#### Acceptance
|
||||
|
||||
- Keyboard shortcuts consistently target session-level search.
|
||||
- No stale reveal jumps during rapid navigation.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Empty query: clear all viewer highlights, reset count/index.
|
||||
- No results: keep the search bar open, disable prev/next.
|
||||
- Added/deleted files: index only the available side.
|
||||
- Collapsed files: queue reveal until `onRendered` fires.
|
||||
- Large diffs: auto-force render before reveal.
|
||||
- Split diff mode: handle duplicate text on both sides without losing side info.
|
||||
- Do not clear line comment draft or selected lines when navigating search results.
|
||||
|
||||
---
|
||||
|
||||
## Testing plan
|
||||
|
||||
### Unit tests
|
||||
|
||||
- Session hit-index builder:
|
||||
- line/column mapping
|
||||
- additions/deletions side tagging
|
||||
- wrap-around next/prev behavior
|
||||
- `File` controlled search handle:
|
||||
- `setQuery`
|
||||
- `clear`
|
||||
- `reveal` by side/line/column in unified and split diff
|
||||
|
||||
### Component / integration tests
|
||||
|
||||
- Search across multiple diffs and navigate across collapsed accordion items.
|
||||
- Global counter updates correctly (`current/total`).
|
||||
- Split and unified diff styles both navigate correctly.
|
||||
- Large diff target auto-renders on navigation.
|
||||
- Existing line comment draft remains intact while searching.
|
||||
|
||||
### Manual verification
|
||||
|
||||
- `Cmd/Ctrl+F` opens session-level search in the review pane.
|
||||
- `Cmd/Ctrl+G` / `Shift+Cmd/Ctrl+G` navigate globally.
|
||||
- Highlighting and scroll behavior stay stable with many open diffs.
|
||||
|
||||
---
|
||||
|
||||
## Risks and rollback
|
||||
|
||||
### Key risks
|
||||
|
||||
- Global index and DOM highlights can drift if line/column mapping does not match viewer DOM content exactly.
|
||||
- Keyboard shortcut conflicts between session-level search and per-viewer search.
|
||||
- Performance impact when indexing many large diffs in one session.
|
||||
|
||||
### Rollback plan
|
||||
|
||||
- Gate session-level search behind a `SessionReview` prop/flag during rollout.
|
||||
- If unstable, disable the session-level path and keep existing per-viewer search unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should search match file paths as well as content, or content only?
|
||||
- In split mode, should the same text on both sides count as two matches?
|
||||
- Should auto-navigation into gated large diffs silently render them, or show a prompt first?
|
||||
- Should the session-level search bar reuse `FileSearchBar` directly or split out a shared non-portal variant?
|
||||
Reference in New Issue
Block a user