Files
opencode/packages/ui/src/components/code.tsx
2026-02-02 14:24:24 -06:00

954 lines
26 KiB
TypeScript

import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs"
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
import { createDefaultOptions, styleVariables } from "../pierre"
import { getWorkerPool } from "../pierre/worker"
import { Icon } from "./icon"
type SelectionSide = "additions" | "deletions"
export type CodeProps<T = {}> = FileOptions<T> & {
file: FileContents
annotations?: LineAnnotation<T>[]
selectedLines?: SelectedLineRange | null
commentedLines?: SelectedLineRange[]
onRendered?: () => void
onLineSelectionEnd?: (selection: SelectedLineRange | null) => void
class?: string
classList?: ComponentProps<"div">["classList"]
}
function findElement(node: Node | null): HTMLElement | undefined {
if (!node) return
if (node instanceof HTMLElement) return node
return node.parentElement ?? undefined
}
function findLineNumber(node: Node | null): number | undefined {
const element = findElement(node)
if (!element) return
const line = element.closest("[data-line]")
if (!(line instanceof HTMLElement)) return
const value = parseInt(line.dataset.line ?? "", 10)
if (Number.isNaN(value)) return
return value
}
function findSide(node: Node | null): SelectionSide | undefined {
const element = findElement(node)
if (!element) return
const code = element.closest("[data-code]")
if (!(code instanceof HTMLElement)) return
if (code.hasAttribute("data-deletions")) return "deletions"
return "additions"
}
type FindHost = {
element: () => HTMLElement | undefined
open: () => void
close: () => void
next: (dir: 1 | -1) => void
isOpen: () => boolean
}
const findHosts = new Set<FindHost>()
let findTarget: FindHost | undefined
let findCurrent: FindHost | undefined
let findInstalled = false
function isEditable(node: unknown): boolean {
if (!(node instanceof HTMLElement)) return false
if (node.closest("[data-prevent-autofocus]")) return true
if (node.isContentEditable) return true
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName)
}
function hostForNode(node: unknown): FindHost | undefined {
if (!(node instanceof Node)) return
for (const host of findHosts) {
const el = host.element()
if (el && el.isConnected && el.contains(node)) return host
}
}
function installFindShortcuts() {
if (findInstalled) return
if (typeof window === "undefined") return
findInstalled = true
window.addEventListener(
"keydown",
(event) => {
if (event.defaultPrevented) return
const mod = event.metaKey || event.ctrlKey
if (!mod) return
const key = event.key.toLowerCase()
if (key === "g") {
const host = findCurrent
if (!host || !host.isOpen()) return
event.preventDefault()
event.stopPropagation()
host.next(event.shiftKey ? -1 : 1)
return
}
if (key !== "f") return
const current = findCurrent
if (current && current.isOpen()) {
event.preventDefault()
event.stopPropagation()
current.open()
return
}
if (isEditable(event.target)) return
const host = hostForNode(document.activeElement) ?? findTarget ?? Array.from(findHosts)[0]
if (!host) return
event.preventDefault()
event.stopPropagation()
host.open()
},
{ capture: true },
)
}
export function Code<T>(props: CodeProps<T>) {
let wrapper!: HTMLDivElement
let container!: HTMLDivElement
let findInput: HTMLInputElement | undefined
let findOverlay!: HTMLDivElement
let findOverlayFrame: number | undefined
let findOverlayScroll: HTMLElement[] = []
let observer: MutationObserver | undefined
let renderToken = 0
let selectionFrame: number | undefined
let dragFrame: number | undefined
let dragStart: number | undefined
let dragEnd: number | undefined
let dragMoved = false
let lastSelection: SelectedLineRange | null = null
let pendingSelectionEnd = false
const [local, others] = splitProps(props, [
"file",
"class",
"classList",
"annotations",
"selectedLines",
"commentedLines",
"onRendered",
])
const [rendered, setRendered] = createSignal(0)
const [findOpen, setFindOpen] = createSignal(false)
const [findQuery, setFindQuery] = createSignal("")
const [findIndex, setFindIndex] = createSignal(0)
const [findCount, setFindCount] = createSignal(0)
let findMode: "highlights" | "overlay" = "overlay"
let findHits: Range[] = []
const file = createMemo(
() =>
new File<T>(
{
...createDefaultOptions<T>("unified"),
...others,
},
getWorkerPool("unified"),
),
)
const getRoot = () => {
const host = container.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return
const root = host.shadowRoot
if (!root) return
return root
}
const applyScheme = () => {
const host = container.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return
const scheme = document.documentElement.dataset.colorScheme
if (scheme === "dark" || scheme === "light") {
host.dataset.colorScheme = scheme
return
}
host.removeAttribute("data-color-scheme")
}
const supportsHighlights = () => {
const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown }
return typeof g.Highlight === "function" && g.CSS?.highlights != null
}
const clearHighlightFind = () => {
const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights
if (!api) return
api.delete("opencode-find")
api.delete("opencode-find-current")
}
const clearOverlayScroll = () => {
for (const el of findOverlayScroll) el.removeEventListener("scroll", scheduleOverlay)
findOverlayScroll = []
}
const clearOverlay = () => {
if (findOverlayFrame !== undefined) {
cancelAnimationFrame(findOverlayFrame)
findOverlayFrame = undefined
}
findOverlay.innerHTML = ""
}
const renderOverlay = () => {
if (findMode !== "overlay") {
clearOverlay()
return
}
clearOverlay()
if (findHits.length === 0) return
const base = wrapper.getBoundingClientRect()
const current = findIndex()
const frag = document.createDocumentFragment()
for (let i = 0; i < findHits.length; i++) {
const range = findHits[i]
const active = i === current
for (const rect of Array.from(range.getClientRects())) {
if (!rect.width || !rect.height) continue
const el = document.createElement("div")
el.style.position = "absolute"
el.style.left = `${Math.round(rect.left - base.left)}px`
el.style.top = `${Math.round(rect.top - base.top)}px`
el.style.width = `${Math.round(rect.width)}px`
el.style.height = `${Math.round(rect.height)}px`
el.style.borderRadius = "2px"
el.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)"
el.style.opacity = active ? "0.55" : "0.35"
if (active) el.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)"
frag.appendChild(el)
}
}
findOverlay.appendChild(frag)
}
function scheduleOverlay() {
if (findMode !== "overlay") return
if (!findOpen()) return
if (findOverlayFrame !== undefined) return
findOverlayFrame = requestAnimationFrame(() => {
findOverlayFrame = undefined
renderOverlay()
})
}
const syncOverlayScroll = () => {
if (findMode !== "overlay") return
const root = getRoot()
const next = root
? Array.from(root.querySelectorAll("[data-code]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
: []
if (next.length === findOverlayScroll.length && next.every((el, i) => el === findOverlayScroll[i])) return
clearOverlayScroll()
findOverlayScroll = next
for (const el of findOverlayScroll) el.addEventListener("scroll", scheduleOverlay, { passive: true })
}
const clearFind = () => {
clearHighlightFind()
clearOverlay()
clearOverlayScroll()
findHits = []
setFindCount(0)
setFindIndex(0)
}
const scanFind = (root: ShadowRoot, query: string) => {
const needle = query.toLowerCase()
const out: Range[] = []
const cols = Array.from(root.querySelectorAll("[data-column-content]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
for (const col of cols) {
const text = col.textContent
if (!text) continue
const hay = text.toLowerCase()
let idx = hay.indexOf(needle)
if (idx === -1) continue
const nodes: Text[] = []
const ends: number[] = []
const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT)
let node = walker.nextNode()
let pos = 0
while (node) {
if (node instanceof Text) {
pos += node.data.length
nodes.push(node)
ends.push(pos)
}
node = walker.nextNode()
}
if (nodes.length === 0) continue
const locate = (at: number) => {
let lo = 0
let hi = ends.length - 1
while (lo < hi) {
const mid = (lo + hi) >> 1
if (ends[mid] >= at) hi = mid
else lo = mid + 1
}
const prev = lo === 0 ? 0 : ends[lo - 1]
return { node: nodes[lo], offset: at - prev }
}
while (idx !== -1) {
const start = locate(idx)
const end = locate(idx + query.length)
const range = document.createRange()
range.setStart(start.node, start.offset)
range.setEnd(end.node, end.offset)
out.push(range)
idx = hay.indexOf(needle, idx + query.length)
}
}
return out
}
const scrollToRange = (range: Range) => {
const start = range.startContainer
const el = start instanceof Element ? start : start.parentElement
el?.scrollIntoView({ block: "center", inline: "center" })
}
const setHighlights = (ranges: Range[], index: number) => {
const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights
const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight
if (!api || typeof Highlight !== "function") return false
api.delete("opencode-find")
api.delete("opencode-find-current")
const active = ranges[index]
if (active) api.set("opencode-find-current", new Highlight(active))
const rest = ranges.filter((_, i) => i !== index)
if (rest.length > 0) api.set("opencode-find", new Highlight(...rest))
return true
}
const applyFind = (opts?: { reset?: boolean; scroll?: boolean }) => {
if (!findOpen()) return
const query = findQuery().trim()
if (!query) {
clearFind()
return
}
const root = getRoot()
if (!root) return
findMode = supportsHighlights() ? "highlights" : "overlay"
const ranges = scanFind(root, query)
const total = ranges.length
const desired = opts?.reset ? 0 : findIndex()
const index = total ? Math.min(desired, total - 1) : 0
findHits = ranges
setFindCount(total)
setFindIndex(index)
const active = ranges[index]
if (findMode === "highlights") {
clearOverlay()
clearOverlayScroll()
if (!setHighlights(ranges, index)) {
findMode = "overlay"
clearHighlightFind()
syncOverlayScroll()
scheduleOverlay()
}
if (opts?.scroll && active) scrollToRange(active)
return
}
clearHighlightFind()
syncOverlayScroll()
if (opts?.scroll && active) scrollToRange(active)
scheduleOverlay()
}
const closeFind = () => {
setFindOpen(false)
clearFind()
if (findCurrent === host) findCurrent = undefined
}
const stepFind = (dir: 1 | -1) => {
if (!findOpen()) return
const total = findCount()
if (total <= 0) return
const index = (findIndex() + dir + total) % total
setFindIndex(index)
const active = findHits[index]
if (!active) return
if (findMode === "highlights") {
if (!setHighlights(findHits, index)) {
findMode = "overlay"
applyFind({ reset: true, scroll: true })
return
}
scrollToRange(active)
return
}
clearHighlightFind()
syncOverlayScroll()
scrollToRange(active)
scheduleOverlay()
}
const host: FindHost = {
element: () => wrapper,
isOpen: () => findOpen(),
next: stepFind,
open: () => {
if (findCurrent && findCurrent !== host) findCurrent.close()
findCurrent = host
findTarget = host
if (!findOpen()) setFindOpen(true)
requestAnimationFrame(() => {
findInput?.focus()
findInput?.select()
})
applyFind({ scroll: true })
},
close: closeFind,
}
onMount(() => {
findMode = supportsHighlights() ? "highlights" : "overlay"
installFindShortcuts()
findHosts.add(host)
if (!findTarget) findTarget = host
onCleanup(() => {
findHosts.delete(host)
if (findCurrent === host) {
findCurrent = undefined
clearHighlightFind()
}
if (findTarget === host) findTarget = undefined
})
})
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")
}
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}"]`))
for (const node of nodes) {
if (!(node instanceof HTMLElement)) continue
node.setAttribute("data-comment-selected", "")
}
}
}
}
const lineCount = () => {
const text = local.file.contents
const total = text.split("\n").length - (text.endsWith("\n") ? 1 : 0)
return Math.max(1, total)
}
const applySelection = (range: SelectedLineRange | null) => {
const root = getRoot()
if (!root) return false
const lines = lineCount()
if (root.querySelectorAll("[data-line]").length < lines) return false
if (!range) {
file().setSelectedLines(null)
return true
}
const start = Math.min(range.start, range.end)
const end = Math.max(range.start, range.end)
if (start < 1 || end > lines) {
file().setSelectedLines(null)
return true
}
if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) {
file().setSelectedLines(null)
return true
}
const normalized = (() => {
if (range.endSide != null) return { start: range.start, end: range.end }
if (range.side !== "deletions") return range
if (root.querySelector("[data-deletions]") != null) return range
return { start: range.start, end: range.end }
})()
file().setSelectedLines(normalized)
return true
}
const notifyRendered = () => {
observer?.disconnect()
observer = undefined
renderToken++
const token = renderToken
const lines = lineCount()
const isReady = (root: ShadowRoot) => root.querySelectorAll("[data-line]").length >= lines
const notify = () => {
if (token !== renderToken) return
observer?.disconnect()
observer = undefined
requestAnimationFrame(() => {
if (token !== renderToken) return
applySelection(lastSelection)
applyFind({ reset: true })
local.onRendered?.()
})
}
const root = getRoot()
if (root && isReady(root)) {
notify()
return
}
if (typeof MutationObserver === "undefined") return
const observeRoot = (root: ShadowRoot) => {
if (isReady(root)) {
notify()
return
}
observer?.disconnect()
observer = new MutationObserver(() => {
if (token !== renderToken) return
if (!isReady(root)) return
notify()
})
observer.observe(root, { childList: true, subtree: true })
}
if (root) {
observeRoot(root)
return
}
observer = new MutationObserver(() => {
if (token !== renderToken) return
const root = getRoot()
if (!root) return
observeRoot(root)
})
observer.observe(container, { childList: true, subtree: true })
}
const updateSelection = () => {
const root = getRoot()
if (!root) return
const selection =
(root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
if (!selection || selection.isCollapsed) return
const domRange =
(
selection as unknown as {
getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[]
}
).getComposedRanges?.({ shadowRoots: [root] })?.[0] ??
(selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
const startNode = domRange?.startContainer ?? selection.anchorNode
const endNode = domRange?.endContainer ?? selection.focusNode
if (!startNode || !endNode) return
if (!root.contains(startNode) || !root.contains(endNode)) return
const start = findLineNumber(startNode)
const end = findLineNumber(endNode)
if (start === undefined || end === undefined) return
const startSide = findSide(startNode)
const endSide = findSide(endNode)
const side = startSide ?? endSide
const selected: SelectedLineRange = {
start,
end,
}
if (side) selected.side = side
if (endSide && side && endSide !== side) selected.endSide = endSide
setSelectedLines(selected)
}
const setSelectedLines = (range: SelectedLineRange | null) => {
lastSelection = range
applySelection(range)
}
const scheduleSelectionUpdate = () => {
if (selectionFrame !== undefined) return
selectionFrame = requestAnimationFrame(() => {
selectionFrame = undefined
updateSelection()
if (!pendingSelectionEnd) return
pendingSelectionEnd = false
props.onLineSelectionEnd?.(lastSelection)
})
}
const updateDragSelection = () => {
if (dragStart === undefined || dragEnd === undefined) return
const start = Math.min(dragStart, dragEnd)
const end = Math.max(dragStart, dragEnd)
setSelectedLines({ start, end })
}
const scheduleDragUpdate = () => {
if (dragFrame !== undefined) return
dragFrame = requestAnimationFrame(() => {
dragFrame = undefined
updateDragSelection()
})
}
const lineFromMouseEvent = (event: MouseEvent) => {
const path = event.composedPath()
let numberColumn = false
let line: number | undefined
for (const item of path) {
if (!(item instanceof HTMLElement)) continue
numberColumn = numberColumn || item.dataset.columnNumber != null
if (line === undefined && item.dataset.line) {
const parsed = parseInt(item.dataset.line, 10)
if (!Number.isNaN(parsed)) line = parsed
}
if (numberColumn && line !== undefined) break
}
return { line, numberColumn }
}
const handleMouseDown = (event: MouseEvent) => {
if (props.enableLineSelection !== true) return
if (event.button !== 0) return
const { line, numberColumn } = lineFromMouseEvent(event)
if (numberColumn) return
if (line === undefined) return
dragStart = line
dragEnd = line
dragMoved = false
}
const handleMouseMove = (event: MouseEvent) => {
if (props.enableLineSelection !== true) return
if (dragStart === undefined) return
if ((event.buttons & 1) === 0) {
dragStart = undefined
dragEnd = undefined
dragMoved = false
return
}
const { line } = lineFromMouseEvent(event)
if (line === undefined) return
dragEnd = line
dragMoved = true
scheduleDragUpdate()
}
const handleMouseUp = () => {
if (props.enableLineSelection !== true) return
if (dragStart === undefined) return
if (!dragMoved) {
pendingSelectionEnd = false
const line = dragStart
setSelectedLines({ start: line, end: line })
props.onLineSelectionEnd?.(lastSelection)
dragStart = undefined
dragEnd = undefined
dragMoved = false
return
}
pendingSelectionEnd = true
scheduleDragUpdate()
scheduleSelectionUpdate()
dragStart = undefined
dragEnd = undefined
dragMoved = false
}
const handleSelectionChange = () => {
if (props.enableLineSelection !== true) return
if (dragStart === undefined) return
const selection = window.getSelection()
if (!selection || selection.isCollapsed) return
scheduleSelectionUpdate()
}
createEffect(() => {
const current = file()
onCleanup(() => {
current.cleanUp()
})
})
createEffect(() => {
observer?.disconnect()
observer = undefined
container.innerHTML = ""
file().render({
file: local.file,
lineAnnotations: local.annotations,
containerWrapper: container,
})
applyScheme()
setRendered((value) => value + 1)
notifyRendered()
})
createEffect(() => {
if (typeof document === "undefined") return
if (typeof MutationObserver === "undefined") return
const root = document.documentElement
const monitor = new MutationObserver(() => applyScheme())
monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
applyScheme()
onCleanup(() => monitor.disconnect())
})
createEffect(() => {
rendered()
const ranges = local.commentedLines ?? []
requestAnimationFrame(() => applyCommentedLines(ranges))
})
createEffect(() => {
setSelectedLines(local.selectedLines ?? null)
})
createEffect(() => {
if (props.enableLineSelection !== true) return
container.addEventListener("mousedown", handleMouseDown)
container.addEventListener("mousemove", handleMouseMove)
window.addEventListener("mouseup", handleMouseUp)
document.addEventListener("selectionchange", handleSelectionChange)
onCleanup(() => {
container.removeEventListener("mousedown", handleMouseDown)
container.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
document.removeEventListener("selectionchange", handleSelectionChange)
})
})
onCleanup(() => {
observer?.disconnect()
clearOverlayScroll()
clearOverlay()
if (findCurrent === host) {
findCurrent = undefined
clearHighlightFind()
}
if (selectionFrame !== undefined) {
cancelAnimationFrame(selectionFrame)
selectionFrame = undefined
}
if (dragFrame !== undefined) {
cancelAnimationFrame(dragFrame)
dragFrame = undefined
}
dragStart = undefined
dragEnd = undefined
dragMoved = false
lastSelection = null
pendingSelectionEnd = false
})
return (
<div
data-component="code"
style={styleVariables}
class="relative outline-none"
classList={{
...(local.classList || {}),
[local.class ?? ""]: !!local.class,
}}
ref={wrapper}
tabIndex={0}
onPointerDown={() => {
findTarget = host
wrapper.focus({ preventScroll: true })
}}
onFocus={() => {
findTarget = host
}}
>
<div ref={container} />
<div ref={findOverlay} class="pointer-events-none absolute inset-0 z-0" />
<Show when={findOpen()}>
<div
class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded-md border border-border-weak-base bg-surface-raised-base px-2 py-1 shadow-xs-border"
onPointerDown={(e) => e.stopPropagation()}
>
<Icon name="magnifying-glass" size="small" class="text-text-weak" />
<input
ref={findInput}
placeholder="Find"
value={findQuery()}
class="w-48 bg-transparent outline-none text-12-regular text-text-strong placeholder:text-text-weak"
onInput={(e) => {
setFindQuery(e.currentTarget.value)
setFindIndex(0)
applyFind({ reset: true, scroll: true })
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
closeFind()
return
}
if (e.key !== "Enter") return
e.preventDefault()
stepFind(e.shiftKey ? -1 : 1)
}}
/>
<div class="px-1 text-12-regular text-text-weak tabular-nums">
{findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"}
</div>
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
disabled={findCount() === 0}
aria-label="Previous match"
onClick={() => stepFind(-1)}
>
<Icon name="chevron-down" size="small" class="rotate-180" />
</button>
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
disabled={findCount() === 0}
aria-label="Next match"
onClick={() => stepFind(1)}
>
<Icon name="chevron-down" size="small" />
</button>
<button
type="button"
class="ml-1 size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
aria-label="Close search"
onClick={closeFind}
>
<Icon name="close-small" size="small" />
</button>
</div>
</Show>
</div>
)
}