/* 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/. */ /** * SearchModal — advanced search (⌘⇧F / Ctrl+Shift+F). * * Shares `searchSlice.searchQuery` with the inline field, so the modal * can never "lose" what you've already typed — open it and the query is * already there, adjust it and closing the modal leaves the inline in * sync. The tab switcher has a "Search" tab (P3) and a "SQL" tab stub * reserved for P4. All search engines (Tier-0 linear scan, Tier-1 token * index) are reused — the modal just renders a bigger, unfiltered, * virtualized version of what the inline popover shows. * * Keyboard (inside the modal): * • ↑ / ↓ — navigate result rows * • Enter — commit (select + frame + enter vim cycle + close) * • ⇧Enter — toggle row in multi-selection (stays open) * • Esc — close modal * • ⌘⇧F / Ctrl+⇧F — toggle modal closed (symmetric with open) */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Search, SlidersHorizontal } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { useViewerStore } from '@/store'; 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 { pushRecentSearch } from '@/lib/search/recent-searches'; import { SearchModalText } from './SearchModal.text'; import { SearchModalFilter } from './SearchModal.filter'; /** Modal-side result cap. Well above what any user scrolls through, small * enough that the score/merge arrays stay cheap. Virtualization keeps * DOM cost constant regardless. */ const RESULT_LIMIT_MODAL = 5000; const DEBOUNCE_MS = 80; export function SearchModal() { const { searchQuery, searchModalOpen, searchModalTab, searchIndexes, models, setSearchModalOpen, setSearchModalTab, setSearchQuery, } = useViewerStore( useShallow((s) => ({ searchQuery: s.searchQuery, searchModalOpen: s.searchModalOpen, searchModalTab: s.searchModalTab, searchIndexes: s.searchIndexes, models: s.models, setSearchModalOpen: s.setSearchModalOpen, setSearchModalTab: s.setSearchModalTab, setSearchQuery: s.setSearchQuery, })), ); // Make sure Tier-1 indexes continue building while the modal is open // (the inline also mounts this hook — cheap re-registration). useSearchIndex(); // Debounce the query the same way the inline does, so fast typing // inside the modal doesn't re-scan per keystroke. const [debouncedQuery, setDebouncedQuery] = useState(searchQuery); useEffect(() => { const handle = window.setTimeout(() => setDebouncedQuery(searchQuery), DEBOUNCE_MS); return () => window.clearTimeout(handle); }, [searchQuery]); // Split models into the two search tiers. Same logic as SearchInline. const { tier0Models, tier1Indexes, availableModelIds } = useMemo(() => { const t0: ScanModel[] = []; const t1: Tier1Index[] = []; const ids: string[] = []; for (const m of models.values()) { if (!m.ifcDataStore) continue; ids.push(m.id); 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 }); } } return { tier0Models: t0, tier1Indexes: t1, availableModelIds: ids }; }, [models, searchIndexes]); // Full result pool (pre-filter). Filtering happens inside the tab. const results = useMemo(() => { if (!debouncedQuery.trim()) return []; if (tier0Models.length === 0 && tier1Indexes.length === 0) return []; const t1Results = tier1Indexes.length > 0 ? queryTier1Indexes(tier1Indexes, debouncedQuery, { limit: RESULT_LIMIT_MODAL }) : []; const t0Results = tier0Models.length > 0 ? runTier0Scan(tier0Models, debouncedQuery, { limit: RESULT_LIMIT_MODAL }) : []; if (t1Results.length === 0) return t0Results; if (t0Results.length === 0) return t1Results; 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_MODAL) break; } return out; }, [tier0Models, tier1Indexes, debouncedQuery]); /** Global ⌘⇧F / Ctrl+⇧F toggle — opens from anywhere, also closes when open. * This is a text-search entry point, so opening always lands on the Search * tab (the controlled tab otherwise remembers the last-used Filter tab). */ useEffect(() => { const handler = (e: globalThis.KeyboardEvent) => { const isAdvancedShortcut = (e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'f' || e.key === 'F'); if (isAdvancedShortcut) { e.preventDefault(); if (searchModalOpen) { setSearchModalOpen(false); } else { setSearchModalTab('search'); setSearchModalOpen(true); } } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [searchModalOpen, setSearchModalOpen, setSearchModalTab]); /** * Record the query in recents on the modal-close *transition* — once * per close, with the final query at that moment. We watch only * `searchModalOpen` (not `searchQuery`) so typing in the inline bar * while the modal is closed never fires this effect; without that * gate, every keystroke in the inline bar (which shares `searchQuery` * with the modal) would push a partial-prefix recent. * * `prevOpenRef` distinguishes the "opened then closed" transition * from the initial mount where `searchModalOpen` is already false. */ const prevOpenRef = useRef(searchModalOpen); useEffect(() => { const wasOpen = prevOpenRef.current; prevOpenRef.current = searchModalOpen; if (wasOpen && !searchModalOpen) { // Use the latest searchQuery via a fresh read — depending on it // would re-fire this effect on every keystroke. Since the close // transition is what we care about, the latest value at close // time is the right thing to record. const q = searchQuery.trim(); if (q) pushRecentSearch(q); } }, [searchModalOpen, searchQuery]); // Auto-select the input on open so typing is immediate. const inputRef = useRef(null); useEffect(() => { if (searchModalOpen) { // Next tick so Radix Dialog has mounted the content. const t = window.setTimeout(() => { inputRef.current?.focus(); inputRef.current?.select(); }, 10); return () => window.clearTimeout(t); } }, [searchModalOpen]); const close = useCallback(() => setSearchModalOpen(false), [setSearchModalOpen]); if (!searchModalOpen) return null; return ( setSearchModalOpen(open)}> Advanced Search setSearchModalTab(v as typeof searchModalTab)} className="flex flex-col flex-1 min-h-0" >
Search Filter
Esc close
} onChange={(e) => setSearchQuery(e.target.value)} className="h-10 text-sm" aria-label="Advanced search query" />
); }