@ifc-lite/viewer 1.17.6 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.turbo/turbo-build.log +17 -14
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +513 -0
  4. package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  5. package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
  6. package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
  7. package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
  8. package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
  9. package/dist/assets/index-COnQRuqY.css +1 -0
  10. package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
  11. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  12. package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
  13. package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
  14. package/dist/index.html +6 -6
  15. package/package.json +10 -10
  16. package/src/apache-arrow.d.ts +30 -0
  17. package/src/components/viewer/AddElementPanel.tsx +758 -0
  18. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  19. package/src/components/viewer/ChatPanel.tsx +64 -2
  20. package/src/components/viewer/CommandPalette.tsx +56 -7
  21. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  22. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  23. package/src/components/viewer/ExportDialog.tsx +19 -1
  24. package/src/components/viewer/MainToolbar.tsx +69 -10
  25. package/src/components/viewer/PropertiesPanel.tsx +222 -22
  26. package/src/components/viewer/SearchInline.tsx +669 -0
  27. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  28. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  29. package/src/components/viewer/SearchModal.text.tsx +388 -0
  30. package/src/components/viewer/SearchModal.tsx +235 -0
  31. package/src/components/viewer/ToolOverlays.tsx +5 -0
  32. package/src/components/viewer/ViewerLayout.tsx +24 -4
  33. package/src/components/viewer/Viewport.tsx +11 -1
  34. package/src/components/viewer/ViewportContainer.tsx +2 -0
  35. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  36. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  37. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  38. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  39. package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
  40. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  41. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  42. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  43. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  44. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  45. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  46. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  47. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  48. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  49. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  50. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  51. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  52. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  53. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  54. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  55. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  56. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  57. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  58. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  59. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  60. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  61. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  62. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  63. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  64. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  65. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  66. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  67. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  68. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  69. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  70. package/src/components/viewer/selectionHandlers.ts +446 -0
  71. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  72. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  73. package/src/components/viewer/useMouseControls.ts +9 -1
  74. package/src/hooks/useIfcLoader.ts +22 -10
  75. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  76. package/src/hooks/useSandbox.ts +1 -1
  77. package/src/hooks/useSearchIndex.ts +125 -0
  78. package/src/index.css +66 -0
  79. package/src/lib/llm/system-prompt.test.ts +14 -0
  80. package/src/lib/llm/system-prompt.ts +102 -1
  81. package/src/lib/llm/types.ts +6 -0
  82. package/src/lib/recent-files.ts +38 -4
  83. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  84. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  85. package/src/lib/scripts/templates.ts +7 -0
  86. package/src/lib/search/common-ifc-types.ts +36 -0
  87. package/src/lib/search/filter-evaluate.test.ts +537 -0
  88. package/src/lib/search/filter-evaluate.ts +610 -0
  89. package/src/lib/search/filter-rules.test.ts +119 -0
  90. package/src/lib/search/filter-rules.ts +198 -0
  91. package/src/lib/search/filter-schema.test.ts +233 -0
  92. package/src/lib/search/filter-schema.ts +146 -0
  93. package/src/lib/search/recent-searches.test.ts +116 -0
  94. package/src/lib/search/recent-searches.ts +93 -0
  95. package/src/lib/search/result-export.test.ts +101 -0
  96. package/src/lib/search/result-export.ts +104 -0
  97. package/src/lib/search/saved-filters.test.ts +118 -0
  98. package/src/lib/search/saved-filters.ts +154 -0
  99. package/src/lib/search/tier0-scan.test.ts +196 -0
  100. package/src/lib/search/tier0-scan.ts +237 -0
  101. package/src/lib/search/tier1-index.test.ts +242 -0
  102. package/src/lib/search/tier1-index.ts +448 -0
  103. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  104. package/src/sdk/adapters/export-adapter.ts +404 -1
  105. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  106. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  107. package/src/sdk/adapters/model-compat.ts +8 -2
  108. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  109. package/src/sdk/adapters/store-adapter.ts +201 -0
  110. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  111. package/src/sdk/local-backend.ts +16 -8
  112. package/src/services/desktop-export.ts +3 -1
  113. package/src/services/desktop-native-metadata.ts +41 -18
  114. package/src/services/file-dialog.ts +4 -1
  115. package/src/services/tauri-modules.d.ts +25 -0
  116. package/src/store/basketVisibleSet.ts +3 -0
  117. package/src/store/globalId.ts +4 -1
  118. package/src/store/index.ts +70 -1
  119. package/src/store/slices/addElementMeshes.ts +365 -0
  120. package/src/store/slices/addElementSlice.ts +275 -0
  121. package/src/store/slices/annotationsSlice.test.ts +133 -0
  122. package/src/store/slices/annotationsSlice.ts +251 -0
  123. package/src/store/slices/dataSlice.test.ts +23 -4
  124. package/src/store/slices/dataSlice.ts +1 -1
  125. package/src/store/slices/modelSlice.test.ts +67 -9
  126. package/src/store/slices/modelSlice.ts +39 -7
  127. package/src/store/slices/mutationSlice.ts +964 -3
  128. package/src/store/slices/overlayCompositor.test.ts +164 -0
  129. package/src/store/slices/overlaySlice.test.ts +93 -0
  130. package/src/store/slices/overlaySlice.ts +151 -0
  131. package/src/store/slices/pinboardSlice.test.ts +6 -1
  132. package/src/store/slices/playbackSlice.ts +128 -0
  133. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  134. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  135. package/src/store/slices/scheduleSlice.test.ts +694 -0
  136. package/src/store/slices/scheduleSlice.ts +1330 -0
  137. package/src/store/slices/searchSlice.test.ts +342 -0
  138. package/src/store/slices/searchSlice.ts +341 -0
  139. package/src/store/slices/selectionSlice.test.ts +46 -0
  140. package/src/store/slices/selectionSlice.ts +20 -0
  141. package/src/store.ts +14 -0
  142. package/dist/assets/index-_bfZsDCC.css +0 -1
  143. package/dist/assets/sandbox-C8575tul.js +0 -5951
@@ -0,0 +1,514 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * SearchModalFilter — chip-based structured-rule filtering.
7
+ *
8
+ * Owns the run lifecycle: assembles per-model arguments, folds the
9
+ * inline search query into a Tier-1/Tier-0 candidate set when present,
10
+ * runs the path-B evaluator (chunked + cancellable + progress), and
11
+ * renders the result table. The chip-editing UI lives in
12
+ * `SearchModalFilterBuilder`; that's a UI-only sibling that reads /
13
+ * writes the same slice state.
14
+ *
15
+ * No DuckDB. No SQL editor. The path-B evaluator handles 4M-entity
16
+ * models via `selectIterationSource` (byType / byStorey index
17
+ * prefilter under AND + op:in), cheap-first per-entity rule ordering,
18
+ * and async chunked yielding — so a single Run button is the whole
19
+ * story.
20
+ */
21
+
22
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
23
+ import { useVirtualizer } from '@tanstack/react-virtual';
24
+ import { Play, AlertCircle, Download } from 'lucide-react';
25
+ import { useShallow } from 'zustand/react/shallow';
26
+ import { useViewerStore } from '@/store';
27
+ import { toGlobalIdFromModels } from '@/store/globalId';
28
+ import { Button } from '@/components/ui/button';
29
+ import {
30
+ DropdownMenu,
31
+ DropdownMenuTrigger,
32
+ DropdownMenuContent,
33
+ DropdownMenuItem,
34
+ } from '@/components/ui/dropdown-menu';
35
+ import { cn } from '@/lib/utils';
36
+ import { evaluateFilterRulesFederated } from '@/lib/search/filter-evaluate';
37
+ import { runTier0Scan, type ScanModel } from '@/lib/search/tier0-scan';
38
+ import { queryTier1Indexes, type Tier1Index } from '@/lib/search/tier1-index';
39
+ import { downloadResult } from '@/lib/search/result-export';
40
+ import { SearchModalFilterBuilder } from './SearchModal.filter.builder';
41
+
42
+ /** Rows per virtualizer page — tuned for the result table row height. */
43
+ const RESULT_ROW_HEIGHT = 28;
44
+ const TEXT_HIT_LIMIT = 50_000;
45
+ const FILTER_CHUNK_SIZE = 20_000;
46
+ const DEFAULT_LIMIT = 5_000;
47
+
48
+ /** Columns we treat as "selection keys" — clicking a row routes the
49
+ * value through the viewer's selection system. */
50
+ const SELECTION_COLUMNS = ['express_id', 'entity_id'] as const;
51
+
52
+ export function SearchModalFilter() {
53
+ const {
54
+ searchFilter,
55
+ searchFilterResult,
56
+ searchFilterRunning,
57
+ searchFilterError,
58
+ searchQuery,
59
+ searchIndexes,
60
+ setSearchFilterRunning,
61
+ setSearchFilterResult,
62
+ setSearchFilterError,
63
+ models,
64
+ activeModelId,
65
+ setSelectedEntity,
66
+ setSelectedEntityId,
67
+ cameraCallbacks,
68
+ } = useViewerStore(
69
+ useShallow((s) => ({
70
+ searchFilter: s.searchFilter,
71
+ searchFilterResult: s.searchFilterResult,
72
+ searchFilterRunning: s.searchFilterRunning,
73
+ searchFilterError: s.searchFilterError,
74
+ searchQuery: s.searchQuery,
75
+ searchIndexes: s.searchIndexes,
76
+ setSearchFilterRunning: s.setSearchFilterRunning,
77
+ setSearchFilterResult: s.setSearchFilterResult,
78
+ setSearchFilterError: s.setSearchFilterError,
79
+ models: s.models,
80
+ activeModelId: s.activeModelId,
81
+ setSelectedEntity: s.setSelectedEntity,
82
+ setSelectedEntityId: s.setSelectedEntityId,
83
+ cameraCallbacks: s.cameraCallbacks,
84
+ })),
85
+ );
86
+
87
+ const activeModel = activeModelId ? models.get(activeModelId) : undefined;
88
+ const activeStore = activeModel?.ifcDataStore ?? null;
89
+ const multiModel = models.size > 1;
90
+
91
+ // ── Run lifecycle: progress, cancel, limit-hit badge ──────────────────
92
+ const runController = useRef<AbortController | null>(null);
93
+ const [progress, setProgress] = useState<{ scanned: number; total: number } | null>(null);
94
+ const [limitHit, setLimitHit] = useState<number | null>(null);
95
+
96
+ const runFilter = useCallback(async () => {
97
+ if (searchFilterRunning) return;
98
+ if (searchFilter.rules.length === 0) {
99
+ setSearchFilterError('Add at least one rule before running.');
100
+ return;
101
+ }
102
+
103
+ runController.current?.abort();
104
+ const controller = new AbortController();
105
+ runController.current = controller;
106
+
107
+ setSearchFilterRunning(true);
108
+ setSearchFilterError(null);
109
+ setLimitHit(null);
110
+ setProgress({ scanned: 0, total: 0 });
111
+
112
+ const start = performance.now();
113
+ try {
114
+ const modelArgs: Array<{ id: string; store: typeof activeStore }> = [];
115
+ for (const m of models.values()) {
116
+ if (m.ifcDataStore) modelArgs.push({ id: m.id, store: m.ifcDataStore });
117
+ }
118
+
119
+ // Fold the inline search query in as a Tier-1/Tier-0 candidate
120
+ // set when present. Empty query → no narrowing (full scan with
121
+ // index prefilter applied inside the evaluator).
122
+ const trimmedQuery = searchQuery.trim();
123
+ let candidatesByModel: Map<string, Iterable<number>> | undefined;
124
+ if (trimmedQuery.length > 0) {
125
+ const t0Models: ScanModel[] = [];
126
+ const t1Indexes: Tier1Index[] = [];
127
+ for (const m of modelArgs) {
128
+ const rec = searchIndexes.get(m.id);
129
+ if (rec?.status === 'ready' && rec.index) {
130
+ t1Indexes.push(rec.index);
131
+ } else {
132
+ t0Models.push({ id: m.id, ifcDataStore: m.store });
133
+ }
134
+ }
135
+ const t1Hits = t1Indexes.length > 0
136
+ ? queryTier1Indexes(t1Indexes, trimmedQuery, { limit: TEXT_HIT_LIMIT })
137
+ : [];
138
+ const t0Hits = t0Models.length > 0
139
+ ? runTier0Scan(t0Models, trimmedQuery, { limit: TEXT_HIT_LIMIT })
140
+ : [];
141
+ const grouped = new Map<string, Set<number>>();
142
+ for (const hit of t1Hits.concat(t0Hits)) {
143
+ let bucket = grouped.get(hit.modelId);
144
+ if (!bucket) { bucket = new Set(); grouped.set(hit.modelId, bucket); }
145
+ bucket.add(hit.expressId);
146
+ }
147
+ candidatesByModel = new Map();
148
+ for (const [id, set] of grouped) candidatesByModel.set(id, set);
149
+ for (const m of modelArgs) {
150
+ // Models with no text hits get an empty candidate so structured
151
+ // rules can't slip through under intersection semantics.
152
+ if (!candidatesByModel.has(m.id)) candidatesByModel.set(m.id, []);
153
+ }
154
+ }
155
+
156
+ const limit = searchFilter.limit > 0 ? searchFilter.limit : DEFAULT_LIMIT;
157
+ const matched = await evaluateFilterRulesFederated(
158
+ modelArgs,
159
+ searchFilter.rules,
160
+ searchFilter.combinator,
161
+ {
162
+ limit,
163
+ chunkSize: FILTER_CHUNK_SIZE,
164
+ candidateExpressIdsByModel: candidatesByModel,
165
+ signal: controller.signal,
166
+ onProgress: (scanned, total) => setProgress({ scanned, total }),
167
+ },
168
+ );
169
+
170
+ const multi = modelArgs.length > 1;
171
+ const columns = multi
172
+ ? ['express_id', 'global_id', 'name', 'type', 'model_id']
173
+ : ['express_id', 'global_id', 'name', 'type'];
174
+ const rows: unknown[][] = matched.map((m) =>
175
+ multi
176
+ ? [m.expressId, m.globalId, m.name, m.ifcType, m.modelId]
177
+ : [m.expressId, m.globalId, m.name, m.ifcType],
178
+ );
179
+ setSearchFilterResult({
180
+ columns,
181
+ rows,
182
+ runMs: Math.round(performance.now() - start),
183
+ });
184
+ if (matched.length >= limit) setLimitHit(limit);
185
+ } catch (err) {
186
+ if (err instanceof DOMException && err.name === 'AbortError') return;
187
+ setSearchFilterError(err instanceof Error ? err.message : String(err));
188
+ } finally {
189
+ if (runController.current === controller) {
190
+ runController.current = null;
191
+ setSearchFilterRunning(false);
192
+ setProgress(null);
193
+ }
194
+ }
195
+ }, [
196
+ models,
197
+ searchFilter,
198
+ searchFilterRunning,
199
+ searchIndexes,
200
+ searchQuery,
201
+ setSearchFilterError,
202
+ setSearchFilterResult,
203
+ setSearchFilterRunning,
204
+ ]);
205
+
206
+ const cancelFilter = useCallback(() => {
207
+ runController.current?.abort();
208
+ }, []);
209
+
210
+ // Cancel any in-flight run when the modal unmounts so background
211
+ // chunked work doesn't keep ticking after close.
212
+ useEffect(() => () => {
213
+ runController.current?.abort();
214
+ }, []);
215
+
216
+ // Locate the model_id column (only present in federated runs) — same
217
+ // routing rule as before: known column → use that model's id space.
218
+ const modelIdColumnIndex = useMemo(() => {
219
+ const cols = searchFilterResult?.columns;
220
+ if (!cols) return -1;
221
+ return cols.indexOf('model_id');
222
+ }, [searchFilterResult]);
223
+
224
+ const selectionKeyIndex = useMemo(() => {
225
+ const cols = searchFilterResult?.columns;
226
+ if (!cols) return -1;
227
+ for (const candidate of SELECTION_COLUMNS) {
228
+ const i = cols.indexOf(candidate);
229
+ if (i >= 0) return i;
230
+ }
231
+ return -1;
232
+ }, [searchFilterResult]);
233
+
234
+ const handleRowClick = useCallback((row: unknown[]) => {
235
+ if (selectionKeyIndex < 0) return;
236
+ const rowModelId = modelIdColumnIndex >= 0 && typeof row[modelIdColumnIndex] === 'string'
237
+ ? (row[modelIdColumnIndex] as string)
238
+ : activeModelId;
239
+ if (!rowModelId) return;
240
+ const raw = row[selectionKeyIndex];
241
+ const expressId = typeof raw === 'number'
242
+ ? raw
243
+ : typeof raw === 'string' && !Number.isNaN(Number(raw))
244
+ ? Number(raw)
245
+ : null;
246
+ if (expressId === null || expressId <= 0) return;
247
+ const globalId = toGlobalIdFromModels(models, rowModelId, expressId);
248
+ setSelectedEntityId(globalId);
249
+ setSelectedEntity({ modelId: rowModelId, expressId });
250
+ if (cameraCallbacks.frameSelection) {
251
+ window.setTimeout(() => cameraCallbacks.frameSelection?.(), 50);
252
+ }
253
+ }, [activeModelId, cameraCallbacks, models, modelIdColumnIndex, selectionKeyIndex, setSelectedEntity, setSelectedEntityId]);
254
+
255
+ const handleExport = useCallback((format: 'csv' | 'json') => {
256
+ if (!searchFilterResult || searchFilterResult.rows.length === 0) return;
257
+ downloadResult(searchFilterResult, format);
258
+ }, [searchFilterResult]);
259
+
260
+ if (!activeStore) {
261
+ return (
262
+ <div className="flex flex-1 items-center justify-center p-8 text-center text-sm text-muted-foreground">
263
+ Load an IFC file first — the filter runs against the active model&apos;s data.
264
+ </div>
265
+ );
266
+ }
267
+
268
+ const canRun = searchFilter.rules.length > 0;
269
+
270
+ return (
271
+ <div className="flex flex-1 min-h-0 flex-col">
272
+ {/* ── Builder (chip palette) ─────────────────────────────────────── */}
273
+ <div className="overflow-y-auto border-b">
274
+ <SearchModalFilterBuilder />
275
+ </div>
276
+
277
+ {/* ── Run bar: status · run/cancel · export ──────────────────────── */}
278
+ <div className="flex items-center gap-2 border-b px-3 py-2 text-[11px]">
279
+ <RuleSummary
280
+ ruleCount={searchFilter.rules.length}
281
+ combinator={searchFilter.combinator}
282
+ limit={searchFilter.limit}
283
+ />
284
+
285
+ {progress && progress.total > 0 && (
286
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
287
+ <span className="relative h-1.5 w-32 overflow-hidden rounded bg-zinc-200 dark:bg-zinc-800">
288
+ <span
289
+ className="absolute left-0 top-0 h-full bg-primary transition-[width] duration-100"
290
+ style={{
291
+ width: `${Math.min(100, Math.round((progress.scanned / progress.total) * 100))}%`,
292
+ }}
293
+ />
294
+ </span>
295
+ <span className="font-mono">
296
+ {progress.scanned.toLocaleString()} / {progress.total.toLocaleString()}
297
+ </span>
298
+ </span>
299
+ )}
300
+ {progress && progress.total <= 0 && (
301
+ <span className="font-mono text-muted-foreground">
302
+ scanned {progress.scanned.toLocaleString()}
303
+ </span>
304
+ )}
305
+
306
+ {!searchFilterRunning && limitHit !== null && (
307
+ <span
308
+ className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-900 dark:bg-amber-900/40 dark:text-amber-200"
309
+ title="Increase the limit or narrow the rules to see more matches"
310
+ >
311
+ limited to {limitHit.toLocaleString()}
312
+ </span>
313
+ )}
314
+
315
+ {searchFilterResult && !searchFilterRunning && (
316
+ <span className="text-muted-foreground">
317
+ ⏱ {searchFilterResult.runMs} ms · {searchFilterResult.rows.length.toLocaleString()} rows
318
+ </span>
319
+ )}
320
+
321
+ <div className="ml-auto flex items-center gap-1.5">
322
+ <DropdownMenu>
323
+ <DropdownMenuTrigger asChild>
324
+ <Button
325
+ variant="ghost"
326
+ size="sm"
327
+ disabled={!searchFilterResult || searchFilterResult.rows.length === 0}
328
+ className="h-7 gap-1 text-xs"
329
+ title="Export results"
330
+ >
331
+ <Download className="h-3 w-3" /> Export
332
+ </Button>
333
+ </DropdownMenuTrigger>
334
+ <DropdownMenuContent align="end">
335
+ <DropdownMenuItem onSelect={() => handleExport('csv')}>
336
+ Download CSV
337
+ </DropdownMenuItem>
338
+ <DropdownMenuItem onSelect={() => handleExport('json')}>
339
+ Download JSON
340
+ </DropdownMenuItem>
341
+ </DropdownMenuContent>
342
+ </DropdownMenu>
343
+
344
+ {searchFilterRunning ? (
345
+ <Button
346
+ variant="outline"
347
+ size="sm"
348
+ onClick={cancelFilter}
349
+ className="h-7 gap-1 text-xs"
350
+ >
351
+ Cancel
352
+ </Button>
353
+ ) : (
354
+ <Button
355
+ variant="default"
356
+ size="sm"
357
+ onClick={runFilter}
358
+ disabled={!canRun}
359
+ className="h-7 gap-1 text-xs"
360
+ title={canRun ? 'Run the filter against every loaded model' : 'Add a rule first'}
361
+ >
362
+ <Play className="h-3 w-3" />
363
+ Run
364
+ </Button>
365
+ )}
366
+ </div>
367
+ </div>
368
+
369
+ {multiModel && (
370
+ <div className="border-b bg-zinc-50 px-3 py-1.5 text-[11px] text-muted-foreground dark:bg-zinc-900/30">
371
+ Filtering across all {models.size} loaded models. Click any row to
372
+ select that element in the right model.
373
+ </div>
374
+ )}
375
+
376
+ {/* ── Result area: error stacks above the last good table ────────── */}
377
+ {searchFilterError && <FilterErrorBox raw={searchFilterError} />}
378
+ <FilterResultTable
379
+ result={searchFilterResult}
380
+ selectionKeyIndex={selectionKeyIndex}
381
+ onRowClick={handleRowClick}
382
+ />
383
+ </div>
384
+ );
385
+ }
386
+
387
+ // ── Sub-components ────────────────────────────────────────────────────
388
+
389
+ function RuleSummary({
390
+ ruleCount,
391
+ combinator,
392
+ limit,
393
+ }: {
394
+ ruleCount: number;
395
+ combinator: 'AND' | 'OR';
396
+ limit: number;
397
+ }) {
398
+ if (ruleCount === 0) {
399
+ return (
400
+ <span className="text-muted-foreground italic">No rules — add one to run.</span>
401
+ );
402
+ }
403
+ return (
404
+ <span className="text-muted-foreground">
405
+ <span className="font-mono text-foreground">{ruleCount}</span>{' '}
406
+ rule{ruleCount === 1 ? '' : 's'}
407
+ <span className="mx-1">·</span>
408
+ <span className="font-mono">{combinator}</span>
409
+ <span className="mx-1">·</span>
410
+ limit{' '}
411
+ <span className="font-mono text-foreground">
412
+ {limit > 0 ? limit.toLocaleString() : '∞'}
413
+ </span>
414
+ </span>
415
+ );
416
+ }
417
+
418
+ function FilterErrorBox({ raw }: { raw: string }) {
419
+ return (
420
+ <div className="border-b bg-red-50/50 px-4 py-3 dark:bg-red-950/20">
421
+ <div className="flex items-start gap-2">
422
+ <AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-600 dark:text-red-400" />
423
+ <div className="min-w-0 flex-1 text-xs">
424
+ <div className="font-semibold text-red-900 dark:text-red-200">Filter failed</div>
425
+ <div className="mt-1 break-words text-red-800 dark:text-red-300">{raw}</div>
426
+ </div>
427
+ </div>
428
+ </div>
429
+ );
430
+ }
431
+
432
+ interface FilterResultTableProps {
433
+ result: { columns: string[]; rows: unknown[][] } | null;
434
+ selectionKeyIndex: number;
435
+ onRowClick: (row: unknown[]) => void;
436
+ }
437
+
438
+ function FilterResultTable({ result, selectionKeyIndex, onRowClick }: FilterResultTableProps) {
439
+ const scrollRef = useRef<HTMLDivElement>(null);
440
+ const virtualizer = useVirtualizer({
441
+ count: result?.rows.length ?? 0,
442
+ getScrollElement: () => scrollRef.current,
443
+ estimateSize: () => RESULT_ROW_HEIGHT,
444
+ overscan: 20,
445
+ });
446
+
447
+ if (!result) {
448
+ return (
449
+ <div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">
450
+ Add rules and click Run.
451
+ </div>
452
+ );
453
+ }
454
+
455
+ if (result.rows.length === 0) {
456
+ return (
457
+ <div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">
458
+ 0 matches — broaden the rules, lower the limit, or try OR.
459
+ </div>
460
+ );
461
+ }
462
+
463
+ return (
464
+ <div className="flex flex-1 min-h-0 flex-col">
465
+ <div className="flex items-center border-b bg-zinc-50/50 px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground dark:bg-zinc-900/30">
466
+ {result.columns.map((c) => (
467
+ <div key={c} className="flex-1 truncate px-2 font-mono">
468
+ {c}
469
+ </div>
470
+ ))}
471
+ </div>
472
+ <div ref={scrollRef} className="flex-1 overflow-auto">
473
+ <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
474
+ {virtualizer.getVirtualItems().map((vRow) => {
475
+ const row = result.rows[vRow.index];
476
+ const clickable = selectionKeyIndex >= 0;
477
+ return (
478
+ <div
479
+ key={vRow.key}
480
+ style={{
481
+ position: 'absolute',
482
+ top: 0,
483
+ left: 0,
484
+ width: '100%',
485
+ height: vRow.size,
486
+ transform: `translateY(${vRow.start}px)`,
487
+ }}
488
+ className={cn(
489
+ 'flex items-center border-b border-zinc-100 px-3 text-[11px] dark:border-zinc-900',
490
+ clickable && 'cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-800',
491
+ )}
492
+ onClick={() => clickable && onRowClick(row)}
493
+ >
494
+ {result.columns.map((_, i) => (
495
+ <div key={i} className="flex-1 truncate px-2 font-mono">
496
+ {formatCell(row[i])}
497
+ </div>
498
+ ))}
499
+ </div>
500
+ );
501
+ })}
502
+ </div>
503
+ </div>
504
+ </div>
505
+ );
506
+ }
507
+
508
+ function formatCell(v: unknown): string {
509
+ if (v === null || v === undefined) return '';
510
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
511
+ if (typeof v === 'bigint') return v.toString();
512
+ if (typeof v === 'object') return JSON.stringify(v);
513
+ return String(v);
514
+ }