Skip to content

Commit c7a6ddb

Browse files
authored
feat(viewer): refresh button to reload the open model from disk (#1345)
Adds a Refresh button that re-reads the open model from disk and re-renders it, so a model can be monitored during design without re-opening it (issue #1345). - Capture a live File System Access handle on every Chromium open path (toolbar, empty-state, drag-drop, Add Model, command palette); re-read via getFile(). - "Refresh all" re-reads every handle-backed model and reloads / rebuilds the federation, preserving id/order/visibility; partial failures reported. - Chromium-only by design; <input type="file"> fallback elsewhere (no button). - Fix command-palette file open on Chrome (user-activation lost to a rAF hop). - Fix recent-files blob cache: it awaited file.arrayBuffer() mid-IndexedDB transaction, so it never persisted and recents always re-picked. - CodeRabbit review addressed (case-insensitive IFCX, picker result filtering, await reload, aria-label, drop file/handle pairing, bounded cache reads).
1 parent 19ca66c commit c7a6ddb

9 files changed

Lines changed: 605 additions & 91 deletions

File tree

apps/viewer/src/components/viewer/CommandPalette.tsx

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ import { SCRIPT_TEMPLATES } from '@/lib/scripts/templates';
9292
import { exportGlbFromGeometry } from '@/lib/export/glb';
9393
import { exportCsvFromBytes } from '@/lib/export/csv';
9494
import { downloadFile } from '@/lib/export/download';
95-
import { getRecentFiles, formatFileSize, getCachedFile } from '@/lib/recent-files';
95+
import { getRecentFiles, formatFileSize, getCachedFile, getCachedFileNames } from '@/lib/recent-files';
9696
import type { RecentFileEntry } from '@/lib/recent-files';
9797
import { closeActiveAnalysisExtension } from '@/services/analysis-extensions';
9898
import { describeRunCommandError } from '@/services/extensions/runtime-errors';
@@ -120,6 +120,13 @@ interface Command {
120120
shortcut?: string;
121121
detail?: string; // subtle secondary text (e.g. file size)
122122
action: () => void;
123+
/**
124+
* Run the action synchronously inside the click handler instead of deferring
125+
* to the next animation frame. Required for actions that open a file dialog:
126+
* Chrome only honours `input.click()` / `showOpenFilePicker()` while transient
127+
* user activation is live, which a `requestAnimationFrame` hop would discard.
128+
*/
129+
immediate?: boolean;
123130
}
124131

125132
interface FlatItem {
@@ -246,6 +253,9 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
246253
const inputRef = useRef<HTMLInputElement>(null);
247254
const listRef = useRef<HTMLDivElement>(null);
248255
const navigatedByKeyboard = useRef(false);
256+
// Names currently in the blob cache, refreshed each open. Lets recent-file
257+
// clicks decide hit/miss without an async gap that would void user activation.
258+
const cachedNamesRef = useRef<Set<string>>(new Set());
249259

250260
const { execute } = useSandbox();
251261
const extensionCommands = useSlotContributions<CommandContribution>('commandPalette');
@@ -255,6 +265,7 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
255265
if (open) {
256266
setRecentIds(getRecentIds());
257267
setRecentFiles(getRecentFiles());
268+
void getCachedFileNames().then((names) => { cachedNamesRef.current = new Set(names); });
258269
setQuery('');
259270
requestAnimationFrame(() => inputRef.current?.focus());
260271
}
@@ -265,11 +276,15 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
265276
const c: Command[] = [];
266277

267278
// ── File ──
279+
// `immediate` so the open action runs inside the click gesture: opening a
280+
// file dialog needs live user activation, which a rAF hop would discard.
281+
// The actual picker is driven by MainToolbar's handleOpenClick (via the
282+
// `ifc-lite:open-files` event) so palette opens capture a live handle too.
268283
c.push(
269284
{ id: 'file:open', label: 'Open File', keywords: 'ifc ifcx glb load model browse', category: 'File', icon: FolderOpen,
285+
immediate: true,
270286
action: () => {
271-
const input = document.getElementById('file-input-open') as HTMLInputElement | null;
272-
if (input) input.click();
287+
window.dispatchEvent(new CustomEvent('ifc-lite:open-files'));
273288
} },
274289
);
275290
for (const rf of recentFiles) {
@@ -279,17 +294,20 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
279294
keywords: `recent open ${formatFileSize(rf.size)}`,
280295
category: 'File', icon: Clock,
281296
detail: formatFileSize(rf.size),
297+
immediate: true,
282298
action: () => {
283-
// Try loading from IndexedDB blob cache → dispatches to MainToolbar's loadFile
284-
getCachedFile(rf).then(file => {
285-
if (file) {
286-
window.dispatchEvent(new CustomEvent('ifc-lite:load-file', { detail: file }));
287-
} else {
288-
// Cache miss — fall back to file picker
289-
const input = document.getElementById('file-input-open') as HTMLInputElement | null;
290-
if (input) input.click();
291-
}
292-
});
299+
// Cached (decided synchronously from the pre-loaded key set): load the
300+
// blob — dispatching a load event needs no user activation.
301+
if (cachedNamesRef.current.has(fileName)) {
302+
void getCachedFile(rf).then(file => {
303+
if (file) window.dispatchEvent(new CustomEvent('ifc-lite:load-file', { detail: file }));
304+
else window.dispatchEvent(new CustomEvent('ifc-lite:open-files'));
305+
});
306+
} else {
307+
// Not cached — re-pick. Synchronous within the gesture so the dialog
308+
// actually opens on Chrome.
309+
window.dispatchEvent(new CustomEvent('ifc-lite:open-files'));
310+
}
293311
},
294312
});
295313
}
@@ -634,7 +652,13 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
634652
const runCommand = useCallback((cmd: Command) => {
635653
onOpenChange(false);
636654
recordUsage(cmd.id);
637-
requestAnimationFrame(() => cmd.action());
655+
// File-dialog actions must run while user activation is still live; deferring
656+
// them to a frame later voids it and Chrome silently ignores the dialog.
657+
if (cmd.immediate) {
658+
cmd.action();
659+
} else {
660+
requestAnimationFrame(() => cmd.action());
661+
}
638662
}, [onOpenChange]);
639663

640664
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {

apps/viewer/src/components/viewer/MainToolbar.tsx

Lines changed: 183 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
Redo2,
5252
Boxes,
5353
Shapes,
54+
RefreshCw,
5455
} from 'lucide-react';
5556
import { Button } from '@/components/ui/button';
5657
import { Switch } from '@/components/ui/switch';
@@ -69,7 +70,7 @@ import {
6970
DropdownMenuSubContent,
7071
} from '@/components/ui/dropdown-menu';
7172
import { Progress } from '@/components/ui/progress';
72-
import { useViewerStore, isIfcxDataStore } from '@/store';
73+
import { useViewerStore, isIfcxDataStore, type FederatedModel } from '@/store';
7374
import { goHomeFromStore, resetVisibilityForHomeFromStore } from '@/store/homeView';
7475
import { executeBasketIsolate } from '@/store/basket/basketCommands';
7576
import { useIfc } from '@/hooks/useIfc';
@@ -85,6 +86,11 @@ import { DataConnector } from './DataConnector';
8586
import { ExportChangesButton } from './ExportChangesButton';
8687
import { SearchInline } from './SearchInline';
8788
import { recordRecentFiles, cacheFileBlobs } from '@/lib/recent-files';
89+
import {
90+
supportsFileSystemAccess,
91+
openIfcFilesWithHandles,
92+
readFreshFile,
93+
} from '@/services/file-system-access';
8894
import { ThemeSwitch } from './ThemeSwitch';
8995
import { ExtensionToolbarSlot } from '@/components/extensions/ExtensionToolbarSlot';
9096
import { toast } from '@/components/ui/toast';
@@ -282,6 +288,19 @@ function ActionButton({ icon: Icon, label, onClick, shortcut, disabled }: Action
282288
}
283289
// #endregion
284290

291+
/** Extensions the viewer can ingest (IFC / IFCX / GLB / point clouds). */
292+
function isSupportedModelFile(f: File): boolean {
293+
const n = f.name.toLowerCase();
294+
return n.endsWith('.ifc') || n.endsWith('.ifcx') || n.endsWith('.glb')
295+
|| n.endsWith('.las') || n.endsWith('.laz') || n.endsWith('.ply') || n.endsWith('.pcd')
296+
|| n.endsWith('.e57') || n.endsWith('.pts') || n.endsWith('.xyz');
297+
}
298+
299+
/** Case-insensitive IFCX check (filenames are accepted case-insensitively). */
300+
function isIfcxModelFile(f: File): boolean {
301+
return f.name.toLowerCase().endsWith('.ifcx');
302+
}
303+
285304
interface MainToolbarProps {
286305
onShowShortcuts?: () => void;
287306
}
@@ -428,11 +447,8 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
428447
const files = e.target.files;
429448
if (!files || files.length === 0) return;
430449

431-
// Filter to supported files (IFC, IFCX, GLB)
432-
const supportedFiles = Array.from(files).filter(
433-
f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
434-
|| f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57') || f.name.toLowerCase().endsWith('.pts') || f.name.toLowerCase().endsWith('.xyz')
435-
);
450+
// Filter to supported files (IFC, IFCX, GLB, point clouds)
451+
const supportedFiles = Array.from(files).filter(isSupportedModelFile);
436452

437453
if (supportedFiles.length === 0) return;
438454

@@ -465,38 +481,161 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
465481
e.target.value = '';
466482
}, [loadFile, loadFilesSequentially, loadFederatedIfcx, resetViewerState, clearAllModels]);
467483

468-
const handleAddModelSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
469-
const files = e.target.files;
470-
if (!files || files.length === 0) return;
471-
472-
// Filter to supported files (IFC, IFCX, GLB)
473-
const supportedFiles = Array.from(files).filter(
474-
f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
475-
|| f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57') || f.name.toLowerCase().endsWith('.pts') || f.name.toLowerCase().endsWith('.xyz')
476-
);
477-
484+
// Shared Add-Model routing. `handles` is positionally aligned with
485+
// `supportedFiles`, carrying a live FS Access handle per file (Chromium) so
486+
// each added model stays part of a refreshable federation.
487+
const addSupportedFiles = useCallback((
488+
supportedFiles: File[],
489+
handles?: (FileSystemFileHandle | undefined)[],
490+
) => {
478491
if (supportedFiles.length === 0) return;
479-
480-
// Check if adding IFCX files
481-
const newFilesAreIfcx = supportedFiles.every(f => f.name.endsWith('.ifcx'));
492+
const newFilesAreIfcx = supportedFiles.every(isIfcxModelFile);
482493
const existingIsIfcx = isIfcxDataStore(ifcDataStore);
483494

484495
if (newFilesAreIfcx && existingIsIfcx) {
485496
// Adding IFCX overlay(s) to existing IFCX model - re-compose with new layers
486497
console.log(`[MainToolbar] Adding ${supportedFiles.length} IFCX overlay(s) to existing IFCX model - re-composing`);
487-
addIfcxOverlays(supportedFiles);
498+
void addIfcxOverlays(supportedFiles);
488499
} else if (newFilesAreIfcx && !existingIsIfcx && ifcDataStore) {
489500
// User trying to add IFCX to IFC4 model - won't work
490501
console.warn('[MainToolbar] Cannot add IFCX files to non-IFCX model');
491502
alert(`IFCX overlay files cannot be added to IFC4 models.\n\nPlease load IFCX files separately.`);
492503
} else {
493504
// Standard case - add as independent models (IFC4, GLB, or mixed)
494-
loadFilesSequentially(supportedFiles);
505+
void loadFilesSequentially(supportedFiles, handles);
495506
}
507+
}, [loadFilesSequentially, addIfcxOverlays, ifcDataStore]);
496508

509+
const handleAddModelSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
510+
const files = e.target.files;
511+
if (!files || files.length === 0) return;
512+
// <input> yields no live handle, so models added this way aren't refreshable.
513+
const supportedFiles = Array.from(files).filter(isSupportedModelFile);
514+
addSupportedFiles(supportedFiles);
497515
// Reset input so same files can be selected again
498516
e.target.value = '';
499-
}, [loadFilesSequentially, addIfcxOverlays, ifcDataStore]);
517+
}, [addSupportedFiles]);
518+
519+
// Preferred Add-Model path: the picker captures a handle per file so the
520+
// resulting federation can be refreshed. Falls back to the hidden <input>.
521+
const handleAddModelClick = useCallback(async () => {
522+
if (!supportsFileSystemAccess()) {
523+
addModelInputRef.current?.click();
524+
return;
525+
}
526+
const opened = await openIfcFilesWithHandles();
527+
if (!opened) return;
528+
const supported = opened.filter(o => isSupportedModelFile(o.file));
529+
addSupportedFiles(supported.map(o => o.file), supported.map(o => o.handle));
530+
}, [addSupportedFiles]);
531+
532+
// Open via the File System Access API when available (Chromium) so we capture
533+
// a live FileSystemFileHandle for each file — that handle is what lets the
534+
// Refresh button re-read the same file from disk later (issue #1345). Browsers
535+
// without the API fall back to the hidden <input type="file">.
536+
const handleOpenClick = useCallback(async () => {
537+
if (!supportsFileSystemAccess()) {
538+
fileInputRef.current?.click();
539+
return;
540+
}
541+
const picked = await openIfcFilesWithHandles();
542+
if (!picked) return; // cancelled, unavailable, or picker failed
543+
// The picker keeps an "all files" option, so drop anything unsupported
544+
// before it reaches the load pipeline (matches the <input> + Add Model paths).
545+
const opened = picked.filter(o => isSupportedModelFile(o.file));
546+
if (opened.length === 0) return;
547+
548+
const files = opened.map(o => o.file);
549+
recordRecentFiles(files.map(f => ({ name: f.name, size: f.size })));
550+
void cacheFileBlobs(files);
551+
552+
if (opened.length === 1) {
553+
// Single model: keep the handle so Refresh can re-read it from disk.
554+
void loadFile(opened[0].file, { kind: 'primary' }, { sourceHandle: opened[0].handle });
555+
} else {
556+
// Multiple files mirror handleFileSelect's branching.
557+
const allIfcx = files.every(isIfcxModelFile);
558+
resetViewerState();
559+
clearAllModels();
560+
if (allIfcx) {
561+
// IFCX layers compose into one shared store — no per-file handle.
562+
void loadFederatedIfcx(files);
563+
} else {
564+
// Carry each file's handle so the whole federation stays refreshable.
565+
void loadFilesSequentially(files, opened.map(o => o.handle));
566+
}
567+
}
568+
}, [loadFile, loadFilesSequentially, loadFederatedIfcx, resetViewerState, clearAllModels]);
569+
570+
// Refresh re-reads files from disk and re-parses them. Offered when EVERY
571+
// loaded model has a live FS Access handle (a single model, or a federation
572+
// fully opened via the picker/drag this session). Drag-drop on non-Chromium,
573+
// <input type="file">, cache-restored, and IFCX-composed models have no
574+
// handle, so a mixed session hides the button rather than risk dropping the
575+
// handle-less models during the rebuild.
576+
const canRefresh = useMemo(() => {
577+
if (loading || models.size === 0) return false;
578+
return Array.from(models.values()).every(m => m.sourceHandle);
579+
}, [models, loading]);
580+
581+
const handleRefresh = useCallback(async () => {
582+
const targets = (Array.from(useViewerStore.getState().models.values()) as FederatedModel[])
583+
.filter((m): m is FederatedModel & { sourceHandle: FileSystemFileHandle } => Boolean(m.sourceHandle))
584+
.sort((a, b) => (a.loadedAt ?? 0) - (b.loadedAt ?? 0));
585+
if (targets.length === 0) return;
586+
587+
// Re-read every handle BEFORE clearing anything, so a failed read never
588+
// leaves the viewer empty.
589+
const reads = await Promise.all(
590+
targets.map(async (m) => ({ model: m, fresh: await readFreshFile(m.sourceHandle) })),
591+
);
592+
const ok = reads.filter((r) => r.fresh) as { model: typeof targets[number]; fresh: File }[];
593+
const failedNames = reads.filter((r) => !r.fresh).map((r) => `"${r.model.name}"`);
594+
595+
if (ok.length === 0) {
596+
toast.error(`Couldn't re-read ${failedNames.join(', ')}. Files may have moved, been deleted, or access was denied.`);
597+
return;
598+
}
599+
600+
recordRecentFiles(ok.map((r) => ({ name: r.fresh.name, size: r.fresh.size })));
601+
void cacheFileBlobs(ok.map((r) => r.fresh));
602+
603+
if (targets.length === 1) {
604+
// Await so the success toast only fires once the reload has completed.
605+
await loadFile(ok[0].fresh, { kind: 'primary' }, { sourceHandle: ok[0].model.sourceHandle });
606+
} else {
607+
// Rebuild the federation from fresh bytes, preserving id + order + state.
608+
clearAllModels();
609+
for (const r of ok) {
610+
const reloadedId = await addModel(r.fresh, {
611+
name: r.model.name,
612+
modelId: r.model.id,
613+
loadedAt: r.model.loadedAt,
614+
visible: r.model.visible,
615+
collapsed: r.model.collapsed,
616+
sourceHandle: r.model.sourceHandle,
617+
});
618+
if (reloadedId && r.model.visible === false) {
619+
useViewerStore.getState().setModelVisibility(r.model.id, false);
620+
}
621+
}
622+
}
623+
624+
if (failedNames.length > 0) {
625+
toast.error(`Refreshed ${ok.length}; couldn't re-read ${failedNames.join(', ')}.`);
626+
} else {
627+
toast.success(ok.length === 1 ? `Refreshed "${ok[0].fresh.name}"` : `Refreshed ${ok.length} models`);
628+
}
629+
}, [loadFile, addModel, clearAllModels]);
630+
631+
// The command palette dispatches this (synchronously, inside the click) so the
632+
// toolbar's handle-capturing open path runs while user activation is still
633+
// live — required for the file dialog to actually open on Chrome.
634+
useEffect(() => {
635+
const handler = () => { void handleOpenClick(); };
636+
window.addEventListener('ifc-lite:open-files', handler);
637+
return () => window.removeEventListener('ifc-lite:open-files', handler);
638+
}, [handleOpenClick]);
500639

501640
const hasSelection = selectedEntityId !== null;
502641
// Selection chip uses the multi-select size when present; falls back
@@ -788,7 +927,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
788927
onClick={(e) => {
789928
// Blur button to close tooltip before opening file dialog
790929
(e.currentTarget as HTMLButtonElement).blur();
791-
fileInputRef.current?.click();
930+
void handleOpenClick();
792931
}}
793932
disabled={loading}
794933
>
@@ -802,6 +941,26 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
802941
<TooltipContent>Open IFC File</TooltipContent>
803942
</Tooltip>
804943

944+
{canRefresh && (
945+
<Tooltip>
946+
<TooltipTrigger asChild>
947+
<Button
948+
variant="ghost"
949+
size="icon-sm"
950+
onClick={(e) => {
951+
(e.currentTarget as HTMLButtonElement).blur();
952+
void handleRefresh();
953+
}}
954+
disabled={loading}
955+
aria-label={models.size > 1 ? 'Refresh models from disk' : 'Refresh model from disk'}
956+
>
957+
<RefreshCw className="h-4 w-4" />
958+
</Button>
959+
</TooltipTrigger>
960+
<TooltipContent>{models.size > 1 ? 'Refresh models from disk' : 'Refresh model from disk'}</TooltipContent>
961+
</Tooltip>
962+
)}
963+
805964
{/* Add Model button - only shown when models are loaded */}
806965
{hasModelsLoaded && (
807966
<Tooltip>
@@ -811,7 +970,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
811970
size="icon-sm"
812971
onClick={(e) => {
813972
(e.currentTarget as HTMLButtonElement).blur();
814-
addModelInputRef.current?.click();
973+
void handleAddModelClick();
815974
}}
816975
disabled={loading}
817976
className="text-[#9ece6a] hover:text-[#9ece6a] hover:bg-[#9ece6a]/10"

0 commit comments

Comments
 (0)