/* 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.text — the Search tab content. * * Chip filters (field match type + per-model include) narrow the pool * of results provided by the parent modal, which owns the scan + merge. * The result list is virtualized via @tanstack/react-virtual so 5000 * rows render at constant cost. Batch actions route through the * existing selection / visibility slices. */ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Crosshair, SquareX, ListChecks, Filter } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { useViewerStore } from '@/store'; import { toGlobalIdFromModels } from '@/store/globalId'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import type { SearchResult } from '@/lib/search/tier0-scan'; import type { SearchFieldFilter } from '@/store/slices/searchSlice'; const ROW_HEIGHT = 36; const FIELD_FILTERS: { value: SearchFieldFilter; label: string }[] = [ { value: 'all', label: 'All' }, { value: 'name', label: 'Name' }, { value: 'type', label: 'Type' }, { value: 'globalId', label: 'GUID' }, { value: 'description', label: 'Description' }, { value: 'objectType', label: 'ObjectType' }, ]; export interface SearchModalTextProps { /** Full result pool from the parent modal (before filter chips). */ results: SearchResult[]; /** All modelIds currently loaded (for the model-filter chips). */ availableModelIds: readonly string[]; /** Close the parent modal — invoked on Enter-commit from a row. */ onClose: () => void; } export function SearchModalText({ results, availableModelIds, onClose }: SearchModalTextProps) { const { searchFieldFilter, searchModelFilter, searchQuery, searchHighlightIndex, selectedEntitiesSet, models, setSearchFieldFilter, toggleSearchModelFilter, clearSearchModelFilter, setSearchHighlightIndex, setSelectedEntity, setSelectedEntityId, addEntitiesToSelection, toggleEntitySelection, clearEntitySelection, enterVimCycle, cameraCallbacks, } = useViewerStore( useShallow((s) => ({ searchFieldFilter: s.searchFieldFilter, searchModelFilter: s.searchModelFilter, searchQuery: s.searchQuery, searchHighlightIndex: s.searchHighlightIndex, selectedEntitiesSet: s.selectedEntitiesSet, models: s.models, setSearchFieldFilter: s.setSearchFieldFilter, toggleSearchModelFilter: s.toggleSearchModelFilter, clearSearchModelFilter: s.clearSearchModelFilter, setSearchHighlightIndex: s.setSearchHighlightIndex, setSelectedEntity: s.setSelectedEntity, setSelectedEntityId: s.setSelectedEntityId, addEntitiesToSelection: s.addEntitiesToSelection, toggleEntitySelection: s.toggleEntitySelection, clearEntitySelection: s.clearEntitySelection, enterVimCycle: s.enterVimCycle, cameraCallbacks: s.cameraCallbacks, })), ); // Filter the result pool by the active chip selections. const filtered = useMemo(() => { return results.filter((r) => { if (searchFieldFilter !== 'all' && r.matchField !== searchFieldFilter) return false; if (searchModelFilter && !searchModelFilter.has(r.modelId)) return false; return true; }); }, [results, searchFieldFilter, searchModelFilter]); // Virtualized list setup. `count` tracks filtered.length; scrollElement // is the parent div the virtualizer measures. const scrollRef = useRef(null); const virtualizer = useVirtualizer({ count: filtered.length, getScrollElement: () => scrollRef.current, estimateSize: () => ROW_HEIGHT, overscan: 10, }); // Keep the highlight index in range as filtered results change. useEffect(() => { if (filtered.length === 0) { if (searchHighlightIndex !== 0) setSearchHighlightIndex(0); return; } if (searchHighlightIndex >= filtered.length) { setSearchHighlightIndex(Math.max(0, filtered.length - 1)); } }, [filtered, searchHighlightIndex, setSearchHighlightIndex]); // Scroll the highlighted row into view when it moves. useEffect(() => { if (filtered.length === 0) return; virtualizer.scrollToIndex(searchHighlightIndex, { align: 'auto' }); }, [searchHighlightIndex, filtered.length, virtualizer]); /** Primary "click row" handler — selects + frames + enters vim cycle. */ const commit = useCallback( (r: SearchResult, indexInFiltered: number) => { 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); setSelectedEntityId(globalId); setSelectedEntity(ref); if (cameraCallbacks.frameSelection) { window.setTimeout(() => cameraCallbacks.frameSelection?.(), 50); } enterVimCycle(searchQuery, filtered, indexInFiltered); onClose(); }, [ cameraCallbacks, enterVimCycle, filtered, models, onClose, searchQuery, setSelectedEntity, setSelectedEntityId, ], ); /** * Additive toggle — adds OR removes from multi-selection without * closing. Uses `toggleEntitySelection` so a second Shift+Enter (or * a second checkbox click on the same row) deselects, rather than * being a no-op that forces the user to clear the entire selection. */ const toggleAdditive = useCallback( (r: SearchResult) => { toggleEntitySelection({ modelId: r.modelId, expressId: r.expressId }); }, [toggleEntitySelection], ); /** * Batch: add every filtered result to multi-selection in a single * Zustand `set`. The naïve loop over `addEntityToSelection` triggered * one re-render per row — visibly janky on a 5K-row filtered set. */ const selectAll = useCallback(() => { if (filtered.length === 0) return; addEntitiesToSelection( filtered.map((r) => ({ modelId: r.modelId, expressId: r.expressId })), ); }, [addEntitiesToSelection, filtered]); /** Batch: frame whatever is the primary selection (if any). */ const frame = useCallback(() => { cameraCallbacks.frameSelection?.(); }, [cameraCallbacks]); const multiCount = selectedEntitiesSet.size; const hasModelChips = availableModelIds.length > 1; return (
{/* ── Chip filters ── */}
Field: {FIELD_FILTERS.map((f) => ( ))} {hasModelChips && ( <> Models: {availableModelIds.map((id) => { const included = searchModelFilter === null || searchModelFilter.has(id); const model = models.get(id); const label = model?.name ?? id.slice(0, 6); return ( ); })} {searchModelFilter !== null && ( )} )}
{/* ── Virtualized results list ── */}
{ if (filtered.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); const next = (searchHighlightIndex + 1) % filtered.length; setSearchHighlightIndex(next); } else if (e.key === 'ArrowUp') { e.preventDefault(); const next = (searchHighlightIndex - 1 + filtered.length) % filtered.length; setSearchHighlightIndex(next); } else if (e.key === 'Enter') { e.preventDefault(); const target = filtered[searchHighlightIndex]; if (target) { if (e.shiftKey) toggleAdditive(target); else commit(target, searchHighlightIndex); } } }} > {filtered.length === 0 ? (
{results.length === 0 ? 'Start typing to search — GlobalIds, names, IFC types, descriptions.' : 'No results match the active filters. Clear chips to widen the search.'}
) : (
{virtualizer.getVirtualItems().map((vRow) => { const r = filtered[vRow.index]; const key = `${r.modelId}:${r.expressId}`; const isChecked = selectedEntitiesSet.has(key); const isHighlighted = vRow.index === searchHighlightIndex; return (
setSearchHighlightIndex(vRow.index)} onClick={(e) => { if (e.shiftKey) toggleAdditive(r); else commit(r, vRow.index); }} > e.stopPropagation()} onChange={() => toggleAdditive(r)} aria-label={`Toggle ${r.name || r.globalId} in selection`} className="shrink-0 cursor-pointer" /> {r.typeName} {r.name || unnamed} {r.globalId && ( {r.globalId.slice(0, 10)}… )} {availableModelIds.length > 1 && ( {(models.get(r.modelId)?.name ?? r.modelId).slice(0, 8)} )} {r.matchField}
); })}
)}
{/* ── Footer: counts + batch actions ── */}
{filtered.length} result{filtered.length === 1 ? '' : 's'} {filtered.length !== results.length && ( (of {results.length}) )} {multiCount > 0 && ( · {multiCount} selected )}
); }