From 0405b425f528ce9042ff0eeb511512e239cb1b5f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:40:18 -0600 Subject: [PATCH] feat(app): file search --- packages/ui/src/components/code.tsx | 462 +++++++++++++++++++++++++++- packages/ui/src/pierre/index.ts | 8 + 2 files changed, 467 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index dbf942dbb..38dfcd838 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -1,7 +1,8 @@ import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs" -import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js" +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" @@ -46,8 +47,88 @@ function findSide(node: Node | null): SelectionSide | undefined { return "additions" } +type FindHost = { + element: () => HTMLElement | undefined + open: () => void + close: () => void + next: (dir: 1 | -1) => void + isOpen: () => boolean +} + +const findHosts = new Set() +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(props: CodeProps) { + 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 @@ -70,6 +151,13 @@ export function Code(props: CodeProps) { 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( @@ -104,6 +192,296 @@ export function Code(props: CodeProps) { 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 @@ -189,6 +567,7 @@ export function Code(props: CodeProps) { requestAnimationFrame(() => { if (token !== renderToken) return applySelection(lastSelection) + applyFind({ reset: true }) local.onRendered?.() }) } @@ -466,6 +845,13 @@ export function Code(props: CodeProps) { onCleanup(() => { observer?.disconnect() + clearOverlayScroll() + clearOverlay() + if (findCurrent === host) { + findCurrent = undefined + clearHighlightFind() + } + if (selectionFrame !== undefined) { cancelAnimationFrame(selectionFrame) selectionFrame = undefined @@ -487,11 +873,81 @@ export function Code(props: CodeProps) {
+ ref={wrapper} + tabIndex={0} + onPointerDown={() => { + findTarget = host + wrapper.focus({ preventScroll: true }) + }} + onFocus={() => { + findTarget = host + }} + > +
+
+ +
e.stopPropagation()} + > + + { + 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) + }} + /> +
+ {findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"} +
+ + + +
+
+
) } diff --git a/packages/ui/src/pierre/index.ts b/packages/ui/src/pierre/index.ts index f0da51979..f6446f3cc 100644 --- a/packages/ui/src/pierre/index.ts +++ b/packages/ui/src/pierre/index.ts @@ -57,6 +57,14 @@ const unsafeCSS = ` background-color: var(--diffs-bg-selection-text); } +::highlight(opencode-find) { + background-color: rgb(from var(--surface-warning-base) r g b / 0.35); +} + +::highlight(opencode-find-current) { + background-color: rgb(from var(--surface-warning-strong) r g b / 0.55); +} + [data-diffs] [data-comment-selected]:not([data-selected-line]) [data-column-content] { box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection); }