import { createEffect, createSignal, onCleanup, onMount } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" import { createStore } from "solid-js/store" export type FindHost = { element: () => HTMLElement | undefined open: () => void close: () => void next: (dir: 1 | -1) => void isOpen: () => boolean } const hosts = new Set() 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 } export function createFileFind(opts: CreateFileFindOptions) { let input: HTMLInputElement | undefined let overlayFrame: number | undefined let mode: "highlights" | "overlay" = "overlay" let hits: Range[] = [] const [overlayScroll, setOverlayScroll] = createSignal([]) const [state, setState] = createStore({ open: false, query: "", index: 0, count: 0, pos: { top: 8, right: 8 }, }) const open = () => state.open const query = () => state.query const index = () => state.index const count = () => state.count const pos = () => state.pos const clearOverlayScroll = () => { setOverlayScroll([]) } 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] 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, ) : [] const current = overlayScroll() if (next.length === current.length && next.every((el, i) => el === current[i])) return clearOverlayScroll() setOverlayScroll(next) } const clearFind = () => { clearHighlightFind() clearOverlay() clearOverlayScroll() hits = [] setState("count", 0) setState("index", 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 setState("pos", { 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: Range[] = [] 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 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) at = hay.indexOf(needle, at + value.length) } } return ranges } 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[], 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] if (active) api.set("opencode-find-current", new Highlight(active)) const rest = ranges.filter((_, i) => i !== currentIndex) if (rest.length > 0) api.set("opencode-find", new Highlight(...rest)) 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 setState("count", total) setState("index", currentIndex) const active = ranges[currentIndex] 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 = () => { setState("open", false) setState("query", "") clearFind() if (current === host) current = undefined } const focus = () => { if (current && current !== host) current.close() current = host target = host if (!open()) setState("open", true) 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 setState("index", currentIndex) const active = hits[currentIndex] if (!active) return if (mode === "highlights") { if (!setHighlights(hits, currentIndex)) { mode = "overlay" apply({ reset: true, scroll: true }) return } scrollToRange(active) return } clearHighlightFind() syncOverlayScroll() scrollToRange(active) scheduleOverlay() } const host: FindHost = { element: opts.wrapper, isOpen: () => open(), next, open: focus, close, } createEffect(() => { for (const el of overlayScroll()) makeEventListener(el, "scroll", scheduleOverlay, { passive: true }) }) onMount(() => { mode = supportsHighlights() ? "highlights" : "overlay" installShortcuts() hosts.add(host) if (!target) target = host onCleanup(() => { hosts.delete(host) if (current === host) { current = undefined clearHighlightFind() } if (target === host) target = undefined }) }) createEffect(() => { if (!open()) return const update = () => positionBar() requestAnimationFrame(update) makeEventListener(window, "resize", update, { passive: true }) const wrapper = opts.wrapper() if (!wrapper) return const root = scrollParent(wrapper) ?? wrapper createResizeObserver(root, update) }) onCleanup(() => { clearOverlayScroll() clearOverlay() if (current === host) { current = undefined clearHighlightFind() } }) return { open, query, count, index, pos, setInput: (el: HTMLInputElement) => { input = el }, setQuery: (value: string) => { setState("query", value) setState("index", 0) apply({ reset: true, scroll: true }) }, focus, close, next, refresh: (args?: { reset?: boolean; scroll?: boolean }) => apply(args), onPointerDown: () => { target = host opts.wrapper()?.focus({ preventScroll: true }) }, onFocus: () => { 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) }, } }