@@ -32,8 +32,20 @@ import { downloadJson } from '$lib/utils/download';
3232import { confirmationStore } from '$lib/stores/confirmation' ;
3333import { nodeRegistry } from '$lib/nodes' ;
3434import { NODE_TYPES } from '$lib/constants/nodeTypes' ;
35-
36- const STORAGE_KEY = 'pathview_autosave' ;
35+ import {
36+ AUTOSAVE_KEY ,
37+ kvDelete ,
38+ kvGet ,
39+ kvHas ,
40+ kvSet ,
41+ recentIdFor ,
42+ recentsAdd ,
43+ recentsList ,
44+ recentsRemove ,
45+ type RecentFile
46+ } from './handleStore' ;
47+
48+ const LEGACY_STORAGE_KEY = 'pathview_autosave' ;
3749const FILE_EXTENSION = '.pvm' ;
3850const LEGACY_EXTENSION = '.json' ;
3951
@@ -318,12 +330,13 @@ export async function loadGraphFile(
318330}
319331
320332/**
321- * Save to localStorage (autosave)
333+ * Save autosave snapshot to IndexedDB. Async because IDB is async; callers
334+ * fire-and-forget unless they need to chain off completion.
322335 */
323- export function autoSave ( ) : void {
336+ export async function autoSave ( ) : Promise < void > {
324337 try {
325338 const file = createGraphFile ( 'Autosave' ) ;
326- localStorage . setItem ( STORAGE_KEY , JSON . stringify ( file ) ) ;
339+ await kvSet ( AUTOSAVE_KEY , file ) ;
327340 } catch ( error ) {
328341 console . warn ( 'Autosave failed:' , error ) ;
329342 }
@@ -337,48 +350,71 @@ export function debouncedAutoSave(delayMs: number = 500): void {
337350 clearTimeout ( autosaveDebounceTimer ) ;
338351 }
339352 autosaveDebounceTimer = setTimeout ( ( ) => {
340- autoSave ( ) ;
353+ void autoSave ( ) ;
341354 autosaveDebounceTimer = null ;
342355 } , delayMs ) ;
343356}
344357
345358/**
346- * Load from localStorage (restore autosave)
359+ * One-shot migration from the old `localStorage` autosave (key
360+ * `pathview_autosave`) to IDB. Runs lazily on the first IDB read/check.
347361 */
348- export async function loadAutoSave ( ) : Promise < boolean > {
362+ async function migrateLegacyAutosave ( ) : Promise < GraphFile | null > {
349363 try {
350- const data = localStorage . getItem ( STORAGE_KEY ) ;
351- if ( ! data ) return false ;
364+ const raw = localStorage . getItem ( LEGACY_STORAGE_KEY ) ;
365+ if ( ! raw ) return null ;
366+ const parsed = JSON . parse ( raw ) as GraphFile ;
367+ if ( parsed ?. version && parsed ?. graph ) {
368+ await kvSet ( AUTOSAVE_KEY , parsed ) ;
369+ localStorage . removeItem ( LEGACY_STORAGE_KEY ) ;
370+ return parsed ;
371+ }
372+ localStorage . removeItem ( LEGACY_STORAGE_KEY ) ;
373+ return null ;
374+ } catch {
375+ localStorage . removeItem ( LEGACY_STORAGE_KEY ) ;
376+ return null ;
377+ }
378+ }
352379
353- const file = JSON . parse ( data ) as GraphFile ;
380+ /**
381+ * Load autosave snapshot from IDB (with one-time localStorage migration)
382+ */
383+ export async function loadAutoSave ( ) : Promise < boolean > {
384+ try {
385+ let file = await kvGet < GraphFile > ( AUTOSAVE_KEY ) ;
386+ if ( ! file ) file = ( await migrateLegacyAutosave ( ) ) ?? undefined ;
387+ if ( ! file ) return false ;
354388
355- // Validate the file has proper structure
356389 if ( ! file . version || ! file . graph ) {
357- clearAutoSave ( ) ;
390+ await clearAutoSave ( ) ;
358391 return false ;
359392 }
360393
361394 await loadGraphFile ( file ) ;
362395 return true ;
363396 } catch ( error ) {
364397 console . warn ( 'Failed to restore autosave, clearing:' , error ) ;
365- clearAutoSave ( ) ;
398+ await clearAutoSave ( ) ;
366399 return false ;
367400 }
368401}
369402
370403/**
371404 * Clear autosave
372405 */
373- export function clearAutoSave ( ) : void {
374- localStorage . removeItem ( STORAGE_KEY ) ;
406+ export async function clearAutoSave ( ) : Promise < void > {
407+ await kvDelete ( AUTOSAVE_KEY ) ;
408+ localStorage . removeItem ( LEGACY_STORAGE_KEY ) ;
375409}
376410
377411/**
378- * Check if autosave exists
412+ * Check if autosave exists (migrates legacy localStorage entry on the way)
379413 */
380- export function hasAutoSave ( ) : boolean {
381- return localStorage . getItem ( STORAGE_KEY ) !== null ;
414+ export async function hasAutoSave ( ) : Promise < boolean > {
415+ if ( await kvHas ( AUTOSAVE_KEY ) ) return true ;
416+ const migrated = await migrateLegacyAutosave ( ) ;
417+ return migrated !== null ;
382418}
383419
384420/**
@@ -393,6 +429,7 @@ export async function saveFile(): Promise<boolean> {
393429 const writable = await currentFileHandle . createWritable ( ) ;
394430 await writable . write ( json ) ;
395431 await writable . close ( ) ;
432+ void rememberRecent ( currentFileHandle ) ;
396433 return true ;
397434 } catch ( error ) {
398435 // User may have revoked permission, fall through to Save As
@@ -431,6 +468,7 @@ export async function saveAsFile(): Promise<boolean> {
431468 // Update current file reference
432469 currentFileHandle = handle ;
433470 currentFileNameStore . set ( name ) ;
471+ void rememberRecent ( handle ) ;
434472 return true ;
435473 } catch ( error : any ) {
436474 if ( error . name === 'AbortError' ) {
@@ -471,7 +509,7 @@ export function newGraph(): void {
471509 consoleStore . clear ( ) ;
472510 settingsStore . reset ( ) ;
473511 historyStore . clear ( ) ;
474- clearAutoSave ( ) ;
512+ void clearAutoSave ( ) ;
475513 clearCurrentFile ( ) ;
476514}
477515
@@ -480,7 +518,9 @@ export function newGraph(): void {
480518 * Returns cleanup function
481519 */
482520export function setupAutoSave ( intervalMs : number = 30000 ) : ( ) => void {
483- const timer = setInterval ( autoSave , intervalMs ) ;
521+ const timer = setInterval ( ( ) => {
522+ void autoSave ( ) ;
523+ } , intervalMs ) ;
484524 return ( ) => clearInterval ( timer ) ;
485525}
486526
@@ -677,6 +717,7 @@ async function importModel(
677717 componentFile . metadata . name ||
678718 null
679719 ) ;
720+ if ( currentFileHandle ) void rememberRecent ( currentFileHandle ) ;
680721
681722 return { success : true , type : 'model' } ;
682723}
@@ -830,3 +871,86 @@ export async function openImportDialog(
830871 input . click ( ) ;
831872 } ) ;
832873}
874+
875+ // =============================================================================
876+ // RECENT FILES (FileSystemFileHandle LRU in IndexedDB)
877+ // =============================================================================
878+
879+ async function rememberRecent ( handle : FileSystemFileHandle ) : Promise < void > {
880+ try {
881+ await recentsAdd ( { id : recentIdFor ( handle ) , name : handle . name , handle } ) ;
882+ } catch ( e ) {
883+ console . warn ( 'Failed to remember recent file:' , e ) ;
884+ }
885+ }
886+
887+ /**
888+ * List recently opened/saved files (most recent first). Only meaningful on
889+ * browsers with the File System Access API — others return an empty list.
890+ */
891+ export async function listRecentFiles ( ) : Promise < RecentFile [ ] > {
892+ if ( ! hasFileSystemAccess ( ) ) return [ ] ;
893+ try {
894+ return await recentsList ( ) ;
895+ } catch ( e ) {
896+ console . warn ( 'Failed to list recent files:' , e ) ;
897+ return [ ] ;
898+ }
899+ }
900+
901+ /**
902+ * Open a recently-used file by its recents id. Triggers the permission
903+ * re-prompt the first time per session, then loads the file in place (same
904+ * code path as `openImportDialog`'s success branch). Stale entries (file
905+ * moved/deleted, permission denied) are evicted from the recents list.
906+ */
907+ export async function openRecentFile ( id : string ) : Promise < ImportResult > {
908+ if ( ! hasFileSystemAccess ( ) ) {
909+ return { success : false , type : 'model' , error : 'File System Access API not available' } ;
910+ }
911+ let entry : RecentFile | undefined ;
912+ try {
913+ const all = await recentsList ( ) ;
914+ entry = all . find ( ( r ) => r . id === id ) ;
915+ } catch ( e ) {
916+ return {
917+ success : false ,
918+ type : 'model' ,
919+ error : e instanceof Error ? e . message : 'Failed to read recent files'
920+ } ;
921+ }
922+ if ( ! entry ) {
923+ return { success : false , type : 'model' , error : 'Recent file no longer tracked' } ;
924+ }
925+
926+ try {
927+ const handle = entry . handle as FileSystemFileHandle & {
928+ queryPermission ?: ( d : { mode : 'readwrite' } ) => Promise < PermissionState > ;
929+ requestPermission ?: ( d : { mode : 'readwrite' } ) => Promise < PermissionState > ;
930+ } ;
931+ if ( handle . queryPermission && handle . requestPermission ) {
932+ let perm = await handle . queryPermission ( { mode : 'readwrite' } ) ;
933+ if ( perm !== 'granted' ) {
934+ perm = await handle . requestPermission ( { mode : 'readwrite' } ) ;
935+ }
936+ if ( perm !== 'granted' ) {
937+ return { success : false , type : 'model' , cancelled : true } ;
938+ }
939+ }
940+ const file = await handle . getFile ( ) ;
941+ return importFile ( file , { fileHandle : handle , fileName : handle . name } ) ;
942+ } catch ( e : any ) {
943+ // File was moved, deleted, or the user revoked permission — evict it
944+ await recentsRemove ( id ) . catch ( ( ) => undefined ) ;
945+ return {
946+ success : false ,
947+ type : 'model' ,
948+ error : e instanceof Error ? e . message : 'Failed to open recent file'
949+ } ;
950+ }
951+ }
952+
953+ /** Remove a single recent-files entry (e.g., user clicks an "x" in the menu). */
954+ export async function removeRecentFile ( id : string ) : Promise < void > {
955+ await recentsRemove ( id ) ;
956+ }
0 commit comments