summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src/pierre
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-26 18:23:04 -0600
committerGitHub <[email protected]>2026-02-26 18:23:04 -0600
commitfc52e4b2d3a41efde772e6de8fb2e01f27821701 (patch)
treecf23af294a00a10e55f230232585344c111f0bb9 /packages/ui/src/pierre
parent9a6bfeb782766099d4ce3a98bb9e7b4e79f8bfe6 (diff)
downloadopencode-fc52e4b2d3a41efde772e6de8fb2e01f27821701.tar.gz
opencode-fc52e4b2d3a41efde772e6de8fb2e01f27821701.zip
feat(app): better diff/code comments (#14621)
Co-authored-by: adamelmore <[email protected]> Co-authored-by: David Hill <[email protected]>
Diffstat (limited to 'packages/ui/src/pierre')
-rw-r--r--packages/ui/src/pierre/comment-hover.ts74
-rw-r--r--packages/ui/src/pierre/commented-lines.ts91
-rw-r--r--packages/ui/src/pierre/diff-selection.ts71
-rw-r--r--packages/ui/src/pierre/file-find.ts576
-rw-r--r--packages/ui/src/pierre/file-runtime.ts114
-rw-r--r--packages/ui/src/pierre/file-selection.ts85
-rw-r--r--packages/ui/src/pierre/index.ts42
-rw-r--r--packages/ui/src/pierre/media.ts110
-rw-r--r--packages/ui/src/pierre/selection-bridge.ts129
9 files changed, 1287 insertions, 5 deletions
diff --git a/packages/ui/src/pierre/comment-hover.ts b/packages/ui/src/pierre/comment-hover.ts
new file mode 100644
index 000000000..1d3674cf6
--- /dev/null
+++ b/packages/ui/src/pierre/comment-hover.ts
@@ -0,0 +1,74 @@
+export type HoverCommentLine = {
+ lineNumber: number
+ side?: "additions" | "deletions"
+}
+
+export function createHoverCommentUtility(props: {
+ label: string
+ getHoveredLine: () => HoverCommentLine | undefined
+ onSelect: (line: HoverCommentLine) => void
+}) {
+ if (typeof document === "undefined") return
+
+ const button = document.createElement("button")
+ button.type = "button"
+ button.ariaLabel = props.label
+ button.textContent = "+"
+ button.style.width = "20px"
+ button.style.height = "20px"
+ button.style.display = "flex"
+ button.style.alignItems = "center"
+ button.style.justifyContent = "center"
+ button.style.border = "none"
+ button.style.borderRadius = "var(--radius-md)"
+ button.style.background = "var(--icon-interactive-base)"
+ button.style.color = "var(--white)"
+ button.style.boxShadow = "var(--shadow-xs)"
+ button.style.fontSize = "14px"
+ button.style.lineHeight = "1"
+ button.style.cursor = "pointer"
+ button.style.position = "relative"
+ button.style.left = "30px"
+ button.style.top = "calc((var(--diffs-line-height, 24px) - 20px) / 2)"
+
+ let line: HoverCommentLine | undefined
+
+ const sync = () => {
+ const next = props.getHoveredLine()
+ if (!next) return
+ line = next
+ }
+
+ const loop = () => {
+ if (!button.isConnected) return
+ sync()
+ requestAnimationFrame(loop)
+ }
+
+ const open = () => {
+ const next = props.getHoveredLine() ?? line
+ if (!next) return
+ props.onSelect(next)
+ }
+
+ requestAnimationFrame(loop)
+ button.addEventListener("mouseenter", sync)
+ button.addEventListener("mousemove", sync)
+ button.addEventListener("pointerdown", (event) => {
+ event.preventDefault()
+ event.stopPropagation()
+ sync()
+ })
+ button.addEventListener("mousedown", (event) => {
+ event.preventDefault()
+ event.stopPropagation()
+ sync()
+ })
+ button.addEventListener("click", (event) => {
+ event.preventDefault()
+ event.stopPropagation()
+ open()
+ })
+
+ return button
+}
diff --git a/packages/ui/src/pierre/commented-lines.ts b/packages/ui/src/pierre/commented-lines.ts
new file mode 100644
index 000000000..d2fa64866
--- /dev/null
+++ b/packages/ui/src/pierre/commented-lines.ts
@@ -0,0 +1,91 @@
+import { type SelectedLineRange } from "@pierre/diffs"
+import { diffLineIndex, diffRowIndex, findDiffSide } from "./diff-selection"
+
+export type CommentSide = "additions" | "deletions"
+
+function annotationIndex(node: HTMLElement) {
+ const value = node.dataset.lineAnnotation?.split(",")[1]
+ if (!value) return
+ const line = parseInt(value, 10)
+ if (Number.isNaN(line)) return
+ return line
+}
+
+function clear(root: ShadowRoot) {
+ const marked = Array.from(root.querySelectorAll("[data-comment-selected]"))
+ for (const node of marked) {
+ if (!(node instanceof HTMLElement)) continue
+ node.removeAttribute("data-comment-selected")
+ }
+}
+
+export function markCommentedDiffLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
+ clear(root)
+
+ const diffs = root.querySelector("[data-diff]")
+ if (!(diffs instanceof HTMLElement)) return
+
+ const split = diffs.dataset.diffType === "split"
+ const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
+ (node): node is HTMLElement => node instanceof HTMLElement,
+ )
+ if (rows.length === 0) return
+
+ const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
+ (node): node is HTMLElement => node instanceof HTMLElement,
+ )
+
+ for (const range of ranges) {
+ const start = diffRowIndex(root, split, range.start, range.side as CommentSide | undefined)
+ if (start === undefined) continue
+
+ const end = (() => {
+ const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
+ if (same) return start
+ return diffRowIndex(root, split, range.end, (range.endSide ?? range.side) as CommentSide | undefined)
+ })()
+ if (end === undefined) continue
+
+ const first = Math.min(start, end)
+ const last = Math.max(start, end)
+
+ for (const row of rows) {
+ const idx = diffLineIndex(split, row)
+ if (idx === undefined || idx < first || idx > last) continue
+ row.setAttribute("data-comment-selected", "")
+ }
+
+ for (const annotation of annotations) {
+ const idx = annotationIndex(annotation)
+ if (idx === undefined || idx < first || idx > last) continue
+ annotation.setAttribute("data-comment-selected", "")
+ }
+ }
+}
+
+export function markCommentedFileLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
+ clear(root)
+
+ const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter(
+ (node): node is HTMLElement => node instanceof HTMLElement,
+ )
+
+ for (const range of ranges) {
+ const start = Math.max(1, Math.min(range.start, range.end))
+ const end = Math.max(range.start, range.end)
+
+ for (let line = start; line <= end; line++) {
+ const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`))
+ for (const node of nodes) {
+ if (!(node instanceof HTMLElement)) continue
+ node.setAttribute("data-comment-selected", "")
+ }
+ }
+
+ for (const annotation of annotations) {
+ const line = annotationIndex(annotation)
+ if (line === undefined || line < start || line > end) continue
+ annotation.setAttribute("data-comment-selected", "")
+ }
+ }
+}
diff --git a/packages/ui/src/pierre/diff-selection.ts b/packages/ui/src/pierre/diff-selection.ts
new file mode 100644
index 000000000..bc008b1b2
--- /dev/null
+++ b/packages/ui/src/pierre/diff-selection.ts
@@ -0,0 +1,71 @@
+import { type SelectedLineRange } from "@pierre/diffs"
+
+export type DiffSelectionSide = "additions" | "deletions"
+
+export function findDiffSide(node: HTMLElement): DiffSelectionSide {
+ const line = node.closest("[data-line], [data-alt-line]")
+ if (line instanceof HTMLElement) {
+ const type = line.dataset.lineType
+ if (type === "change-deletion") return "deletions"
+ if (type === "change-addition" || type === "change-additions") return "additions"
+ }
+
+ const code = node.closest("[data-code]")
+ if (!(code instanceof HTMLElement)) return "additions"
+ return code.hasAttribute("data-deletions") ? "deletions" : "additions"
+}
+
+export function diffLineIndex(split: boolean, node: HTMLElement) {
+ const raw = node.dataset.lineIndex
+ if (!raw) return
+
+ const values = raw
+ .split(",")
+ .map((x) => parseInt(x, 10))
+ .filter((x) => !Number.isNaN(x))
+ if (values.length === 0) return
+ if (!split) return values[0]
+ if (values.length === 2) return values[1]
+ return values[0]
+}
+
+export function diffRowIndex(root: ShadowRoot, split: boolean, line: number, side: DiffSelectionSide | undefined) {
+ const rows = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
+ (node): node is HTMLElement => node instanceof HTMLElement,
+ )
+ if (rows.length === 0) return
+
+ const target = side ?? "additions"
+ for (const row of rows) {
+ if (findDiffSide(row) === target) return diffLineIndex(split, row)
+ if (parseInt(row.dataset.altLine ?? "", 10) === line) return diffLineIndex(split, row)
+ }
+}
+
+export function fixDiffSelection(root: ShadowRoot | undefined, range: SelectedLineRange | null) {
+ if (!range) return range
+ if (!root) return
+
+ const diffs = root.querySelector("[data-diff]")
+ if (!(diffs instanceof HTMLElement)) return
+
+ const split = diffs.dataset.diffType === "split"
+ const start = diffRowIndex(root, split, range.start, range.side)
+ const end = diffRowIndex(root, split, range.end, range.endSide ?? range.side)
+
+ if (start === undefined || end === undefined) {
+ if (root.querySelector("[data-line], [data-alt-line]") == null) return
+ return null
+ }
+ if (start <= end) return range
+
+ const side = range.endSide ?? range.side
+ const swapped: SelectedLineRange = {
+ start: range.end,
+ end: range.start,
+ }
+
+ if (side) swapped.side = side
+ if (range.endSide && range.side) swapped.endSide = range.side
+ return swapped
+}
diff --git a/packages/ui/src/pierre/file-find.ts b/packages/ui/src/pierre/file-find.ts
new file mode 100644
index 000000000..7d55cfa72
--- /dev/null
+++ b/packages/ui/src/pierre/file-find.ts
@@ -0,0 +1,576 @@
+import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
+
+export type FindHost = {
+ element: () => HTMLElement | undefined
+ open: () => void
+ close: () => void
+ next: (dir: 1 | -1) => void
+ isOpen: () => boolean
+}
+
+type FileFindSide = "additions" | "deletions"
+
+export type FileFindReveal = {
+ side: FileFindSide
+ line: number
+ col: number
+ len: number
+}
+
+type FileFindHit = FileFindReveal & {
+ range: Range
+ alt?: number
+}
+
+const hosts = new Set<FindHost>()
+let target: FindHost | undefined
+let current: FindHost | undefined
+let installed = false
+
+function isEditable(node: unknown): boolean {
+ if (!(node instanceof HTMLElement)) return false
+ if (node.closest("[data-prevent-autofocus]")) return true
+ if (node.isContentEditable) return true
+ return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName)
+}
+
+function hostForNode(node: unknown) {
+ if (!(node instanceof Node)) return
+ for (const host of hosts) {
+ const el = host.element()
+ if (el && el.isConnected && el.contains(node)) return host
+ }
+}
+
+function installShortcuts() {
+ if (installed) return
+ if (typeof window === "undefined") return
+ installed = true
+
+ window.addEventListener(
+ "keydown",
+ (event) => {
+ if (event.defaultPrevented) return
+ if (isEditable(event.target)) return
+
+ const mod = event.metaKey || event.ctrlKey
+ if (!mod) return
+
+ const key = event.key.toLowerCase()
+ if (key === "g") {
+ const host = current
+ if (!host || !host.isOpen()) return
+ event.preventDefault()
+ event.stopPropagation()
+ host.next(event.shiftKey ? -1 : 1)
+ return
+ }
+
+ if (key !== "f") return
+
+ const active = current
+ if (active && active.isOpen()) {
+ event.preventDefault()
+ event.stopPropagation()
+ active.open()
+ return
+ }
+
+ const host = hostForNode(document.activeElement) ?? hostForNode(event.target) ?? target ?? Array.from(hosts)[0]
+ if (!host) return
+
+ event.preventDefault()
+ event.stopPropagation()
+ host.open()
+ },
+ { capture: true },
+ )
+}
+
+function clearHighlightFind() {
+ const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights
+ if (!api) return
+ api.delete("opencode-find")
+ api.delete("opencode-find-current")
+}
+
+function supportsHighlights() {
+ const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown }
+ return typeof g.Highlight === "function" && g.CSS?.highlights != null
+}
+
+function scrollParent(el: HTMLElement): HTMLElement | undefined {
+ let parent = el.parentElement
+ while (parent) {
+ const style = getComputedStyle(parent)
+ if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
+ parent = parent.parentElement
+ }
+}
+
+type CreateFileFindOptions = {
+ wrapper: () => HTMLElement | undefined
+ overlay: () => HTMLDivElement | undefined
+ getRoot: () => ShadowRoot | undefined
+ shortcuts?: "global" | "disabled"
+}
+
+export function createFileFind(opts: CreateFileFindOptions) {
+ let input: HTMLInputElement | undefined
+ let overlayFrame: number | undefined
+ let overlayScroll: HTMLElement[] = []
+ let mode: "highlights" | "overlay" = "overlay"
+ let hits: FileFindHit[] = []
+
+ const [open, setOpen] = createSignal(false)
+ const [query, setQuery] = createSignal("")
+ const [index, setIndex] = createSignal(0)
+ const [count, setCount] = createSignal(0)
+ const [pos, setPos] = createSignal({ top: 8, right: 8 })
+
+ const clearOverlayScroll = () => {
+ for (const el of overlayScroll) el.removeEventListener("scroll", scheduleOverlay)
+ overlayScroll = []
+ }
+
+ const clearOverlay = () => {
+ const el = opts.overlay()
+ if (!el) return
+ if (overlayFrame !== undefined) {
+ cancelAnimationFrame(overlayFrame)
+ overlayFrame = undefined
+ }
+ el.innerHTML = ""
+ }
+
+ const renderOverlay = () => {
+ if (mode !== "overlay") {
+ clearOverlay()
+ return
+ }
+
+ const wrapper = opts.wrapper()
+ const overlay = opts.overlay()
+ if (!wrapper || !overlay) return
+
+ clearOverlay()
+ if (hits.length === 0) return
+
+ const base = wrapper.getBoundingClientRect()
+ const currentIndex = index()
+ const frag = document.createDocumentFragment()
+
+ for (let i = 0; i < hits.length; i++) {
+ const range = hits[i].range
+ const active = i === currentIndex
+ for (const rect of Array.from(range.getClientRects())) {
+ if (!rect.width || !rect.height) continue
+
+ const mark = document.createElement("div")
+ mark.style.position = "absolute"
+ mark.style.left = `${Math.round(rect.left - base.left)}px`
+ mark.style.top = `${Math.round(rect.top - base.top)}px`
+ mark.style.width = `${Math.round(rect.width)}px`
+ mark.style.height = `${Math.round(rect.height)}px`
+ mark.style.borderRadius = "2px"
+ mark.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)"
+ mark.style.opacity = active ? "0.55" : "0.35"
+ if (active) mark.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)"
+ frag.appendChild(mark)
+ }
+ }
+
+ overlay.appendChild(frag)
+ }
+
+ function scheduleOverlay() {
+ if (mode !== "overlay") return
+ if (!open()) return
+ if (overlayFrame !== undefined) return
+
+ overlayFrame = requestAnimationFrame(() => {
+ overlayFrame = undefined
+ renderOverlay()
+ })
+ }
+
+ const syncOverlayScroll = () => {
+ if (mode !== "overlay") return
+ const root = opts.getRoot()
+
+ const next = root
+ ? Array.from(root.querySelectorAll("[data-code]")).filter(
+ (node): node is HTMLElement => node instanceof HTMLElement,
+ )
+ : []
+ if (next.length === overlayScroll.length && next.every((el, i) => el === overlayScroll[i])) return
+
+ clearOverlayScroll()
+ overlayScroll = next
+ for (const el of overlayScroll) el.addEventListener("scroll", scheduleOverlay, { passive: true })
+ }
+
+ const clearFind = () => {
+ clearHighlightFind()
+ clearOverlay()
+ clearOverlayScroll()
+ hits = []
+ setCount(0)
+ setIndex(0)
+ }
+
+ const positionBar = () => {
+ if (typeof window === "undefined") return
+ const wrapper = opts.wrapper()
+ if (!wrapper) return
+
+ const root = scrollParent(wrapper) ?? wrapper
+ const rect = root.getBoundingClientRect()
+ const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
+ const header = Number.isNaN(title) ? 0 : title
+
+ setPos({
+ top: Math.round(rect.top) + header - 4,
+ right: Math.round(window.innerWidth - rect.right) + 8,
+ })
+ }
+
+ const scan = (root: ShadowRoot, value: string) => {
+ const needle = value.toLowerCase()
+ const ranges: FileFindHit[] = []
+ const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter(
+ (node): node is HTMLElement => node instanceof HTMLElement,
+ )
+
+ for (const col of cols) {
+ const text = col.textContent
+ if (!text) continue
+
+ const hay = text.toLowerCase()
+ let at = hay.indexOf(needle)
+ if (at === -1) continue
+
+ const row = col.closest("[data-line], [data-alt-line]")
+ if (!(row instanceof HTMLElement)) continue
+
+ const primary = parseInt(row.dataset.line ?? "", 10)
+ const alt = parseInt(row.dataset.altLine ?? "", 10)
+ const line = (() => {
+ if (!Number.isNaN(primary)) return primary
+ if (!Number.isNaN(alt)) return alt
+ })()
+ if (line === undefined) continue
+
+ const side = (() => {
+ const code = col.closest("[data-code]")
+ if (code instanceof HTMLElement) return code.hasAttribute("data-deletions") ? "deletions" : "additions"
+
+ const row = col.closest("[data-line-type]")
+ if (!(row instanceof HTMLElement)) return "additions"
+ const type = row.dataset.lineType
+ if (type === "change-deletion") return "deletions"
+ return "additions"
+ })() as FileFindSide
+
+ const nodes: Text[] = []
+ const ends: number[] = []
+ const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT)
+ let node = walker.nextNode()
+ let pos = 0
+ while (node) {
+ if (node instanceof Text) {
+ pos += node.data.length
+ nodes.push(node)
+ ends.push(pos)
+ }
+ node = walker.nextNode()
+ }
+ if (nodes.length === 0) continue
+
+ const locate = (offset: number) => {
+ let lo = 0
+ let hi = ends.length - 1
+ while (lo < hi) {
+ const mid = (lo + hi) >> 1
+ if (ends[mid] >= offset) hi = mid
+ else lo = mid + 1
+ }
+ const prev = lo === 0 ? 0 : ends[lo - 1]
+ return { node: nodes[lo], offset: offset - prev }
+ }
+
+ while (at !== -1) {
+ const start = locate(at)
+ const end = locate(at + value.length)
+ const range = document.createRange()
+ range.setStart(start.node, start.offset)
+ range.setEnd(end.node, end.offset)
+ ranges.push({
+ range,
+ side,
+ line,
+ alt: Number.isNaN(alt) ? undefined : alt,
+ col: at + 1,
+ len: value.length,
+ })
+ at = hay.indexOf(needle, at + value.length)
+ }
+ }
+
+ return ranges
+ }
+
+ const scrollToRange = (range: Range) => {
+ const scroll = () => {
+ const start = range.startContainer
+ const el = start instanceof Element ? start : start.parentElement
+ el?.scrollIntoView({ block: "center", inline: "center" })
+ }
+
+ scroll()
+ requestAnimationFrame(scroll)
+ }
+
+ const setHighlights = (ranges: FileFindHit[], currentIndex: number) => {
+ const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights
+ const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight
+ if (!api || typeof Highlight !== "function") return false
+
+ api.delete("opencode-find")
+ api.delete("opencode-find-current")
+
+ const active = ranges[currentIndex]?.range
+ if (active) api.set("opencode-find-current", new Highlight(active))
+
+ const rest = ranges.flatMap((hit, i) => (i === currentIndex ? [] : [hit.range]))
+ if (rest.length > 0) api.set("opencode-find", new Highlight(...rest))
+ return true
+ }
+
+ const select = (currentIndex: number, scroll: boolean) => {
+ const active = hits[currentIndex]?.range
+ if (!active) return false
+
+ setIndex(currentIndex)
+
+ if (mode === "highlights") {
+ if (!setHighlights(hits, currentIndex)) {
+ mode = "overlay"
+ apply({ reset: true, scroll })
+ return false
+ }
+ if (scroll) scrollToRange(active)
+ return true
+ }
+
+ clearHighlightFind()
+ syncOverlayScroll()
+ if (scroll) scrollToRange(active)
+ scheduleOverlay()
+ return true
+ }
+
+ const apply = (args?: { reset?: boolean; scroll?: boolean }) => {
+ if (!open()) return
+
+ const value = query().trim()
+ if (!value) {
+ clearFind()
+ return
+ }
+
+ const root = opts.getRoot()
+ if (!root) return
+
+ mode = supportsHighlights() ? "highlights" : "overlay"
+
+ const ranges = scan(root, value)
+ const total = ranges.length
+ const desired = args?.reset ? 0 : index()
+ const currentIndex = total ? Math.min(desired, total - 1) : 0
+
+ hits = ranges
+ setCount(total)
+ setIndex(currentIndex)
+
+ const active = ranges[currentIndex]?.range
+ if (mode === "highlights") {
+ clearOverlay()
+ clearOverlayScroll()
+ if (!setHighlights(ranges, currentIndex)) {
+ mode = "overlay"
+ clearHighlightFind()
+ syncOverlayScroll()
+ scheduleOverlay()
+ }
+ if (args?.scroll && active) scrollToRange(active)
+ return
+ }
+
+ clearHighlightFind()
+ syncOverlayScroll()
+ if (args?.scroll && active) scrollToRange(active)
+ scheduleOverlay()
+ }
+
+ const close = () => {
+ setOpen(false)
+ setQuery("")
+ clearFind()
+ if (current === host) current = undefined
+ }
+
+ const clear = () => {
+ setQuery("")
+ clearFind()
+ }
+
+ const activate = () => {
+ if (opts.shortcuts !== "disabled") {
+ if (current && current !== host) current.close()
+ current = host
+ target = host
+ }
+
+ if (!open()) setOpen(true)
+ }
+
+ const focus = () => {
+ activate()
+ requestAnimationFrame(() => {
+ apply({ scroll: true })
+ input?.focus()
+ input?.select()
+ })
+ }
+
+ const next = (dir: 1 | -1) => {
+ if (!open()) return
+ const total = count()
+ if (total <= 0) return
+
+ const currentIndex = (index() + dir + total) % total
+ select(currentIndex, true)
+ }
+
+ const reveal = (targetHit: FileFindReveal) => {
+ if (!open()) return false
+ if (hits.length === 0) return false
+
+ const exact = hits.findIndex(
+ (hit) =>
+ hit.side === targetHit.side &&
+ hit.line === targetHit.line &&
+ hit.col === targetHit.col &&
+ hit.len === targetHit.len,
+ )
+ const fallback = hits.findIndex(
+ (hit) =>
+ (hit.line === targetHit.line || hit.alt === targetHit.line) &&
+ hit.col === targetHit.col &&
+ hit.len === targetHit.len,
+ )
+
+ const nextIndex = exact >= 0 ? exact : fallback
+ if (nextIndex < 0) return false
+ return select(nextIndex, true)
+ }
+
+ const host: FindHost = {
+ element: opts.wrapper,
+ isOpen: () => open(),
+ next,
+ open: focus,
+ close,
+ }
+
+ onMount(() => {
+ mode = supportsHighlights() ? "highlights" : "overlay"
+ if (opts.shortcuts !== "disabled") {
+ installShortcuts()
+ hosts.add(host)
+ if (!target) target = host
+ }
+
+ onCleanup(() => {
+ if (opts.shortcuts !== "disabled") {
+ hosts.delete(host)
+ if (current === host) {
+ current = undefined
+ clearHighlightFind()
+ }
+ if (target === host) target = undefined
+ }
+ })
+ })
+
+ createEffect(() => {
+ if (!open()) return
+
+ const update = () => positionBar()
+ requestAnimationFrame(update)
+ window.addEventListener("resize", update, { passive: true })
+
+ const wrapper = opts.wrapper()
+ if (!wrapper) return
+ const root = scrollParent(wrapper) ?? wrapper
+ const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(() => update())
+ observer?.observe(root)
+
+ onCleanup(() => {
+ window.removeEventListener("resize", update)
+ observer?.disconnect()
+ })
+ })
+
+ onCleanup(() => {
+ clearOverlayScroll()
+ clearOverlay()
+ if (current === host) {
+ current = undefined
+ clearHighlightFind()
+ }
+ })
+
+ return {
+ open,
+ query,
+ count,
+ index,
+ pos,
+ setInput: (el: HTMLInputElement) => {
+ input = el
+ },
+ setQuery: (value: string, args?: { scroll?: boolean }) => {
+ setQuery(value)
+ setIndex(0)
+ apply({ reset: true, scroll: args?.scroll ?? true })
+ },
+ clear,
+ activate,
+ focus,
+ close,
+ next,
+ reveal,
+ refresh: (args?: { reset?: boolean; scroll?: boolean }) => apply(args),
+ onPointerDown: () => {
+ if (opts.shortcuts === "disabled") return
+ target = host
+ opts.wrapper()?.focus({ preventScroll: true })
+ },
+ onFocus: () => {
+ if (opts.shortcuts === "disabled") return
+ target = host
+ },
+ onInputKeyDown: (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.preventDefault()
+ close()
+ return
+ }
+ if (event.key !== "Enter") return
+ event.preventDefault()
+ next(event.shiftKey ? -1 : 1)
+ },
+ }
+}
diff --git a/packages/ui/src/pierre/file-runtime.ts b/packages/ui/src/pierre/file-runtime.ts
new file mode 100644
index 000000000..a20721003
--- /dev/null
+++ b/packages/ui/src/pierre/file-runtime.ts
@@ -0,0 +1,114 @@
+type ReadyWatcher = {
+ observer?: MutationObserver
+ token: number
+}
+
+export function createReadyWatcher(): ReadyWatcher {
+ return { token: 0 }
+}
+
+export function clearReadyWatcher(state: ReadyWatcher) {
+ state.observer?.disconnect()
+ state.observer = undefined
+}
+
+export function getViewerHost(container: HTMLElement | undefined) {
+ if (!container) return
+ const host = container.querySelector("diffs-container")
+ if (!(host instanceof HTMLElement)) return
+ return host
+}
+
+export function getViewerRoot(container: HTMLElement | undefined) {
+ return getViewerHost(container)?.shadowRoot ?? undefined
+}
+
+export function applyViewerScheme(host: HTMLElement | undefined) {
+ if (!host) return
+ if (typeof document === "undefined") return
+
+ const scheme = document.documentElement.dataset.colorScheme
+ if (scheme === "dark" || scheme === "light") {
+ host.dataset.colorScheme = scheme
+ return
+ }
+
+ host.removeAttribute("data-color-scheme")
+}
+
+export function observeViewerScheme(getHost: () => HTMLElement | undefined) {
+ if (typeof document === "undefined") return () => {}
+
+ applyViewerScheme(getHost())
+ if (typeof MutationObserver === "undefined") return () => {}
+
+ const root = document.documentElement
+ const monitor = new MutationObserver(() => applyViewerScheme(getHost()))
+ monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
+ return () => monitor.disconnect()
+}
+
+export function notifyShadowReady(opts: {
+ state: ReadyWatcher
+ container: HTMLElement
+ getRoot: () => ShadowRoot | undefined
+ isReady: (root: ShadowRoot) => boolean
+ onReady: () => void
+ settleFrames?: number
+}) {
+ clearReadyWatcher(opts.state)
+ opts.state.token += 1
+
+ const token = opts.state.token
+ const settle = Math.max(0, opts.settleFrames ?? 0)
+
+ const runReady = () => {
+ const step = (left: number) => {
+ if (token !== opts.state.token) return
+ if (left <= 0) {
+ opts.onReady()
+ return
+ }
+ requestAnimationFrame(() => step(left - 1))
+ }
+
+ requestAnimationFrame(() => step(settle))
+ }
+
+ const observeRoot = (root: ShadowRoot) => {
+ if (opts.isReady(root)) {
+ runReady()
+ return
+ }
+
+ if (typeof MutationObserver === "undefined") return
+
+ clearReadyWatcher(opts.state)
+ opts.state.observer = new MutationObserver(() => {
+ if (token !== opts.state.token) return
+ if (!opts.isReady(root)) return
+
+ clearReadyWatcher(opts.state)
+ runReady()
+ })
+ opts.state.observer.observe(root, { childList: true, subtree: true })
+ }
+
+ const root = opts.getRoot()
+ if (!root) {
+ if (typeof MutationObserver === "undefined") return
+
+ opts.state.observer = new MutationObserver(() => {
+ if (token !== opts.state.token) return
+
+ const next = opts.getRoot()
+ if (!next) return
+
+ observeRoot(next)
+ })
+ opts.state.observer.observe(opts.container, { childList: true, subtree: true })
+ return
+ }
+
+ observeRoot(root)
+}
diff --git a/packages/ui/src/pierre/file-selection.ts b/packages/ui/src/pierre/file-selection.ts
new file mode 100644
index 000000000..fdc34729e
--- /dev/null
+++ b/packages/ui/src/pierre/file-selection.ts
@@ -0,0 +1,85 @@
+import { type SelectedLineRange } from "@pierre/diffs"
+import { toRange } from "./selection-bridge"
+
+export function findElement(node: Node | null): HTMLElement | undefined {
+ if (!node) return
+ if (node instanceof HTMLElement) return node
+ return node.parentElement ?? undefined
+}
+
+export function findFileLineNumber(node: Node | null): number | undefined {
+ const el = findElement(node)
+ if (!el) return
+
+ const line = el.closest("[data-line]")
+ if (!(line instanceof HTMLElement)) return
+
+ const value = parseInt(line.dataset.line ?? "", 10)
+ if (Number.isNaN(value)) return
+ return value
+}
+
+export function findDiffLineNumber(node: Node | null): number | undefined {
+ const el = findElement(node)
+ if (!el) return
+
+ const line = el.closest("[data-line], [data-alt-line]")
+ if (!(line instanceof HTMLElement)) return
+
+ const primary = parseInt(line.dataset.line ?? "", 10)
+ if (!Number.isNaN(primary)) return primary
+
+ const alt = parseInt(line.dataset.altLine ?? "", 10)
+ if (!Number.isNaN(alt)) return alt
+}
+
+export function findCodeSelectionSide(node: Node | null): SelectedLineRange["side"] {
+ const el = findElement(node)
+ if (!el) return
+
+ const code = el.closest("[data-code]")
+ if (!(code instanceof HTMLElement)) return
+ if (code.hasAttribute("data-deletions")) return "deletions"
+ return "additions"
+}
+
+export function readShadowLineSelection(opts: {
+ root: ShadowRoot
+ lineForNode: (node: Node | null) => number | undefined
+ sideForNode?: (node: Node | null) => SelectedLineRange["side"]
+ preserveTextSelection?: boolean
+}) {
+ const selection =
+ (opts.root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
+ if (!selection || selection.isCollapsed) return
+
+ const domRange =
+ (
+ selection as unknown as {
+ getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => StaticRange[]
+ }
+ ).getComposedRanges?.({ shadowRoots: [opts.root] })?.[0] ??
+ (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
+
+ const startNode = domRange?.startContainer ?? selection.anchorNode
+ const endNode = domRange?.endContainer ?? selection.focusNode
+ if (!startNode || !endNode) return
+ if (!opts.root.contains(startNode) || !opts.root.contains(endNode)) return
+
+ const start = opts.lineForNode(startNode)
+ const end = opts.lineForNode(endNode)
+ if (start === undefined || end === undefined) return
+
+ const startSide = opts.sideForNode?.(startNode)
+ const endSide = opts.sideForNode?.(endNode)
+ const side = startSide ?? endSide
+
+ const range: SelectedLineRange = { start, end }
+ if (side) range.side = side
+ if (endSide && side && endSide !== side) range.endSide = endSide
+
+ return {
+ range,
+ text: opts.preserveTextSelection && domRange ? toRange(domRange).cloneRange() : undefined,
+ }
+}
diff --git a/packages/ui/src/pierre/index.ts b/packages/ui/src/pierre/index.ts
index f226a9ae1..22586f0f5 100644
--- a/packages/ui/src/pierre/index.ts
+++ b/packages/ui/src/pierre/index.ts
@@ -1,5 +1,6 @@
import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs"
import { ComponentProps } from "solid-js"
+import { lineCommentStyles } from "../components/line-comment-styles"
export type DiffProps<T = {}> = FileDiffOptions<T> & {
before: FileContents
@@ -7,13 +8,15 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
annotations?: DiffLineAnnotation<T>[]
selectedLines?: SelectedLineRange | null
commentedLines?: SelectedLineRange[]
+ onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
onRendered?: () => void
class?: string
classList?: ComponentProps<"div">["classList"]
}
const unsafeCSS = `
-[data-diff] {
+[data-diff],
+[data-file] {
--diffs-bg: light-dark(var(--diffs-light-bg), var(--diffs-dark-bg));
--diffs-bg-buffer: var(--diffs-bg-buffer-override, light-dark( color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer))));
--diffs-bg-hover: var(--diffs-bg-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 97%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-mixer))));
@@ -44,7 +47,8 @@ const unsafeCSS = `
--diffs-bg-selection-text: rgb(from var(--surface-warning-strong) r g b / 0.2);
}
-:host([data-color-scheme='dark']) [data-diff] {
+:host([data-color-scheme='dark']) [data-diff],
+:host([data-color-scheme='dark']) [data-file] {
--diffs-selection-number-fg: #fdfbfb;
--diffs-bg-selection: var(--diffs-bg-selection-override, rgb(from var(--solaris-dark-6) r g b / 0.65));
--diffs-bg-selection-number: var(
@@ -53,7 +57,8 @@ const unsafeCSS = `
);
}
-[data-diff] ::selection {
+[data-diff] ::selection,
+[data-file] ::selection {
background-color: var(--diffs-bg-selection-text);
}
@@ -69,25 +74,48 @@ const unsafeCSS = `
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
}
+[data-file] [data-line][data-comment-selected]:not([data-selected-line]) {
+ box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
+}
+
[data-diff] [data-column-number][data-comment-selected]:not([data-selected-line]) {
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
color: var(--diffs-selection-number-fg);
}
+[data-file] [data-column-number][data-comment-selected]:not([data-selected-line]) {
+ box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
+ color: var(--diffs-selection-number-fg);
+}
+
[data-diff] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] {
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
}
+[data-file] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] {
+ box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
+}
+
[data-diff] [data-line][data-selected-line] {
background-color: var(--diffs-bg-selection);
box-shadow: inset 2px 0 0 var(--diffs-selection-border);
}
+[data-file] [data-line][data-selected-line] {
+ background-color: var(--diffs-bg-selection);
+ box-shadow: inset 2px 0 0 var(--diffs-selection-border);
+}
+
[data-diff] [data-column-number][data-selected-line] {
background-color: var(--diffs-bg-selection-number);
color: var(--diffs-selection-number-fg);
}
+[data-file] [data-column-number][data-selected-line] {
+ background-color: var(--diffs-bg-selection-number);
+ color: var(--diffs-selection-number-fg);
+}
+
[data-diff] [data-column-number][data-line-type='context'][data-selected-line],
[data-diff] [data-column-number][data-line-type='context-expanded'][data-selected-line],
[data-diff] [data-column-number][data-line-type='change-addition'][data-selected-line],
@@ -123,9 +151,13 @@ const unsafeCSS = `
}
[data-code] {
overflow-x: auto !important;
- overflow-y: hidden !important;
+ overflow-y: clip !important;
}
-}`
+}
+
+${lineCommentStyles}
+
+`
export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) {
return {
diff --git a/packages/ui/src/pierre/media.ts b/packages/ui/src/pierre/media.ts
new file mode 100644
index 000000000..1ee63c25b
--- /dev/null
+++ b/packages/ui/src/pierre/media.ts
@@ -0,0 +1,110 @@
+import type { FileContent } from "@opencode-ai/sdk/v2"
+
+export type MediaKind = "image" | "audio" | "svg"
+
+const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"])
+const audioExtensions = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"])
+
+type MediaValue = unknown
+
+function mediaRecord(value: unknown) {
+ if (!value || typeof value !== "object") return
+ return value as Partial<FileContent> & {
+ content?: unknown
+ encoding?: unknown
+ mimeType?: unknown
+ type?: unknown
+ }
+}
+
+export function normalizeMimeType(type: string | undefined) {
+ if (!type) return
+ const mime = type.split(";", 1)[0]?.trim().toLowerCase()
+ if (!mime) return
+ if (mime === "audio/x-aac") return "audio/aac"
+ if (mime === "audio/x-m4a") return "audio/mp4"
+ return mime
+}
+
+export function fileExtension(path: string | undefined) {
+ if (!path) return ""
+ const idx = path.lastIndexOf(".")
+ if (idx === -1) return ""
+ return path.slice(idx + 1).toLowerCase()
+}
+
+export function mediaKindFromPath(path: string | undefined): MediaKind | undefined {
+ const ext = fileExtension(path)
+ if (ext === "svg") return "svg"
+ if (imageExtensions.has(ext)) return "image"
+ if (audioExtensions.has(ext)) return "audio"
+}
+
+export function isBinaryContent(value: MediaValue) {
+ return mediaRecord(value)?.type === "binary"
+}
+
+function validDataUrl(value: string, kind: MediaKind) {
+ if (kind === "svg") return value.startsWith("data:image/svg+xml") ? value : undefined
+ if (kind === "image") return value.startsWith("data:image/") ? value : undefined
+ if (value.startsWith("data:audio/x-aac;")) return value.replace("data:audio/x-aac;", "data:audio/aac;")
+ if (value.startsWith("data:audio/x-m4a;")) return value.replace("data:audio/x-m4a;", "data:audio/mp4;")
+ if (value.startsWith("data:audio/")) return value
+}
+
+export function dataUrlFromMediaValue(value: MediaValue, kind: MediaKind) {
+ if (!value) return
+
+ if (typeof value === "string") {
+ return validDataUrl(value, kind)
+ }
+
+ const record = mediaRecord(value)
+ if (!record) return
+
+ if (typeof record.content !== "string") return
+
+ const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined)
+ if (!mime) return
+
+ if (kind === "svg") {
+ if (mime !== "image/svg+xml") return
+ if (record.encoding === "base64") return `data:image/svg+xml;base64,${record.content}`
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(record.content)}`
+ }
+
+ if (kind === "image" && !mime.startsWith("image/")) return
+ if (kind === "audio" && !mime.startsWith("audio/")) return
+ if (record.encoding !== "base64") return
+
+ return `data:${mime};base64,${record.content}`
+}
+
+function decodeBase64Utf8(value: string) {
+ if (typeof atob !== "function") return
+
+ try {
+ const raw = atob(value)
+ const bytes = Uint8Array.from(raw, (x) => x.charCodeAt(0))
+ if (typeof TextDecoder === "function") return new TextDecoder().decode(bytes)
+ return raw
+ } catch {}
+}
+
+export function svgTextFromValue(value: MediaValue) {
+ const record = mediaRecord(value)
+ if (!record) return
+ if (typeof record.content !== "string") return
+
+ const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined)
+ if (mime !== "image/svg+xml") return
+ if (record.encoding === "base64") return decodeBase64Utf8(record.content)
+ return record.content
+}
+
+export function hasMediaValue(value: MediaValue) {
+ if (typeof value === "string") return value.length > 0
+ const record = mediaRecord(value)
+ if (!record) return false
+ return typeof record.content === "string" && record.content.length > 0
+}
diff --git a/packages/ui/src/pierre/selection-bridge.ts b/packages/ui/src/pierre/selection-bridge.ts
new file mode 100644
index 000000000..d493ead3d
--- /dev/null
+++ b/packages/ui/src/pierre/selection-bridge.ts
@@ -0,0 +1,129 @@
+import { type SelectedLineRange } from "@pierre/diffs"
+
+type PointerMode = "none" | "text" | "numbers"
+type Side = SelectedLineRange["side"]
+type LineSpan = Pick<SelectedLineRange, "start" | "end">
+
+export function formatSelectedLineLabel(range: LineSpan) {
+ const start = Math.min(range.start, range.end)
+ const end = Math.max(range.start, range.end)
+ if (start === end) return `line ${start}`
+ return `lines ${start}-${end}`
+}
+
+export function previewSelectedLines(source: string, range: LineSpan) {
+ const start = Math.max(1, Math.min(range.start, range.end))
+ const end = Math.max(range.start, range.end)
+ const lines = source.split("\n").slice(start - 1, end)
+ if (lines.length === 0) return
+ return lines.slice(0, 2).join("\n")
+}
+
+export function cloneSelectedLineRange(range: SelectedLineRange): SelectedLineRange {
+ const next: SelectedLineRange = {
+ start: range.start,
+ end: range.end,
+ }
+
+ if (range.side) next.side = range.side
+ if (range.endSide) next.endSide = range.endSide
+ return next
+}
+
+export function lineInSelectedRange(range: SelectedLineRange | null | undefined, line: number, side?: Side) {
+ if (!range) return false
+
+ const start = Math.min(range.start, range.end)
+ const end = Math.max(range.start, range.end)
+ if (line < start || line > end) return false
+ if (!side) return true
+
+ const first = range.side
+ const last = range.endSide ?? first
+ if (!first && !last) return true
+ if (!first || !last) return (first ?? last) === side
+ if (first === last) return first === side
+ if (line === start) return first === side
+ if (line === end) return last === side
+ return true
+}
+
+export function isSingleLineSelection(range: SelectedLineRange | null) {
+ if (!range) return false
+ return range.start === range.end && (range.endSide == null || range.endSide === range.side)
+}
+
+export function toRange(source: Range | StaticRange): Range {
+ if (source instanceof Range) return source
+ const range = new Range()
+ range.setStart(source.startContainer, source.startOffset)
+ range.setEnd(source.endContainer, source.endOffset)
+ return range
+}
+
+export function restoreShadowTextSelection(root: ShadowRoot | undefined, range: Range | undefined) {
+ if (!root || !range) return
+
+ requestAnimationFrame(() => {
+ const selection =
+ (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
+ if (!selection) return
+
+ try {
+ selection.removeAllRanges()
+ selection.addRange(range)
+ } catch {}
+ })
+}
+
+export function createLineNumberSelectionBridge() {
+ let mode: PointerMode = "none"
+ let line: number | undefined
+ let moved = false
+ let pending = false
+
+ const clear = () => {
+ mode = "none"
+ line = undefined
+ moved = false
+ }
+
+ return {
+ begin(numberColumn: boolean, next: number | undefined) {
+ if (!numberColumn) {
+ mode = "text"
+ return
+ }
+
+ mode = "numbers"
+ line = next
+ moved = false
+ },
+ track(buttons: number, next: number | undefined) {
+ if (mode !== "numbers") return false
+
+ if ((buttons & 1) === 0) {
+ clear()
+ return true
+ }
+
+ if (next !== undefined && line !== undefined && next !== line) moved = true
+ return true
+ },
+ finish() {
+ const current = mode
+ pending = current === "numbers" && moved
+ clear()
+ return current
+ },
+ consume(range: SelectedLineRange | null) {
+ const result = pending && !isSingleLineSelection(range)
+ pending = false
+ return result
+ },
+ reset() {
+ pending = false
+ clear()
+ },
+ }
+}