diff options
| author | Adam <[email protected]> | 2026-02-26 18:23:04 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-26 18:23:04 -0600 |
| commit | fc52e4b2d3a41efde772e6de8fb2e01f27821701 (patch) | |
| tree | cf23af294a00a10e55f230232585344c111f0bb9 /packages/ui/src/pierre | |
| parent | 9a6bfeb782766099d4ce3a98bb9e7b4e79f8bfe6 (diff) | |
| download | opencode-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.ts | 74 | ||||
| -rw-r--r-- | packages/ui/src/pierre/commented-lines.ts | 91 | ||||
| -rw-r--r-- | packages/ui/src/pierre/diff-selection.ts | 71 | ||||
| -rw-r--r-- | packages/ui/src/pierre/file-find.ts | 576 | ||||
| -rw-r--r-- | packages/ui/src/pierre/file-runtime.ts | 114 | ||||
| -rw-r--r-- | packages/ui/src/pierre/file-selection.ts | 85 | ||||
| -rw-r--r-- | packages/ui/src/pierre/index.ts | 42 | ||||
| -rw-r--r-- | packages/ui/src/pierre/media.ts | 110 | ||||
| -rw-r--r-- | packages/ui/src/pierre/selection-bridge.ts | 129 |
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() + }, + } +} |
