@@ -51,6 +51,7 @@ import {
5151 Redo2 ,
5252 Boxes ,
5353 Shapes ,
54+ RefreshCw ,
5455} from 'lucide-react' ;
5556import { Button } from '@/components/ui/button' ;
5657import { Switch } from '@/components/ui/switch' ;
@@ -69,7 +70,7 @@ import {
6970 DropdownMenuSubContent ,
7071} from '@/components/ui/dropdown-menu' ;
7172import { Progress } from '@/components/ui/progress' ;
72- import { useViewerStore , isIfcxDataStore } from '@/store' ;
73+ import { useViewerStore , isIfcxDataStore , type FederatedModel } from '@/store' ;
7374import { goHomeFromStore , resetVisibilityForHomeFromStore } from '@/store/homeView' ;
7475import { executeBasketIsolate } from '@/store/basket/basketCommands' ;
7576import { useIfc } from '@/hooks/useIfc' ;
@@ -85,6 +86,11 @@ import { DataConnector } from './DataConnector';
8586import { ExportChangesButton } from './ExportChangesButton' ;
8687import { SearchInline } from './SearchInline' ;
8788import { recordRecentFiles , cacheFileBlobs } from '@/lib/recent-files' ;
89+ import {
90+ supportsFileSystemAccess ,
91+ openIfcFilesWithHandles ,
92+ readFreshFile ,
93+ } from '@/services/file-system-access' ;
8894import { ThemeSwitch } from './ThemeSwitch' ;
8995import { ExtensionToolbarSlot } from '@/components/extensions/ExtensionToolbarSlot' ;
9096import { 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+
285304interface 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