opencode/packages/ui/src/pierre/file-find.ts

486 lines
13 KiB
TypeScript

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<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
}
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<HTMLElement[]>([])
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)
},
}
}