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