/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * SearchInline — always-visible search field in the MainToolbar. * * P0: Tier-0 linear scan over cached EntityTable columns. * P1: Tier-1 per-model inverted token index, built post-load. * P2: Vim-style n/N cycle after Enter-commit, plus recent-search MRU * surfaced in the popover when the field is focused with empty query. * * Keyboard: * • `/` or ⌘F / Ctrl+F → focus the field (focus-suppressed when an * input/textarea/CodeMirror editor already has focus) * • ↑ / ↓ → navigate result rows in the popover * • Enter → select + frame the highlighted result, * enter vim cycle mode, record recent * • ⇧Enter → add to multi-selection (no frame, no cycle) * • Esc → close popover; second Esc blurs the field; * while cycling, Esc exits the cycle * • n / N → step forward / backward through the cycle, * framing each match (fires anywhere except * inside other editable surfaces) */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Search, Clock, X, SlidersHorizontal } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { Input } from '@/components/ui/input'; import { useViewerStore } from '@/store'; import { toGlobalIdFromModels } from '@/store/globalId'; import { cn } from '@/lib/utils'; import { runTier0Scan, type SearchResult, type ScanModel } from '@/lib/search/tier0-scan'; import { queryTier1Indexes, type Tier1Index } from '@/lib/search/tier1-index'; import { useSearchIndex } from '@/hooks/useSearchIndex'; import { loadRecentSearches, pushRecentSearch, clearRecentSearches, } from '@/lib/search/recent-searches'; const DEBOUNCE_MS = 80; const RESULT_LIMIT = 50; /** True when an editable surface has focus and should swallow `/` / `n` keystrokes. */ function isEditableFocused(): boolean { const el = document.activeElement; if (!el) return false; const tag = el.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; if ((el as HTMLElement).isContentEditable) return true; // CodeMirror 6 editor — its content host wears `.cm-content`. if (el.closest?.('.cm-editor')) return true; return false; } export function SearchInline() { const inputRef = useRef(null); const containerRef = useRef(null); // Tracks the latest scheduled `frameSelection` timer so back-to-back // selection changes (or unmount) don't leak orphaned timeouts. Without // this, picking a different result inside the 50ms window — or // unmounting the component — leaves a stale callback queued that fires // on a now-unrelated camera state. const frameTimerRef = useRef(null); const { searchQuery, searchOpen, searchHighlightIndex, searchIndexes, searchVimCycle, setSearchQuery, setSearchOpen, setSearchHighlightIndex, closeSearch, enterVimCycle, exitVimCycle, stepVimCycle, setSearchModalOpen, setSearchModalTab, activeRuleCount, clearFilterRules, models, setSelectedEntity, setSelectedEntityId, toggleEntitySelection, cameraCallbacks, } = useViewerStore( useShallow((s) => ({ searchQuery: s.searchQuery, searchOpen: s.searchOpen, searchHighlightIndex: s.searchHighlightIndex, searchIndexes: s.searchIndexes, searchVimCycle: s.searchVimCycle, setSearchQuery: s.setSearchQuery, setSearchOpen: s.setSearchOpen, setSearchHighlightIndex: s.setSearchHighlightIndex, closeSearch: s.closeSearch, enterVimCycle: s.enterVimCycle, exitVimCycle: s.exitVimCycle, stepVimCycle: s.stepVimCycle, setSearchModalOpen: s.setSearchModalOpen, setSearchModalTab: s.setSearchModalTab, activeRuleCount: s.searchFilter.rules.length, clearFilterRules: s.clearFilterRules, models: s.models, setSelectedEntity: s.setSelectedEntity, setSelectedEntityId: s.setSelectedEntityId, toggleEntitySelection: s.toggleEntitySelection, cameraCallbacks: s.cameraCallbacks, })), ); // Kick off lazy Tier-1 index builds for any loaded model. useSearchIndex(); // Recents list — loaded on mount, refreshed after each Enter commit. const [recents, setRecents] = useState(() => loadRecentSearches()); // Debounce the query so each keystroke doesn't trigger a 4M-entity scan. const [debouncedQuery, setDebouncedQuery] = useState(searchQuery); useEffect(() => { const handle = window.setTimeout(() => setDebouncedQuery(searchQuery), DEBOUNCE_MS); return () => window.clearTimeout(handle); }, [searchQuery]); // Clear any pending frame timer on unmount so a fire-and-forget // callback can't outlive the component. useEffect(() => () => { if (frameTimerRef.current !== null) { window.clearTimeout(frameTimerRef.current); frameTimerRef.current = null; } }, []); // Split models into two pools: those with a ready Tier-1 index, and // those still relying on the Tier-0 linear scan. Recomputed only when // either the federation or the index map changes identity. const { tier0Models, tier1Indexes, indexingCount } = useMemo(() => { const t0: ScanModel[] = []; const t1: Tier1Index[] = []; let building = 0; for (const m of models.values()) { if (!m.ifcDataStore) continue; const record = searchIndexes.get(m.id); if (record?.status === 'ready' && record.index) { t1.push(record.index); } else { t0.push({ id: m.id, ifcDataStore: m.ifcDataStore }); if (record?.status === 'building') building += 1; } } return { tier0Models: t0, tier1Indexes: t1, indexingCount: building }; }, [models, searchIndexes]); /** * Run the Tier-0/Tier-1 scan synchronously for an arbitrary query. * Extracted from the debounced `results` memo so the Enter-commit * path can flush against the LIVE `searchQuery` rather than the * debounced snapshot — without it, hitting Enter inside the 80ms * debounce window commits a hit from the previous query and records * the wrong recent-search term, even though the input shows newer * text (Codex P2: "Commit inline search against the current query"). */ const runScan = useCallback((q: string): SearchResult[] => { if (!q.trim()) return []; if (tier0Models.length === 0 && tier1Indexes.length === 0) return []; const t1Results = tier1Indexes.length > 0 ? queryTier1Indexes(tier1Indexes, q, { limit: RESULT_LIMIT }) : []; const t0Results = tier0Models.length > 0 ? runTier0Scan(tier0Models, q, { limit: RESULT_LIMIT }) : []; if (t1Results.length === 0) return t0Results; if (t0Results.length === 0) return t1Results; // Merge + dedupe. Scores from Tier-0 and Tier-1 share the same ladder // so a descending-score sort is stable between them. const combined = [...t1Results, ...t0Results]; combined.sort((a, b) => { if (b.score !== a.score) return b.score - a.score; if (a.modelId !== b.modelId) return a.modelId < b.modelId ? -1 : 1; return a.expressId - b.expressId; }); const seen = new Set(); const out: SearchResult[] = []; for (const r of combined) { const key = `${r.modelId}:${r.expressId}`; if (seen.has(key)) continue; seen.add(key); out.push(r); if (out.length >= RESULT_LIMIT) break; } return out; }, [tier0Models, tier1Indexes]); const results = useMemo( () => runScan(debouncedQuery), [runScan, debouncedQuery], ); // Keep the highlight index in range as results change. useEffect(() => { if (results.length === 0) { if (searchHighlightIndex !== 0) setSearchHighlightIndex(0); return; } if (searchHighlightIndex >= results.length) { setSearchHighlightIndex(Math.max(0, results.length - 1)); } }, [results, searchHighlightIndex, setSearchHighlightIndex]); /** Apply selection + frame for a search result. Does NOT touch cycle state. */ const applySelection = useCallback( (r: SearchResult, addToSelection: boolean) => { const ref = { modelId: r.modelId, expressId: r.expressId }; const isLegacy = r.modelId === 'legacy' || r.modelId === '__legacy__' || models.size === 0; const globalId = isLegacy ? r.expressId : toGlobalIdFromModels(models, r.modelId, r.expressId); if (addToSelection) { // Shift+Enter additive — TOGGLES rather than just adds, so a // second Shift+Enter on the same row deselects (was: forced // the user to clear the entire multi-selection to undo). toggleEntitySelection(ref); setSelectedEntityId(globalId); return; } setSelectedEntityId(globalId); setSelectedEntity(ref); if (cameraCallbacks.frameSelection) { if (frameTimerRef.current !== null) window.clearTimeout(frameTimerRef.current); frameTimerRef.current = window.setTimeout(() => { cameraCallbacks.frameSelection?.(); frameTimerRef.current = null; }, 50); } }, [ cameraCallbacks, models, setSelectedEntity, setSelectedEntityId, toggleEntitySelection, ], ); /** * Commit: select + frame + enter vim cycle + record recent. * * `overrideResults` / `overrideQuery` let the Enter-commit path * pass freshly-scanned results from the LIVE `searchQuery` when * the debounce hasn't settled yet — without that, the user's * `n`/`N` cycle and the recorded recent both reflect the prior * (debounced) query rather than what the input shows. */ const commitResult = useCallback( ( r: SearchResult, index: number, addToSelection: boolean, overrideResults?: SearchResult[], overrideQuery?: string, ) => { const cycleResults = overrideResults ?? results; const cycleQuery = overrideQuery ?? debouncedQuery; applySelection(r, addToSelection); if (!addToSelection && cycleResults.length > 0) { enterVimCycle(cycleQuery, cycleResults, index); } const trimmed = cycleQuery.trim(); if (trimmed) setRecents(pushRecentSearch(trimmed)); closeSearch(); }, [applySelection, closeSearch, debouncedQuery, enterVimCycle, results], ); // Re-select + reframe when the vim cycle steps. Uses the results-array // identity to distinguish entry (selection already done by commitResult) // from subsequent steps (this effect drives the selection). const handledCycleRef = useRef<{ results: SearchResult[]; index: number } | null>(null); useEffect(() => { if (!searchVimCycle) { handledCycleRef.current = null; return; } const last = handledCycleRef.current; const isEntry = !last || last.results !== searchVimCycle.results; handledCycleRef.current = { results: searchVimCycle.results, index: searchVimCycle.index, }; if (isEntry) return; // selection was performed by commitResult. const current = searchVimCycle.results[searchVimCycle.index]; if (current) applySelection(current, false); }, [searchVimCycle, applySelection]); /** Global `/` and ⌘F / Ctrl+F shortcuts to focus the field. */ useEffect(() => { const handler = (e: globalThis.KeyboardEvent) => { // ⌘F / Ctrl+F focuses regardless of what else has focus — we want // to override the browser's native Find inside the viewer. const isFindShortcut = (e.ctrlKey || e.metaKey) && (e.key === 'f' || e.key === 'F') && !e.shiftKey; if (isFindShortcut) { e.preventDefault(); inputRef.current?.focus(); inputRef.current?.select(); setSearchOpen(true); return; } // `/` only when no other input is focused — vim-style search summon. if (e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey && !isEditableFocused()) { e.preventDefault(); inputRef.current?.focus(); setSearchOpen(true); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [setSearchOpen]); /** Global n / N / Esc cycle-control listener — active only while cycling. */ useEffect(() => { if (!searchVimCycle) return; const handler = (e: globalThis.KeyboardEvent) => { if (e.metaKey || e.ctrlKey || e.altKey) return; // Don't swallow `n` / `N` when the user is typing elsewhere. if (isEditableFocused()) return; if (e.key === 'n') { e.preventDefault(); stepVimCycle(1); return; } if (e.key === 'N') { e.preventDefault(); stepVimCycle(-1); return; } if (e.key === 'Escape') { e.preventDefault(); exitVimCycle(); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [searchVimCycle, stepVimCycle, exitVimCycle]); /** Click-outside closes the popover (but doesn't blur the field). */ useEffect(() => { if (!searchOpen) return; const handler = (e: MouseEvent) => { const target = e.target as Node | null; if (target && containerRef.current && !containerRef.current.contains(target)) { setSearchOpen(false); } }; window.addEventListener('mousedown', handler); return () => window.removeEventListener('mousedown', handler); }, [searchOpen, setSearchOpen]); const handleInputKeyDown = useCallback( (e: React.KeyboardEvent) => { // Esc: first press closes popover, second blurs the field. Cycle // exit is handled by the global listener, so we don't fight it here. if (e.key === 'Escape') { if (searchOpen) { e.preventDefault(); setSearchOpen(false); } else { inputRef.current?.blur(); } return; } if (!searchOpen && (e.key === 'ArrowDown' || e.key === 'Enter')) { if (results.length > 0) setSearchOpen(true); } if (e.key === 'ArrowDown') { e.preventDefault(); if (results.length === 0) return; const next = (searchHighlightIndex + 1) % results.length; setSearchHighlightIndex(next); return; } if (e.key === 'ArrowUp') { e.preventDefault(); if (results.length === 0) return; const next = (searchHighlightIndex - 1 + results.length) % results.length; setSearchHighlightIndex(next); return; } if (e.key === 'Enter') { e.preventDefault(); // ⌘↵ / Ctrl+↵ opens the advanced modal instead of committing — the // inline query is preserved so the modal opens already populated. // Text-search entry point, so land on the Search tab. if (e.metaKey || e.ctrlKey) { setSearchOpen(false); setSearchModalTab('search'); setSearchModalOpen(true); return; } // Flush the debounce: if the user typed something that hasn't yet // settled into `debouncedQuery`, re-scan synchronously against the // LIVE `searchQuery`. The popover is showing stale results in that // window (debounced still reflects the prior query) so committing // `results[index]` would select the wrong entity. Match the input. const live = searchQuery; const useLive = live.trim() !== debouncedQuery.trim(); const liveResults = useLive ? runScan(live) : results; if (liveResults.length === 0) return; const idx = useLive ? Math.min(searchHighlightIndex, liveResults.length - 1) : searchHighlightIndex; const target = liveResults[idx]; if (target) commitResult(target, idx, e.shiftKey, liveResults, live); } }, [commitResult, results, searchHighlightIndex, searchOpen, setSearchHighlightIndex, setSearchModalOpen, setSearchModalTab, setSearchOpen], ); const hasFilters = activeRuleCount > 0; /** Open the advanced modal straight to the Filter builder — the * always-visible entry point to structured filtering. */ const openAdvancedFilter = useCallback(() => { setSearchOpen(false); setSearchModalTab('filter'); setSearchModalOpen(true); }, [setSearchOpen, setSearchModalTab, setSearchModalOpen]); const queryTrimmedLen = searchQuery.trim().length; const showPopover = searchOpen && (results.length > 0 || queryTrimmedLen > 0 || recents.length > 0); const showRecents = searchOpen && queryTrimmedLen === 0 && recents.length > 0; return (
} onChange={(e) => { setSearchQuery(e.target.value); if (!searchOpen) setSearchOpen(true); }} onFocus={() => setSearchOpen(true)} onKeyDown={handleInputKeyDown} className={cn(hasFilters ? 'pr-[4.5rem]' : 'pr-9')} aria-label="Search entities" aria-autocomplete="list" aria-expanded={showPopover} aria-controls="search-inline-popover" /> {/* Advanced-filter affordance — always visible so structured filtering is discoverable without the ⌘⇧F shortcut. Shows the active rule count and a quick-clear when a filter is applied. */}
{hasFilters && ( )}
{/* Vim cycle hint — shows below the input whenever a cycle is active and the popover is closed. Clicking it exits the cycle. */} {searchVimCycle && !showPopover && ( )} {showPopover && showRecents && ( { setSearchQuery(q); inputRef.current?.focus(); }} onClear={() => { clearRecentSearches(); setRecents([]); }} /> )} {showPopover && !showRecents && ( commitResult(r, i, additive)} onHover={(i) => setSearchHighlightIndex(i)} onOpenAdvanced={() => { setSearchOpen(false); setSearchModalTab('search'); setSearchModalOpen(true); }} /> )}
); } interface VimCycleHintProps { query: string; index: number; total: number; onExit: () => void; } function VimCycleHint({ query, index, total, onExit }: VimCycleHintProps) { return (
{index + 1} / {total} cycling "{query}" — press n / N
); } interface RecentsPopoverProps { recents: string[]; onPick: (query: string) => void; onClear: () => void; } function RecentsPopover({ recents, onPick, onClear }: RecentsPopoverProps) { return (
Recent searches
{recents.map((q) => ( ))}
); } interface SearchPopoverProps { results: SearchResult[]; highlightIndex: number; modelsCount: number; indexingCount: number; onSelect: (r: SearchResult, index: number, additive: boolean) => void; onHover: (index: number) => void; onOpenAdvanced: () => void; } function SearchPopover({ results, highlightIndex, modelsCount, indexingCount, onSelect, onHover, onOpenAdvanced, }: SearchPopoverProps) { if (results.length === 0) { return (
{indexingCount > 0 ? `Indexing ${indexingCount} model${indexingCount === 1 ? '' : 's'}… results appear as rows become searchable.` : 'No results — try a name, IFC type, or full GlobalId.'}
); } return (
{results.map((r, i) => ( ))}
{results.length} result{results.length === 1 ? '' : 's'} · ↑↓ · ↵ · ⇧↵ · Esc {indexingCount > 0 && · indexing {indexingCount}…}
); }