/* 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/. */ /** * CommandPalette — Ctrl+K / Cmd+K * * Raycast-style command palette for the entire viewer. * Keyboard-first, scored search, recent usage tracking. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Dialog, DialogContent, } from '@/components/ui/dialog'; import { Search, Play, MousePointer2, PersonStanding, Ruler, Scissors, Home, Maximize2, Crosshair, GitCompareArrows, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Box, EyeOff, Eye, Equal, Plus, Minus, RotateCcw, SquareX, Building2, Layout, TreeDeciduous, FileCode2, MessageSquare, ClipboardCheck, FileSpreadsheet, Palette, Puzzle, Camera, Download, FileJson, Sun, Info, Orbit, FolderOpen, Clock, Save, CalendarClock, CalendarPlus, Sparkles, Eraser, StickyNote, Pencil, PenLine, Slice, Layers3, SquareStack, ChevronsUpDown, PanelRight, SlidersHorizontal, ChevronsRight, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useViewerStore } from '@/store'; import { applyLevelDisplayMode } from '@/store/levelDisplay'; import { goHomeFromStore, resetVisibilityForHomeFromStore } from '@/store/homeView'; import { executeBasketSet, executeBasketAdd, executeBasketRemove, executeBasketToggleVisibility, executeBasketSaveView, executeBasketClear, } from '@/store/basket/basketCommands'; import { useSandbox } from '@/hooks/useSandbox'; import { useSlotContributions } from '@/hooks/useSlotContributions'; import { useOptionalExtensionHost } from '@/sdk/ExtensionHostProvider'; import { resolveExtensionIcon } from '@/components/extensions/icon-registry'; import type { CommandContribution } from '@ifc-lite/extensions'; import { toast as paletteToast } from '@/components/ui/toast'; import { SCRIPT_TEMPLATES } from '@/lib/scripts/templates'; import { exportGlbFromGeometry } from '@/lib/export/glb'; import { exportCsvFromBytes } from '@/lib/export/csv'; import { getRecentFiles, formatFileSize, getCachedFile } from '@/lib/recent-files'; import type { RecentFileEntry } from '@/lib/recent-files'; import { closeActiveAnalysisExtension } from '@/services/analysis-extensions'; import { describeRunCommandError } from '@/services/extensions/runtime-errors'; // ── Types ────────────────────────────────────────────────────────────── type Category = | 'Recent' | 'File' | 'View' | 'Tools' | 'Visibility' | 'Panels' | 'Export' | 'Automation' | 'Preferences' | 'Extensions'; interface Command { id: string; label: string; keywords: string; // extra search tokens (no UI display) category: Exclude; icon: React.ElementType; shortcut?: string; detail?: string; // subtle secondary text (e.g. file size) action: () => void; } interface FlatItem { cmd: Command; flatIdx: number; } // ── Constants ────────────────────────────────────────────────────────── const RECENT_KEY = 'ifc-lite:cmd-palette:recent'; const MAX_RECENT = 5; const CATEGORY_ORDER: Category[] = [ 'Recent', 'File', 'View', 'Tools', 'Visibility', 'Panels', 'Export', 'Automation', 'Preferences', ]; // ── Search scoring ───────────────────────────────────────────────────── /** * Score how well `query` matches `text`. * 0 = no match * 100 = exact substring * 50 = word-start initials * 1-25 = tight fuzzy (avg gap ≤ 5) */ function score(query: string, text: string): number { const q = query.toLowerCase(); const t = text.toLowerCase(); // Exact substring if (t.includes(q)) return 100; // Word-start initials (e.g. "cs" → "Color Spaces") const words = t.split(/[\s\-_:\/,]+/); let wi = 0, qi = 0; while (wi < words.length && qi < q.length) { if (words[wi].length > 0 && words[wi][0] === q[qi]) qi++; wi++; } if (qi === q.length) return 50; // Tight fuzzy — reject if chars are scattered let lastIdx = -1, totalGap = 0; qi = 0; for (let i = 0; i < t.length && qi < q.length; i++) { if (t[i] === q[qi]) { if (lastIdx >= 0) totalGap += i - lastIdx - 1; lastIdx = i; qi++; } } if (qi < q.length) return 0; const avgGap = q.length > 1 ? totalGap / (q.length - 1) : 0; if (avgGap > 5) return 0; return Math.max(1, 25 - Math.round(avgGap * 3)); } /** Rank a command against the search query. Label dominates. */ function rankCommand(cmd: Command, query: string): number { const l = score(query, cmd.label); const k = score(query, cmd.keywords) * 0.9; const c = score(query, cmd.category) * 0.5; return Math.max(l, k, c); } // ── Recent usage ─────────────────────────────────────────────────────── function getRecentIds(): string[] { try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]'); } catch { return []; } } function recordUsage(id: string) { try { const r = getRecentIds().filter(x => x !== id); r.unshift(id); localStorage.setItem(RECENT_KEY, JSON.stringify(r.slice(0, 30))); } catch { /* noop */ } } // ── Utilities ────────────────────────────────────────────────────────── function downloadBlob(data: BlobPart | Uint8Array, name: string, mime: string) { // The Rust/wasm exporters return `Uint8Array`, which TS 5.7 // no longer treats as a `BlobPart`. Copy into a fresh ArrayBuffer-backed view // (same coercion as GLBExportDialog) so the Blob constructor accepts it. const part: BlobPart = data instanceof Uint8Array ? new Uint8Array(data) : data; const url = URL.createObjectURL(new Blob([part], { type: mime })); Object.assign(document.createElement('a'), { href: url, download: name }).click(); URL.revokeObjectURL(url); } /** Toggle a sidebar workspace panel (#1208). The store's `toggleWorkspacePanel` * owns the single-tenant + re-dock + detach semantics; a second activation * closes the panel back to the Information fallback. Closing any active * analysis extension first preserves the prior "panels win the slot" behavior. * Kept as two thin helpers so every existing command action keeps its call * site (the `'list'` legacy id maps to the registry's `'lists'`). */ function activateRightPanel(panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'compare' | 'extensions') { closeActiveAnalysisExtension(); useViewerStore.getState().toggleWorkspacePanel(panel); } /** Bottom panel (Script / List / Gantt) — mutually exclusive in the bottom * strip, independent of the sidebar. Toggling the active one closes it. */ function activateBottomPanel(panel: 'script' | 'list' | 'gantt') { const s = useViewerStore.getState(); const isActive = panel === 'script' ? s.scriptPanelVisible : panel === 'list' ? s.listPanelVisible : s.ganttPanelVisible; closeActiveAnalysisExtension(); s.setScriptPanelVisible(false); s.setListPanelVisible(false); s.setGanttPanelVisible(false); if (!isActive) { s.setRightPanelCollapsed(false); if (panel === 'script') s.setScriptPanelVisible(true); else if (panel === 'list') s.setListPanelVisible(true); else s.setGanttPanelVisible(true); } } // ── Component ────────────────────────────────────────────────────────── interface CommandPaletteProps { open: boolean; onOpenChange: (open: boolean) => void; } export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); const [recentIds, setRecentIds] = useState([]); const [recentFiles, setRecentFiles] = useState([]); const inputRef = useRef(null); const listRef = useRef(null); const navigatedByKeyboard = useRef(false); const { execute } = useSandbox(); const extensionCommands = useSlotContributions('commandPalette'); const extensionHost = useOptionalExtensionHost(); useEffect(() => { if (open) { setRecentIds(getRecentIds()); setRecentFiles(getRecentFiles()); setQuery(''); requestAnimationFrame(() => inputRef.current?.focus()); } }, [open]); // ── Command definitions ── const commands = useMemo(() => { const c: Command[] = []; // ── File ── c.push( { id: 'file:open', label: 'Open File', keywords: 'ifc ifcx glb load model browse', category: 'File', icon: FolderOpen, action: () => { const input = document.getElementById('file-input-open') as HTMLInputElement | null; if (input) input.click(); } }, ); for (const rf of recentFiles) { const fileName = rf.name; c.push({ id: `file:recent:${fileName}`, label: fileName, keywords: `recent open ${formatFileSize(rf.size)}`, category: 'File', icon: Clock, detail: formatFileSize(rf.size), action: () => { // Try loading from IndexedDB blob cache → dispatches to MainToolbar's loadFile getCachedFile(rf).then(file => { if (file) { window.dispatchEvent(new CustomEvent('ifc-lite:load-file', { detail: file })); } else { // Cache miss — fall back to file picker const input = document.getElementById('file-input-open') as HTMLInputElement | null; if (input) input.click(); } }); }, }); } // ── View ── c.push( { id: 'view:home', label: 'Home', keywords: 'isometric reset camera', category: 'View', icon: Home, shortcut: 'H', action: () => { goHomeFromStore(); } }, { id: 'view:fit', label: 'Fit All', keywords: 'zoom extents entire model', category: 'View', icon: Maximize2, shortcut: 'Z', action: () => { useViewerStore.getState().cameraCallbacks.fitAll?.(); } }, { id: 'view:frame', label: 'Frame Selection', keywords: 'zoom focus selected', category: 'View', icon: Crosshair, shortcut: 'F', action: () => { useViewerStore.getState().cameraCallbacks.frameSelection?.(); } }, { id: 'view:stacked', label: 'Level — Stacked', keywords: 'level display mode stacked default storey storeys', category: 'View', icon: Layers3, action: () => { applyLevelDisplayMode('stacked'); } }, { id: 'view:exploded', label: 'Level — Exploded', keywords: 'level display mode exploded explode lift storey storeys gap', category: 'View', icon: ChevronsUpDown, action: () => { applyLevelDisplayMode('exploded'); } }, { id: 'view:solo', label: 'Level — Solo', keywords: 'level display mode solo isolate storey single only top', category: 'View', icon: SquareStack, action: () => { applyLevelDisplayMode('solo'); } }, { id: 'view:projection', label: 'Projection', keywords: 'perspective orthographic ortho toggle switch', category: 'View', icon: Orbit, action: () => { useViewerStore.getState().toggleProjectionMode(); } }, { id: 'view:top', label: 'Top View', keywords: 'camera plan', category: 'View', icon: ArrowUp, shortcut: '1', action: () => { useViewerStore.getState().cameraCallbacks.setPresetView?.('top'); } }, { id: 'view:bottom', label: 'Bottom View', keywords: 'camera', category: 'View', icon: ArrowDown, shortcut: '2', action: () => { useViewerStore.getState().cameraCallbacks.setPresetView?.('bottom'); } }, { id: 'view:front', label: 'Front View', keywords: 'camera elevation', category: 'View', icon: ArrowRight, shortcut: '3', action: () => { useViewerStore.getState().cameraCallbacks.setPresetView?.('front'); } }, { id: 'view:back', label: 'Back View', keywords: 'camera', category: 'View', icon: ArrowLeft, shortcut: '4', action: () => { useViewerStore.getState().cameraCallbacks.setPresetView?.('back'); } }, { id: 'view:left', label: 'Left View', keywords: 'camera', category: 'View', icon: ArrowLeft, shortcut: '5', action: () => { useViewerStore.getState().cameraCallbacks.setPresetView?.('left'); } }, { id: 'view:right', label: 'Right View', keywords: 'camera', category: 'View', icon: ArrowRight, shortcut: '6', action: () => { useViewerStore.getState().cameraCallbacks.setPresetView?.('right'); } }, ); // ── Tools ── c.push( { id: 'tool:select', label: 'Select', keywords: 'pick click pointer', category: 'Tools', icon: MousePointer2, shortcut: 'V', action: () => { useViewerStore.getState().setActiveTool('select'); } }, { id: 'tool:walk', label: 'Walk', keywords: 'first person navigate wasd', category: 'Tools', icon: PersonStanding, shortcut: 'C', action: () => { useViewerStore.getState().setActiveTool('walk'); } }, { id: 'tool:measure', label: 'Measure', keywords: 'distance ruler dimension', category: 'Tools', icon: Ruler, shortcut: 'M', action: () => { useViewerStore.getState().setActiveTool('measure'); } }, { id: 'tool:section', label: 'Section', keywords: 'clip cut plane', category: 'Tools', icon: Scissors, shortcut: 'X', action: () => { useViewerStore.getState().setActiveTool('section'); } }, { id: 'tool:annotate', label: 'Annotate', keywords: 'pin note comment marker', category: 'Tools', icon: StickyNote, shortcut: 'P', action: () => { useViewerStore.getState().setActiveTool('annotate'); } }, { id: 'tool:add-element', label: 'Add Element', keywords: 'wall slab beam column place drop new add element generic', category: 'Tools', icon: Box, action: () => { useViewerStore.getState().setActiveTool('addElement'); } }, { id: 'tool:edit-mode', label: 'Toggle Edit Mode', keywords: 'edit mode pen unlock readonly properties geometry author modify', category: 'Tools', icon: PenLine, shortcut: 'E', action: () => { useViewerStore.getState().toggleEditEnabled(); } }, { id: 'tool:split', label: 'Split selected entity', keywords: 'split cut knife slice divide segment break wall beam column slab selected', category: 'Tools', icon: Slice, shortcut: 'K', action: () => { const s = useViewerStore.getState(); const sel = s.selectedEntity; if (!sel) return; s.setSplitTarget(sel.modelId, sel.expressId); s.setActiveTool('split'); } }, // Add-element gestures live entirely in the AddElementPanel // (opened via `setActiveTool('addElement')` — see the // dedicated "Add Element" command below). Per-type shortcuts // duplicated that panel's UI and have been dropped. ); // ── Visibility ── c.push( { id: 'vis:hide', label: 'Hide Selection', keywords: 'hide selected invisible', category: 'Visibility', icon: EyeOff, shortcut: 'Del', action: () => { const s = useViewerStore.getState(); const ids = s.selectedEntityIds.size > 0 ? Array.from(s.selectedEntityIds) : s.selectedEntityId !== null ? [s.selectedEntityId] : []; if (ids.length > 0) { s.hideEntities(ids); s.clearSelection(); } } }, { id: 'vis:show', label: 'Show All', keywords: 'unhide reset visible', category: 'Visibility', icon: Eye, shortcut: 'A', action: () => { resetVisibilityForHomeFromStore(); } }, { id: 'vis:set-iso', label: 'Isolate (Set Basket)', keywords: 'basket isolate set selection hierarchy view equals', category: 'Visibility', icon: Equal, shortcut: 'I', action: () => executeBasketSet() }, { id: 'vis:add-iso', label: 'Add to Basket', keywords: 'basket plus selection hierarchy view', category: 'Visibility', icon: Plus, shortcut: '+', action: () => executeBasketAdd() }, { id: 'vis:remove-iso', label: 'Remove from Basket', keywords: 'basket minus selection hierarchy view', category: 'Visibility', icon: Minus, shortcut: '−', action: () => executeBasketRemove() }, { id: 'vis:toggle-iso', label: 'Toggle Basket Visibility', keywords: 'basket show hide', category: 'Visibility', icon: Eye, action: () => executeBasketToggleVisibility() }, { id: 'vis:save-view', label: 'Save Basket as View', keywords: 'basket presentation thumbnail', category: 'Visibility', icon: Save, action: () => executeBasketSaveView().catch((err) => { console.error('[CommandPalette] Failed to save basket view:', err); }) }, { id: 'vis:toggle-presentation', label: 'Toggle Basket Presentation Dock', keywords: 'basket panel carousel thumbnails', category: 'Visibility', icon: Layout, action: () => { useViewerStore.getState().toggleBasketPresentationVisible(); } }, { id: 'vis:clear-iso', label: 'Clear Basket', keywords: 'basket clear reset', category: 'Visibility', icon: RotateCcw, action: () => executeBasketClear() }, { id: 'vis:spaces', label: 'Spaces', keywords: 'IfcSpace rooms show hide', category: 'Visibility', icon: Box, action: () => { useViewerStore.getState().toggleTypeVisibility('spaces'); } }, { id: 'vis:spatialZones', label: 'Spatial Zones', keywords: 'IfcSpatialZone gross area GFA show hide', category: 'Visibility', icon: Box, action: () => { useViewerStore.getState().toggleTypeVisibility('spatialZones'); } }, { id: 'vis:openings', label: 'Openings', keywords: 'IfcOpeningElement show hide', category: 'Visibility', icon: SquareX, action: () => { useViewerStore.getState().toggleTypeVisibility('openings'); } }, { id: 'vis:site', label: 'Site', keywords: 'IfcSite terrain show hide', category: 'Visibility', icon: Building2, action: () => { useViewerStore.getState().toggleTypeVisibility('site'); } }, { id: 'vis:ifcAnnotations', label: 'Annotations', keywords: 'IfcAnnotation 2d drawing symbols text dimension leader label show hide', category: 'Visibility', icon: Pencil, action: () => { useViewerStore.getState().toggleTypeVisibility('ifcAnnotations'); } }, // Issue #862: IfcGrid split off from IfcAnnotation so dense-grid // models can hide axes/bubbles without losing dimensions. { id: 'vis:ifcGrid', label: 'Grids', keywords: 'IfcGrid IfcGridAxis grid axis bubble tag show hide section clip', category: 'Visibility', icon: Pencil, action: () => { useViewerStore.getState().toggleTypeVisibility('ifcGrid'); } }, { id: 'vis:reset-colors', label: 'Reset Colors', keywords: 'clear color override', category: 'Visibility', icon: Palette, action: () => { execute('bim.viewer.resetColors()\nconsole.log("Colors reset")'); } }, ); // ── Panels ── c.push( { id: 'panel:properties', label: 'Inspector', keywords: 'properties attributes material classification schedule task panel right information', category: 'Panels', icon: Layout, action: () => { useViewerStore.getState().showWorkspacePanel('properties'); } }, { id: 'panel:tree', label: 'Spatial Tree', keywords: 'hierarchy left panel', category: 'Panels', icon: TreeDeciduous, action: () => { const s = useViewerStore.getState(); s.setLeftPanelCollapsed(!s.leftPanelCollapsed); } }, { id: 'panel:script', label: 'Script Editor', keywords: 'code automation console', category: 'Panels', icon: FileCode2, action: () => { activateBottomPanel('script'); } }, { id: 'panel:bcf', label: 'BCF Issues', keywords: 'collaboration topics comments viewpoint', category: 'Panels', icon: MessageSquare, action: () => { activateRightPanel('bcf'); } }, { id: 'panel:ids', label: 'IDS Validation', keywords: 'information delivery specification check', category: 'Panels', icon: ClipboardCheck, action: () => { activateRightPanel('ids'); } }, { id: 'panel:clash', label: 'Clash Detection', keywords: 'collision interference clearance coordination clash matrix mep', category: 'Panels', icon: Crosshair, action: () => { activateRightPanel('clash'); } }, { id: 'panel:compare', label: 'Compare Models', keywords: 'diff revision version change added deleted modified geometry data', category: 'Panels', icon: GitCompareArrows, action: () => { activateRightPanel('compare'); } }, { id: 'panel:lists', label: 'Entity Lists', keywords: 'table spreadsheet', category: 'Panels', icon: FileSpreadsheet, action: () => { activateBottomPanel('list'); } }, { id: 'panel:gantt', label: 'Construction Schedule (Gantt)', keywords: '4d timeline tasks ifctask sequence playback animation', category: 'Panels', icon: CalendarClock, action: () => { activateBottomPanel('gantt'); } }, { id: 'panel:lens', label: 'Lens Rules', keywords: 'color filter highlight', category: 'Panels', icon: Palette, action: () => { activateRightPanel('lens'); } }, { id: 'panel:extensions', label: 'Extensions', keywords: 'extension plugin install manage iflx', category: 'Panels', icon: Puzzle, action: () => { activateRightPanel('extensions'); } }, // ── Customization entry points — first-class discoverability // for new users who don't know extensions/flavors exist. Each // routes to the right surface and pre-seeds context where // helpful (e.g. open Ideas tab with the empty-plan flow). { id: 'extensions:author', label: 'Author an extension…', keywords: 'create new build plan chat ai extension generate', category: 'Tools', icon: Sparkles, action: () => { const s = useViewerStore.getState(); activateRightPanel('extensions'); s.setExtensionsRequestedView('ideas'); s.setIdeasOpenEmptyPlan(true); } }, { id: 'extensions:flavors', label: 'Manage flavors…', keywords: 'flavor profile switch export import merge customization', category: 'Panels', icon: Palette, action: () => { useViewerStore.getState().setFlavorDialogRequested(true); } }, // ── Sidebar layout (#1208) ── { id: 'sidebar:toggle', label: 'Toggle Sidebar', keywords: 'sidebar panels show hide off optional workspace', category: 'Panels', icon: PanelRight, shortcut: 'Alt+\\', action: () => { useViewerStore.getState().toggleSidebar(); } }, { id: 'sidebar:collapse', label: 'Collapse Sidebar to Icons', keywords: 'sidebar collapse icons rail minimize', category: 'Panels', icon: ChevronsRight, action: () => { useViewerStore.getState().setSidebarMode('collapsed'); } }, { id: 'sidebar:customize', label: 'Customize Sidebar…', keywords: 'sidebar customize reorder hide show panels edit arrange', category: 'Panels', icon: SlidersHorizontal, action: () => { const s = useViewerStore.getState(); s.setSidebarMode('expanded'); s.setSidebarCustomizing(true); } }, { id: 'sidebar:reset', label: 'Reset Sidebar Layout', keywords: 'sidebar reset default order width restore', category: 'Panels', icon: RotateCcw, action: () => { useViewerStore.getState().resetSidebarLayout(); } }, ); // ── Schedule / 4D (Tools) ───────────────────────────── c.push( { id: 'schedule:generate', label: 'Generate Schedule from Storeys…', keywords: '4d ifctask construction sequence storey building create gantt', category: 'Tools', icon: CalendarPlus, action: () => { // Make sure the Gantt panel is mounted so the dialog has a host // before flipping the dialog flag. Order matters — closing other // panels first prevents the bottom strip from rendering them. const s = useViewerStore.getState(); if (!s.ganttPanelVisible) activateBottomPanel('gantt'); // Same tick is fine — the dialog is portalled via Radix and doesn't // depend on GanttPanel finishing its first render. useViewerStore.getState().setGenerateScheduleDialogOpen(true); } }, { id: 'schedule:toggle-animation', label: 'Toggle 4D Construction Animation', keywords: 'play pause schedule task gantt simulation', category: 'Visibility', icon: Sparkles, action: () => { const s = useViewerStore.getState(); s.setAnimationEnabled(!s.animationEnabled); } }, { id: 'schedule:reset', label: 'Reset Schedule (Clear 4D Data)', keywords: 'remove gantt tasks ifctask delete clear', category: 'Tools', icon: Eraser, action: () => { const s = useViewerStore.getState(); s.setScheduleData(null); s.setAnimationEnabled(false); s.pauseSchedule(); } }, ); // ── Export ── c.push( { id: 'export:screenshot', label: 'Screenshot', keywords: 'capture png image viewport', category: 'Export', icon: Camera, action: () => { const canvas = document.querySelector('canvas'); if (!canvas) return; try { const d = canvas.toDataURL('image/png'); Object.assign(document.createElement('a'), { href: d, download: 'screenshot.png' }).click(); } catch (e) { console.error('Screenshot failed:', e); } } }, { id: 'export:glb', label: 'Export GLB', keywords: '3d model gltf download', category: 'Export', icon: Download, action: async () => { const gr = useViewerStore.getState().geometryResult; if (!gr) return; try { downloadBlob(await exportGlbFromGeometry(gr, { includeMetadata: true }), 'model.glb', 'model/gltf-binary'); } catch (e) { console.error('GLB export failed:', e); } } }, { id: 'export:csv-entities', label: 'Export CSV: Entities', keywords: 'spreadsheet properties download', category: 'Export', icon: FileSpreadsheet, action: async () => { const d = useViewerStore.getState().ifcDataStore; if (!d?.source) return; try { downloadBlob(await exportCsvFromBytes(d.source, 'entities', { includeProperties: true }), 'entities.csv', 'text/csv'); } catch (e) { console.error(e); } } }, { id: 'export:csv-properties', label: 'Export CSV: Properties', keywords: 'pset spreadsheet download', category: 'Export', icon: FileSpreadsheet, action: async () => { const d = useViewerStore.getState().ifcDataStore; if (!d?.source) return; try { downloadBlob(await exportCsvFromBytes(d.source, 'properties'), 'properties.csv', 'text/csv'); } catch (e) { console.error(e); } } }, { id: 'export:csv-quantities', label: 'Export CSV: Quantities', keywords: 'qto spreadsheet download', category: 'Export', icon: FileSpreadsheet, action: async () => { const d = useViewerStore.getState().ifcDataStore; if (!d?.source) return; try { downloadBlob(await exportCsvFromBytes(d.source, 'quantities'), 'quantities.csv', 'text/csv'); } catch (e) { console.error(e); } } }, { id: 'export:csv-spatial', label: 'Export CSV: Spatial', keywords: 'hierarchy spreadsheet download', category: 'Export', icon: FileSpreadsheet, action: async () => { const d = useViewerStore.getState().ifcDataStore; if (!d?.source) return; try { downloadBlob(await exportCsvFromBytes(d.source, 'spatial'), 'spatial-hierarchy.csv', 'text/csv'); } catch (e) { console.error(e); } } }, { id: 'export:json', label: 'Export JSON', keywords: 'data entities all download', category: 'Export', icon: FileJson, action: () => { const d = useViewerStore.getState().ifcDataStore; if (!d) return; try { const out: Record[] = []; for (let i = 0; i < d.entities.count; i++) { const id = d.entities.expressId[i]; out.push({ expressId: id, globalId: d.entities.getGlobalId(id), name: d.entities.getName(id), type: d.entities.getTypeName(id), properties: d.properties.getForEntity(id) }); } downloadBlob(JSON.stringify({ entities: out }, null, 2), 'model-data.json', 'application/json'); } catch (e) { console.error(e); } } }, ); // ── Automation (scripts — last, power-user feature) ── for (const t of SCRIPT_TEMPLATES) { c.push({ id: `auto:${t.name}`, label: t.name, keywords: `script run ${t.description}`, category: 'Automation', icon: Play, action: () => { const s = useViewerStore.getState(); s.setListPanelVisible(false); s.setScriptPanelVisible(true); s.setScriptEditorContent(t.code); execute(t.code); }, }); } // ── Preferences ── c.push( { id: 'pref:theme', label: 'Theme', keywords: 'dark light mode appearance switch', category: 'Preferences', icon: Sun, shortcut: 'T', action: () => { useViewerStore.getState().toggleTheme(); } }, { id: 'pref:tooltips', label: 'Hover Tooltips', keywords: 'entity info mouse hover show hide', category: 'Preferences', icon: Info, action: () => { useViewerStore.getState().toggleHoverTooltips(); } }, ); // ── Extension contributions ── // Surfaced under the "Extensions" category. Clicking dispatches // through the activation event so the runtime executes the // bundle's command handler (or surfaces the failure clearly). for (const contribution of extensionCommands) { const payload = contribution.payload; if (!payload?.id || !payload.title) continue; c.push({ id: `ext:${payload.id}`, label: payload.title, keywords: `${payload.id} ${payload.paletteCategory ?? ''} extension`, category: 'Extensions', // `resolveExtensionIcon` is the shared icon registry the // picker writes against, so the icon the user chose is the // icon shown in the palette. icon: resolveExtensionIcon(payload.icon), detail: payload.paletteCategory, action: () => { if (!extensionHost) return; // Fire the activation event first so onCommand:-subscribed // extensions wake up, then invoke the command handler. The // runtime dedupes activations. void extensionHost.dispatcher .fire(`onCommand:${payload.id}` as `onCommand:${string}`) .then(() => extensionHost.runCommand(payload.id)) .catch((err) => { paletteToast.error(describeRunCommandError(payload.id, err)); }); }, }); } return c; }, [execute, recentFiles, extensionCommands, extensionHost]); // ── Search: score, filter, sort ── // When searching, results are FLAT sorted by relevance — no category grouping. // When browsing (no query), results are grouped by category. const { grouped, flatItems } = useMemo(() => { const groups: { category: string; items: FlatItem[] }[] = []; const flat: FlatItem[] = []; let idx = 0; if (query) { // ── Searching: flat ranked list, no categories ── const scored = commands .map(cmd => ({ cmd, s: rankCommand(cmd, query) })) .filter(x => x.s > 0); scored.sort((a, b) => b.s - a.s); if (scored.length > 0) { const items: FlatItem[] = scored.map(({ cmd }) => { const item = { cmd, flatIdx: idx++ }; flat.push(item); return item; }); groups.push({ category: '', items }); // empty category = no header } } else { // ── Browsing: recent on top, then categories ── if (recentIds.length > 0) { const items: FlatItem[] = []; for (const id of recentIds.slice(0, MAX_RECENT)) { const cmd = commands.find(c => c.id === id); if (cmd) { const item = { cmd, flatIdx: idx++ }; items.push(item); flat.push(item); } } if (items.length > 0) groups.push({ category: 'Recent', items }); } for (const cat of CATEGORY_ORDER) { if (cat === 'Recent') continue; const catCmds = commands.filter(c => c.category === cat); if (catCmds.length > 0) { const items: FlatItem[] = catCmds.map(cmd => { const item = { cmd, flatIdx: idx++ }; flat.push(item); return item; }); groups.push({ category: cat, items }); } } } return { grouped: groups, flatItems: flat }; }, [commands, query, recentIds]); useEffect(() => { setSelectedIndex(0); }, [query, open]); useEffect(() => { if (!navigatedByKeyboard.current || !listRef.current) return; navigatedByKeyboard.current = false; const el = listRef.current.querySelector(`[data-index="${selectedIndex}"]`) as HTMLElement | null; el?.scrollIntoView({ block: 'nearest' }); }, [selectedIndex]); const runCommand = useCallback((cmd: Command) => { onOpenChange(false); recordUsage(cmd.id); requestAnimationFrame(() => cmd.action()); }, [onOpenChange]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); navigatedByKeyboard.current = true; setSelectedIndex(i => Math.min(i + 1, flatItems.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); navigatedByKeyboard.current = true; setSelectedIndex(i => Math.max(i - 1, 0)); } else if (e.key === 'Enter') { e.preventDefault(); const item = flatItems[selectedIndex]; if (item) runCommand(item.cmd); } }, [flatItems, selectedIndex, runCommand]); return ( {/* Search */}
setQuery(e.target.value)} onKeyDown={handleKeyDown} placeholder="What do you need?" className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" autoComplete="off" spellCheck={false} /> Esc
{/* Results */}
{flatItems.length === 0 && (
No results
)} {grouped.map((group) => (
{group.category && (
{group.category}
)} {group.items.map(({ cmd, flatIdx }) => { const Icon = cmd.icon; return ( ); })}
))}
{/* Footer */}
↑↓ navigate run esc close
); }