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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,265 @@
import type { FileContent } from "@opencode-ai/sdk/v2"
import { createEffect, createMemo, createResource, Match, on, Show, Switch, type JSX } from "solid-js"
import { useI18n } from "../context/i18n"
import {
dataUrlFromMediaValue,
hasMediaValue,
isBinaryContent,
mediaKindFromPath,
normalizeMimeType,
svgTextFromValue,
} from "../pierre/media"
export type FileMediaOptions = {
mode?: "auto" | "off"
path?: string
current?: unknown
before?: unknown
after?: unknown
readFile?: (path: string) => Promise<FileContent | undefined>
onLoad?: () => void
onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void
}
function mediaValue(cfg: FileMediaOptions, mode: "image" | "audio") {
if (cfg.current !== undefined) return cfg.current
if (mode === "image") return cfg.after ?? cfg.before
return cfg.after ?? cfg.before
}
export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX.Element }) {
const i18n = useI18n()
const cfg = () => props.media
const kind = createMemo(() => {
const media = cfg()
if (!media || media.mode === "off") return
return mediaKindFromPath(media.path)
})
const isBinary = createMemo(() => {
const media = cfg()
if (!media || media.mode === "off") return false
if (kind()) return false
return isBinaryContent(media.current as any)
})
const onLoad = () => props.media?.onLoad?.()
const deleted = createMemo(() => {
const media = cfg()
const k = kind()
if (!media || !k) return false
if (k === "svg") return false
if (media.current !== undefined) return false
return !hasMediaValue(media.after as any) && hasMediaValue(media.before as any)
})
const direct = createMemo(() => {
const media = cfg()
const k = kind()
if (!media || (k !== "image" && k !== "audio")) return
return dataUrlFromMediaValue(mediaValue(media, k), k)
})
const request = createMemo(() => {
const media = cfg()
const k = kind()
if (!media || (k !== "image" && k !== "audio")) return
if (media.current !== undefined) return
if (deleted()) return
if (direct()) return
if (!media.path || !media.readFile) return
return {
key: `${k}:${media.path}`,
kind: k,
path: media.path,
readFile: media.readFile,
onError: media.onError,
}
})
const [loaded] = createResource(request, async (input) => {
return input.readFile(input.path).then(
(result) => {
const src = dataUrlFromMediaValue(result as any, input.kind)
if (!src) {
input.onError?.({ kind: input.kind })
return { key: input.key, error: true as const }
}
return {
key: input.key,
src,
mime: input.kind === "audio" ? normalizeMimeType(result?.mimeType) : undefined,
}
},
() => {
input.onError?.({ kind: input.kind })
return { key: input.key, error: true as const }
},
)
})
const remote = createMemo(() => {
const input = request()
const value = loaded()
if (!input || !value || value.key !== input.key) return
return value
})
const src = createMemo(() => {
const value = remote()
return direct() ?? (value && "src" in value ? value.src : undefined)
})
const status = createMemo(() => {
if (direct()) return "ready" as const
if (!request()) return "idle" as const
if (loaded.loading) return "loading" as const
if (remote()?.error) return "error" as const
if (src()) return "ready" as const
return "idle" as const
})
const audioMime = createMemo(() => {
const value = remote()
return value && "mime" in value ? value.mime : undefined
})
const svgSource = createMemo(() => {
const media = cfg()
if (!media || kind() !== "svg") return
return svgTextFromValue(media.current as any)
})
const svgSrc = createMemo(() => {
const media = cfg()
if (!media || kind() !== "svg") return
return dataUrlFromMediaValue(media.current as any, "svg")
})
const svgInvalid = createMemo(() => {
const media = cfg()
if (!media || kind() !== "svg") return
if (svgSource() !== undefined) return
if (!hasMediaValue(media.current as any)) return
return [media.path, media.current] as const
})
createEffect(
on(
svgInvalid,
(value) => {
if (!value) return
cfg()?.onError?.({ kind: "svg" })
},
{ defer: true },
),
)
const kindLabel = (value: "image" | "audio") =>
i18n.t(value === "image" ? "ui.fileMedia.kind.image" : "ui.fileMedia.kind.audio")
return (
<Switch>
<Match when={kind() === "image" || kind() === "audio"}>
<Show
when={src()}
fallback={(() => {
const media = cfg()
const k = kind()
if (!media || (k !== "image" && k !== "audio")) return props.fallback()
const label = kindLabel(k)
if (deleted()) {
return (
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
{i18n.t("ui.fileMedia.state.removed", { kind: label })}
</div>
)
}
if (status() === "loading") {
return (
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
{i18n.t("ui.fileMedia.state.loading", { kind: label })}
</div>
)
}
if (status() === "error") {
return (
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
{i18n.t("ui.fileMedia.state.error", { kind: label })}
</div>
)
}
return (
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
{i18n.t("ui.fileMedia.state.unavailable", { kind: label })}
</div>
)
})()}
>
{(value) => {
const k = kind()
if (k !== "image" && k !== "audio") return props.fallback()
if (k === "image") {
return (
<div class="flex justify-center bg-background-stronger px-6 py-4">
<img
src={value()}
alt={cfg()?.path}
class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
onLoad={onLoad}
/>
</div>
)
}
return (
<div class="flex justify-center bg-background-stronger px-6 py-4">
<audio class="w-full max-w-xl" controls preload="metadata" onLoadedMetadata={onLoad}>
<source src={value()} type={audioMime()} />
</audio>
</div>
)
}}
</Show>
</Match>
<Match when={kind() === "svg"}>
{(() => {
if (svgSource() === undefined && svgSrc() == null) return props.fallback()
return (
<div class="flex flex-col gap-4 px-6 py-4">
<Show when={svgSource() !== undefined}>{props.fallback()}</Show>
<Show when={svgSrc()}>
{(value) => (
<div class="flex justify-center">
<img
src={value()}
alt={cfg()?.path}
class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
onLoad={onLoad}
/>
</div>
)}
</Show>
</div>
)
})()}
</Match>
<Match when={isBinary()}>
<div class="flex min-h-56 flex-col items-center justify-center gap-2 px-6 py-10 text-center">
<div class="text-14-semibold text-text-strong">
{cfg()?.path?.split("/").pop() ?? i18n.t("ui.fileMedia.binary.title")}
</div>
<div class="text-14-regular text-text-weak">
{(() => {
const path = cfg()?.path
if (!path) return i18n.t("ui.fileMedia.binary.description.default")
return i18n.t("ui.fileMedia.binary.description.path", { path })
})()}
</div>
</div>
</Match>
<Match when={true}>{props.fallback()}</Match>
</Switch>
)
}

View File

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

View File

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

View File

@@ -1,6 +1,12 @@
[data-component="diff"] {
[data-component="file"] {
content-visibility: auto;
}
[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);

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,9 +1,23 @@
export const lineCommentStyles = `
[data-annotation-slot] {
padding: 12px;
box-sizing: border-box;
}
[data-component="line-comment"] {
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
}

View File

@@ -1,52 +1,121 @@
import { onMount, Show, splitProps, type JSX } from "solid-js"
import { createEffect, createSignal, onMount, Show, splitProps, type JSX } from "solid-js"
import { Button } from "./button"
import { Icon } from "./icon"
import { installLineCommentStyles } from "./line-comment-styles"
import { useI18n } from "../context/i18n"
export type LineCommentVariant = "default" | "editor"
installLineCommentStyles()
export type LineCommentVariant = "default" | "editor" | "add"
function InlineGlyph(props: { icon: "comment" | "plus" }) {
return (
<svg data-slot="line-comment-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<Show
when={props.icon === "comment"}
fallback={
<path
d="M10 5.41699V10.0003M10 10.0003V14.5837M10 10.0003H5.4165M10 10.0003H14.5832"
stroke="currentColor"
stroke-linecap="square"
/>
}
>
<path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square" />
</Show>
</svg>
)
}
export type LineCommentAnchorProps = {
id?: string
top?: number
inline?: boolean
hideButton?: boolean
open: boolean
variant?: LineCommentVariant
icon?: "comment" | "plus"
buttonLabel?: string
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
class?: string
popoverClass?: string
children: JSX.Element
children?: JSX.Element
}
export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
const hidden = () => props.top === undefined
const hidden = () => !props.inline && props.top === undefined
const variant = () => props.variant ?? "default"
const icon = () => props.icon ?? "comment"
const inlineBody = () => props.inline && props.hideButton
return (
<div
data-component="line-comment"
data-prevent-autofocus=""
data-variant={variant()}
data-comment-id={props.id}
data-open={props.open ? "" : undefined}
data-inline={props.inline ? "" : undefined}
classList={{
[props.class ?? ""]: !!props.class,
}}
style={{
top: `${props.top ?? 0}px`,
opacity: hidden() ? 0 : 1,
"pointer-events": hidden() ? "none" : "auto",
}}
style={
props.inline
? undefined
: {
top: `${props.top ?? 0}px`,
opacity: hidden() ? 0 : 1,
"pointer-events": hidden() ? "none" : "auto",
}
}
>
<button type="button" data-slot="line-comment-button" onClick={props.onClick} onMouseEnter={props.onMouseEnter}>
<Icon name="comment" size="small" />
</button>
<Show when={props.open}>
<Show
when={inlineBody()}
fallback={
<>
<button
type="button"
aria-label={props.buttonLabel}
data-slot="line-comment-button"
on:mousedown={(e) => e.stopPropagation()}
on:mouseup={(e) => e.stopPropagation()}
on:click={props.onClick as any}
on:mouseenter={props.onMouseEnter as any}
>
<Show
when={props.inline}
fallback={<Icon name={icon() === "plus" ? "plus-small" : "comment"} size="small" />}
>
<InlineGlyph icon={icon()} />
</Show>
</button>
<Show when={props.open}>
<div
data-slot="line-comment-popover"
classList={{
[props.popoverClass ?? ""]: !!props.popoverClass,
}}
on:mousedown={(e) => e.stopPropagation()}
on:focusout={props.onPopoverFocusOut as any}
>
{props.children}
</div>
</Show>
</>
}
>
<div
data-slot="line-comment-popover"
data-inline-body=""
classList={{
[props.popoverClass ?? ""]: !!props.popoverClass,
}}
onFocusOut={props.onPopoverFocusOut}
on:mousedown={(e) => e.stopPropagation()}
on:click={props.onClick as any}
on:mouseenter={props.onMouseEnter as any}
on:focusout={props.onPopoverFocusOut as any}
>
{props.children}
</div>
@@ -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>

View File

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

View File

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

View File

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

View File

@@ -200,50 +200,6 @@
color: var(--icon-diff-modified-base);
}
[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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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å ",

View File

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

View File

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

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "에 댓글 달기",

View File

@@ -16,6 +16,15 @@ export const dict: Record<Keys, string> = {
"ui.sessionReview.largeDiff.title": "Diff er for stor til å gjengi",
"ui.sessionReview.largeDiff.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": "",

View File

@@ -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: ",

View File

@@ -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": "Комментирование: ",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

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

View File

@@ -0,0 +1,91 @@
import { type SelectedLineRange } from "@pierre/diffs"
import { diffLineIndex, diffRowIndex, findDiffSide } from "./diff-selection"
export type CommentSide = "additions" | "deletions"
function annotationIndex(node: HTMLElement) {
const value = node.dataset.lineAnnotation?.split(",")[1]
if (!value) return
const line = parseInt(value, 10)
if (Number.isNaN(line)) return
return line
}
function clear(root: ShadowRoot) {
const marked = Array.from(root.querySelectorAll("[data-comment-selected]"))
for (const node of marked) {
if (!(node instanceof HTMLElement)) continue
node.removeAttribute("data-comment-selected")
}
}
export function markCommentedDiffLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
clear(root)
const diffs = root.querySelector("[data-diff]")
if (!(diffs instanceof HTMLElement)) return
const split = diffs.dataset.diffType === "split"
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (rows.length === 0) return
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
for (const range of ranges) {
const start = diffRowIndex(root, split, range.start, range.side as CommentSide | undefined)
if (start === undefined) continue
const end = (() => {
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
if (same) return start
return diffRowIndex(root, split, range.end, (range.endSide ?? range.side) as CommentSide | undefined)
})()
if (end === undefined) continue
const first = Math.min(start, end)
const last = Math.max(start, end)
for (const row of rows) {
const idx = diffLineIndex(split, row)
if (idx === undefined || idx < first || idx > last) continue
row.setAttribute("data-comment-selected", "")
}
for (const annotation of annotations) {
const idx = annotationIndex(annotation)
if (idx === undefined || idx < first || idx > last) continue
annotation.setAttribute("data-comment-selected", "")
}
}
}
export function markCommentedFileLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
clear(root)
const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
for (const range of ranges) {
const start = Math.max(1, Math.min(range.start, range.end))
const end = Math.max(range.start, range.end)
for (let line = start; line <= end; line++) {
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`))
for (const node of nodes) {
if (!(node instanceof HTMLElement)) continue
node.setAttribute("data-comment-selected", "")
}
}
for (const annotation of annotations) {
const line = annotationIndex(annotation)
if (line === undefined || line < start || line > end) continue
annotation.setAttribute("data-comment-selected", "")
}
}
}

View File

@@ -0,0 +1,71 @@
import { type SelectedLineRange } from "@pierre/diffs"
export type DiffSelectionSide = "additions" | "deletions"
export function findDiffSide(node: HTMLElement): DiffSelectionSide {
const line = node.closest("[data-line], [data-alt-line]")
if (line instanceof HTMLElement) {
const type = line.dataset.lineType
if (type === "change-deletion") return "deletions"
if (type === "change-addition" || type === "change-additions") return "additions"
}
const code = node.closest("[data-code]")
if (!(code instanceof HTMLElement)) return "additions"
return code.hasAttribute("data-deletions") ? "deletions" : "additions"
}
export function diffLineIndex(split: boolean, node: HTMLElement) {
const raw = node.dataset.lineIndex
if (!raw) return
const values = raw
.split(",")
.map((x) => parseInt(x, 10))
.filter((x) => !Number.isNaN(x))
if (values.length === 0) return
if (!split) return values[0]
if (values.length === 2) return values[1]
return values[0]
}
export function diffRowIndex(root: ShadowRoot, split: boolean, line: number, side: DiffSelectionSide | undefined) {
const rows = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (rows.length === 0) return
const target = side ?? "additions"
for (const row of rows) {
if (findDiffSide(row) === target) return diffLineIndex(split, row)
if (parseInt(row.dataset.altLine ?? "", 10) === line) return diffLineIndex(split, row)
}
}
export function fixDiffSelection(root: ShadowRoot | undefined, range: SelectedLineRange | null) {
if (!range) return range
if (!root) return
const diffs = root.querySelector("[data-diff]")
if (!(diffs instanceof HTMLElement)) return
const split = diffs.dataset.diffType === "split"
const start = diffRowIndex(root, split, range.start, range.side)
const end = diffRowIndex(root, split, range.end, range.endSide ?? range.side)
if (start === undefined || end === undefined) {
if (root.querySelector("[data-line], [data-alt-line]") == null) return
return null
}
if (start <= end) return range
const side = range.endSide ?? range.side
const swapped: SelectedLineRange = {
start: range.end,
end: range.start,
}
if (side) swapped.side = side
if (range.endSide && range.side) swapped.endSide = range.side
return swapped
}

View File

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

View File

@@ -0,0 +1,114 @@
type ReadyWatcher = {
observer?: MutationObserver
token: number
}
export function createReadyWatcher(): ReadyWatcher {
return { token: 0 }
}
export function clearReadyWatcher(state: ReadyWatcher) {
state.observer?.disconnect()
state.observer = undefined
}
export function getViewerHost(container: HTMLElement | undefined) {
if (!container) return
const host = container.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return
return host
}
export function getViewerRoot(container: HTMLElement | undefined) {
return getViewerHost(container)?.shadowRoot ?? undefined
}
export function applyViewerScheme(host: HTMLElement | undefined) {
if (!host) return
if (typeof document === "undefined") return
const scheme = document.documentElement.dataset.colorScheme
if (scheme === "dark" || scheme === "light") {
host.dataset.colorScheme = scheme
return
}
host.removeAttribute("data-color-scheme")
}
export function observeViewerScheme(getHost: () => HTMLElement | undefined) {
if (typeof document === "undefined") return () => {}
applyViewerScheme(getHost())
if (typeof MutationObserver === "undefined") return () => {}
const root = document.documentElement
const monitor = new MutationObserver(() => applyViewerScheme(getHost()))
monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
return () => monitor.disconnect()
}
export function notifyShadowReady(opts: {
state: ReadyWatcher
container: HTMLElement
getRoot: () => ShadowRoot | undefined
isReady: (root: ShadowRoot) => boolean
onReady: () => void
settleFrames?: number
}) {
clearReadyWatcher(opts.state)
opts.state.token += 1
const token = opts.state.token
const settle = Math.max(0, opts.settleFrames ?? 0)
const runReady = () => {
const step = (left: number) => {
if (token !== opts.state.token) return
if (left <= 0) {
opts.onReady()
return
}
requestAnimationFrame(() => step(left - 1))
}
requestAnimationFrame(() => step(settle))
}
const observeRoot = (root: ShadowRoot) => {
if (opts.isReady(root)) {
runReady()
return
}
if (typeof MutationObserver === "undefined") return
clearReadyWatcher(opts.state)
opts.state.observer = new MutationObserver(() => {
if (token !== opts.state.token) return
if (!opts.isReady(root)) return
clearReadyWatcher(opts.state)
runReady()
})
opts.state.observer.observe(root, { childList: true, subtree: true })
}
const root = opts.getRoot()
if (!root) {
if (typeof MutationObserver === "undefined") return
opts.state.observer = new MutationObserver(() => {
if (token !== opts.state.token) return
const next = opts.getRoot()
if (!next) return
observeRoot(next)
})
opts.state.observer.observe(opts.container, { childList: true, subtree: true })
return
}
observeRoot(root)
}

View File

@@ -0,0 +1,85 @@
import { type SelectedLineRange } from "@pierre/diffs"
import { toRange } from "./selection-bridge"
export function findElement(node: Node | null): HTMLElement | undefined {
if (!node) return
if (node instanceof HTMLElement) return node
return node.parentElement ?? undefined
}
export function findFileLineNumber(node: Node | null): number | undefined {
const el = findElement(node)
if (!el) return
const line = el.closest("[data-line]")
if (!(line instanceof HTMLElement)) return
const value = parseInt(line.dataset.line ?? "", 10)
if (Number.isNaN(value)) return
return value
}
export function findDiffLineNumber(node: Node | null): number | undefined {
const el = findElement(node)
if (!el) return
const line = el.closest("[data-line], [data-alt-line]")
if (!(line instanceof HTMLElement)) return
const primary = parseInt(line.dataset.line ?? "", 10)
if (!Number.isNaN(primary)) return primary
const alt = parseInt(line.dataset.altLine ?? "", 10)
if (!Number.isNaN(alt)) return alt
}
export function findCodeSelectionSide(node: Node | null): SelectedLineRange["side"] {
const el = findElement(node)
if (!el) return
const code = el.closest("[data-code]")
if (!(code instanceof HTMLElement)) return
if (code.hasAttribute("data-deletions")) return "deletions"
return "additions"
}
export function readShadowLineSelection(opts: {
root: ShadowRoot
lineForNode: (node: Node | null) => number | undefined
sideForNode?: (node: Node | null) => SelectedLineRange["side"]
preserveTextSelection?: boolean
}) {
const selection =
(opts.root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
if (!selection || selection.isCollapsed) return
const domRange =
(
selection as unknown as {
getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => StaticRange[]
}
).getComposedRanges?.({ shadowRoots: [opts.root] })?.[0] ??
(selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
const startNode = domRange?.startContainer ?? selection.anchorNode
const endNode = domRange?.endContainer ?? selection.focusNode
if (!startNode || !endNode) return
if (!opts.root.contains(startNode) || !opts.root.contains(endNode)) return
const start = opts.lineForNode(startNode)
const end = opts.lineForNode(endNode)
if (start === undefined || end === undefined) return
const startSide = opts.sideForNode?.(startNode)
const endSide = opts.sideForNode?.(endNode)
const side = startSide ?? endSide
const range: SelectedLineRange = { start, end }
if (side) range.side = side
if (endSide && side && endSide !== side) range.endSide = endSide
return {
range,
text: opts.preserveTextSelection && domRange ? toRange(domRange).cloneRange() : undefined,
}
}

View File

@@ -1,5 +1,6 @@
import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs"
import { 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 {

View File

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

View File

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

View File

@@ -13,9 +13,8 @@
@import "../components/button.css" layer(components);
@import "../components/card.css" layer(components);
@import "../components/checkbox.css" layer(components);
@import "../components/code.css" layer(components);
@import "../components/file.css" layer(components);
@import "../components/collapsible.css" layer(components);
@import "../components/diff.css" layer(components);
@import "../components/diff-changes.css" layer(components);
@import "../components/context-menu.css" layer(components);
@import "../components/dropdown-menu.css" layer(components);
@@ -28,7 +27,6 @@
@import "../components/icon-button.css" layer(components);
@import "../components/image-preview.css" layer(components);
@import "../components/keybind.css" layer(components);
@import "../components/line-comment.css" layer(components);
@import "../components/text-field.css" layer(components);
@import "../components/inline-input.css" layer(components);
@import "../components/list.css" layer(components);