diff --git a/src/lib/schema/fileOps.ts b/src/lib/schema/fileOps.ts index 83232d5c..efdc1bc9 100644 --- a/src/lib/schema/fileOps.ts +++ b/src/lib/schema/fileOps.ts @@ -32,8 +32,20 @@ import { downloadJson } from '$lib/utils/download'; import { confirmationStore } from '$lib/stores/confirmation'; import { nodeRegistry } from '$lib/nodes'; import { NODE_TYPES } from '$lib/constants/nodeTypes'; - -const STORAGE_KEY = 'pathview_autosave'; +import { + AUTOSAVE_KEY, + kvDelete, + kvGet, + kvHas, + kvSet, + recentIdFor, + recentsAdd, + recentsList, + recentsRemove, + type RecentFile +} from './handleStore'; + +const LEGACY_STORAGE_KEY = 'pathview_autosave'; const FILE_EXTENSION = '.pvm'; const LEGACY_EXTENSION = '.json'; @@ -318,12 +330,13 @@ export async function loadGraphFile( } /** - * Save to localStorage (autosave) + * Save autosave snapshot to IndexedDB. Async because IDB is async; callers + * fire-and-forget unless they need to chain off completion. */ -export function autoSave(): void { +export async function autoSave(): Promise { try { const file = createGraphFile('Autosave'); - localStorage.setItem(STORAGE_KEY, JSON.stringify(file)); + await kvSet(AUTOSAVE_KEY, file); } catch (error) { console.warn('Autosave failed:', error); } @@ -337,24 +350,44 @@ export function debouncedAutoSave(delayMs: number = 500): void { clearTimeout(autosaveDebounceTimer); } autosaveDebounceTimer = setTimeout(() => { - autoSave(); + void autoSave(); autosaveDebounceTimer = null; }, delayMs); } /** - * Load from localStorage (restore autosave) + * One-shot migration from the old `localStorage` autosave (key + * `pathview_autosave`) to IDB. Runs lazily on the first IDB read/check. */ -export async function loadAutoSave(): Promise { +async function migrateLegacyAutosave(): Promise { try { - const data = localStorage.getItem(STORAGE_KEY); - if (!data) return false; + const raw = localStorage.getItem(LEGACY_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as GraphFile; + if (parsed?.version && parsed?.graph) { + await kvSet(AUTOSAVE_KEY, parsed); + localStorage.removeItem(LEGACY_STORAGE_KEY); + return parsed; + } + localStorage.removeItem(LEGACY_STORAGE_KEY); + return null; + } catch { + localStorage.removeItem(LEGACY_STORAGE_KEY); + return null; + } +} - const file = JSON.parse(data) as GraphFile; +/** + * Load autosave snapshot from IDB (with one-time localStorage migration) + */ +export async function loadAutoSave(): Promise { + try { + let file = await kvGet(AUTOSAVE_KEY); + if (!file) file = (await migrateLegacyAutosave()) ?? undefined; + if (!file) return false; - // Validate the file has proper structure if (!file.version || !file.graph) { - clearAutoSave(); + await clearAutoSave(); return false; } @@ -362,7 +395,7 @@ export async function loadAutoSave(): Promise { return true; } catch (error) { console.warn('Failed to restore autosave, clearing:', error); - clearAutoSave(); + await clearAutoSave(); return false; } } @@ -370,15 +403,18 @@ export async function loadAutoSave(): Promise { /** * Clear autosave */ -export function clearAutoSave(): void { - localStorage.removeItem(STORAGE_KEY); +export async function clearAutoSave(): Promise { + await kvDelete(AUTOSAVE_KEY); + localStorage.removeItem(LEGACY_STORAGE_KEY); } /** - * Check if autosave exists + * Check if autosave exists (migrates legacy localStorage entry on the way) */ -export function hasAutoSave(): boolean { - return localStorage.getItem(STORAGE_KEY) !== null; +export async function hasAutoSave(): Promise { + if (await kvHas(AUTOSAVE_KEY)) return true; + const migrated = await migrateLegacyAutosave(); + return migrated !== null; } /** @@ -393,6 +429,7 @@ export async function saveFile(): Promise { const writable = await currentFileHandle.createWritable(); await writable.write(json); await writable.close(); + void rememberRecent(currentFileHandle); return true; } catch (error) { // User may have revoked permission, fall through to Save As @@ -431,6 +468,7 @@ export async function saveAsFile(): Promise { // Update current file reference currentFileHandle = handle; currentFileNameStore.set(name); + void rememberRecent(handle); return true; } catch (error: any) { if (error.name === 'AbortError') { @@ -471,7 +509,7 @@ export function newGraph(): void { consoleStore.clear(); settingsStore.reset(); historyStore.clear(); - clearAutoSave(); + void clearAutoSave(); clearCurrentFile(); } @@ -480,7 +518,9 @@ export function newGraph(): void { * Returns cleanup function */ export function setupAutoSave(intervalMs: number = 30000): () => void { - const timer = setInterval(autoSave, intervalMs); + const timer = setInterval(() => { + void autoSave(); + }, intervalMs); return () => clearInterval(timer); } @@ -677,6 +717,7 @@ async function importModel( componentFile.metadata.name || null ); + if (currentFileHandle) void rememberRecent(currentFileHandle); return { success: true, type: 'model' }; } @@ -830,3 +871,86 @@ export async function openImportDialog( input.click(); }); } + +// ============================================================================= +// RECENT FILES (FileSystemFileHandle LRU in IndexedDB) +// ============================================================================= + +async function rememberRecent(handle: FileSystemFileHandle): Promise { + try { + await recentsAdd({ id: recentIdFor(handle), name: handle.name, handle }); + } catch (e) { + console.warn('Failed to remember recent file:', e); + } +} + +/** + * List recently opened/saved files (most recent first). Only meaningful on + * browsers with the File System Access API — others return an empty list. + */ +export async function listRecentFiles(): Promise { + if (!hasFileSystemAccess()) return []; + try { + return await recentsList(); + } catch (e) { + console.warn('Failed to list recent files:', e); + return []; + } +} + +/** + * Open a recently-used file by its recents id. Triggers the permission + * re-prompt the first time per session, then loads the file in place (same + * code path as `openImportDialog`'s success branch). Stale entries (file + * moved/deleted, permission denied) are evicted from the recents list. + */ +export async function openRecentFile(id: string): Promise { + if (!hasFileSystemAccess()) { + return { success: false, type: 'model', error: 'File System Access API not available' }; + } + let entry: RecentFile | undefined; + try { + const all = await recentsList(); + entry = all.find((r) => r.id === id); + } catch (e) { + return { + success: false, + type: 'model', + error: e instanceof Error ? e.message : 'Failed to read recent files' + }; + } + if (!entry) { + return { success: false, type: 'model', error: 'Recent file no longer tracked' }; + } + + try { + const handle = entry.handle as FileSystemFileHandle & { + queryPermission?: (d: { mode: 'readwrite' }) => Promise; + requestPermission?: (d: { mode: 'readwrite' }) => Promise; + }; + if (handle.queryPermission && handle.requestPermission) { + let perm = await handle.queryPermission({ mode: 'readwrite' }); + if (perm !== 'granted') { + perm = await handle.requestPermission({ mode: 'readwrite' }); + } + if (perm !== 'granted') { + return { success: false, type: 'model', cancelled: true }; + } + } + const file = await handle.getFile(); + return importFile(file, { fileHandle: handle, fileName: handle.name }); + } catch (e: any) { + // File was moved, deleted, or the user revoked permission — evict it + await recentsRemove(id).catch(() => undefined); + return { + success: false, + type: 'model', + error: e instanceof Error ? e.message : 'Failed to open recent file' + }; + } +} + +/** Remove a single recent-files entry (e.g., user clicks an "x" in the menu). */ +export async function removeRecentFile(id: string): Promise { + await recentsRemove(id); +} diff --git a/src/lib/schema/handleStore.ts b/src/lib/schema/handleStore.ts new file mode 100644 index 00000000..e6114225 --- /dev/null +++ b/src/lib/schema/handleStore.ts @@ -0,0 +1,138 @@ +/** + * IndexedDB-backed storage for autosave snapshots and persisted + * File System Access API handles. + * + * Two object stores: + * - `kv` — simple key/value (used for the autosave blob, single row) + * - `recents` — LRU of file handles with metadata (last 10 entries) + * + * Handles are structured-cloneable; the browser persists them as-is and we + * re-prompt for permission via `handle.requestPermission` when reopening. + */ + +const DB_NAME = 'pathview'; +const DB_VERSION = 1; +const KV_STORE = 'kv'; +const RECENTS_STORE = 'recents'; +const RECENTS_LIMIT = 10; + +export const AUTOSAVE_KEY = 'autosave'; + +export interface RecentFile { + id: string; + name: string; + handle: FileSystemFileHandle; + lastOpened: number; +} + +let dbPromise: Promise | null = null; + +function openDb(): Promise { + if (dbPromise) return dbPromise; + dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(KV_STORE)) { + db.createObjectStore(KV_STORE); + } + if (!db.objectStoreNames.contains(RECENTS_STORE)) { + const store = db.createObjectStore(RECENTS_STORE, { keyPath: 'id' }); + store.createIndex('lastOpened', 'lastOpened'); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + req.onblocked = () => reject(new Error('IndexedDB open blocked')); + }); + return dbPromise; +} + +function tx( + storeName: string, + mode: IDBTransactionMode, + run: (store: IDBObjectStore) => IDBRequest | Promise +): Promise { + return openDb().then( + (db) => + new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, mode); + const store = transaction.objectStore(storeName); + let result: T; + Promise.resolve(run(store)).then((r) => { + if (r && typeof (r as IDBRequest).addEventListener === 'function') { + const req = r as IDBRequest; + req.onsuccess = () => { + result = req.result; + }; + req.onerror = () => reject(req.error); + } else { + result = r as T; + } + }); + transaction.oncomplete = () => resolve(result); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }) + ); +} + +// ─── KV ──────────────────────────────────────────────────────────────────── + +export async function kvGet(key: string): Promise { + return tx(KV_STORE, 'readonly', (store) => store.get(key)); +} + +export async function kvSet(key: string, value: unknown): Promise { + await tx(KV_STORE, 'readwrite', (store) => store.put(value, key)); +} + +export async function kvDelete(key: string): Promise { + await tx(KV_STORE, 'readwrite', (store) => store.delete(key)); +} + +export async function kvHas(key: string): Promise { + const v = await tx(KV_STORE, 'readonly', (store) => store.getKey(key)); + return v !== undefined; +} + +// ─── Recent files ────────────────────────────────────────────────────────── + +export async function recentsList(): Promise { + const all = await tx(RECENTS_STORE, 'readonly', (store) => store.getAll()); + return all.sort((a, b) => b.lastOpened - a.lastOpened); +} + +export async function recentsAdd(entry: Omit): Promise { + const now = Date.now(); + await tx(RECENTS_STORE, 'readwrite', (store) => store.put({ ...entry, lastOpened: now })); + // Trim to LRU_LIMIT — keep newest, evict the rest + const all = await recentsList(); + if (all.length > RECENTS_LIMIT) { + const toEvict = all.slice(RECENTS_LIMIT); + await tx(RECENTS_STORE, 'readwrite', (store) => { + toEvict.forEach((e) => store.delete(e.id)); + return store.count(); + }); + } +} + +export async function recentsRemove(id: string): Promise { + await tx(RECENTS_STORE, 'readwrite', (store) => store.delete(id)); +} + +export async function recentsClear(): Promise { + await tx(RECENTS_STORE, 'readwrite', (store) => store.clear()); +} + +/** + * Stable id for a handle. Same file (by name + kind) collapses into one + * recents row instead of accumulating duplicates across sessions. + */ +export function recentIdFor(handle: FileSystemFileHandle): string { + return `${handle.kind}:${handle.name}`; +} + +export function hasFileSystemAccess(): boolean { + return typeof window !== 'undefined' && 'showOpenFilePicker' in window; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 0b89cb65..5d306040 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -49,7 +49,9 @@ import { initBackendFromUrl, autoDetectBackend } from '$lib/pyodide/backend'; import { runGraphStreamingSimulation, validateGraphSimulation, exportToPython } from '$lib/pyodide/pathsimRunner'; import { consoleStore } from '$lib/stores/console'; - import { newGraph, saveFile, saveAsFile, setupAutoSave, clearAutoSave, debouncedAutoSave, openImportDialog, importFromUrl, currentFileName } from '$lib/schema/fileOps'; + import { newGraph, saveFile, saveAsFile, setupAutoSave, clearAutoSave, debouncedAutoSave, openImportDialog, importFromUrl, currentFileName, loadGraphFile, listRecentFiles, openRecentFile, removeRecentFile } from '$lib/schema/fileOps'; + import { AUTOSAVE_KEY, kvGet, hasFileSystemAccess, type RecentFile } from '$lib/schema/handleStore'; + import type { GraphFile } from '$lib/nodes/types'; import { confirmationStore } from '$lib/stores/confirmation'; import ConfirmationModal from '$lib/components/ConfirmationModal.svelte'; import { triggerFitView, triggerZoomIn, triggerZoomOut, triggerPan, getViewportCenter, screenToFlow, triggerClearSelection, triggerNudge, hasAnySelection, setFitViewPadding, triggerFlyInAnimation } from '$lib/stores/viewActions'; @@ -647,8 +649,12 @@ consoleLogCount = logs.length; }); - // Always start with clean slate - clearAutoSave(); + // Snapshot the previous session's autosave (if any) *before* setting up + // subscriptions, so the upcoming subscribe-fire / debounced writes + // cannot race-overwrite it in IDB while the user is still answering + // the Restore prompt. We keep the snapshot in memory and reload from + // there on confirm. + const initialAutosavePromise = kvGet(AUTOSAVE_KEY); // Setup periodic autosave (backup) const cleanupAutoSave = setupAutoSave(30000); @@ -671,6 +677,30 @@ window.addEventListener('run-simulation', handleRunSimulation); window.addEventListener('continue-simulation', handleContinueSimulation); + // Offer to restore the previous session's autosave (skip if a URL + // model is loading — that takes precedence over restore). + void (async () => { + const snapshot = await initialAutosavePromise; + if (!snapshot || urlModelConfig) return; + const ok = await confirmationStore.show({ + title: 'Restore last session?', + message: 'PathView found an autosaved version of your last session. Restore it?', + confirmText: 'Restore', + cancelText: 'Discard' + }); + if (ok) { + try { + await loadGraphFile(snapshot); + setTimeout(() => triggerFitView(), 100); + } catch (e) { + console.warn('Failed to restore autosave:', e); + await clearAutoSave(); + } + } else { + await clearAutoSave(); + } + })(); + return () => { // Cleanup store subscriptions unsubPinnedPreviews(); @@ -1045,6 +1075,64 @@ } } + // ── Recent files hover menu ────────────────────────────────────────────── + const recentFilesSupported = hasFileSystemAccess(); + let recentFiles = $state([]); + let recentFilesMenuOpen = $state(false); + let recentOpenTimer: ReturnType | null = null; + let recentCloseTimer: ReturnType | null = null; + const RECENT_HOVER_OPEN_MS = 250; + const RECENT_HOVER_CLOSE_MS = 180; + + function clearRecentTimers() { + if (recentOpenTimer) { + clearTimeout(recentOpenTimer); + recentOpenTimer = null; + } + if (recentCloseTimer) { + clearTimeout(recentCloseTimer); + recentCloseTimer = null; + } + } + + function handleOpenGroupEnter() { + if (!recentFilesSupported) return; + clearRecentTimers(); + recentOpenTimer = setTimeout(async () => { + recentOpenTimer = null; + const list = await listRecentFiles(); + if (list.length === 0) return; // no menu when empty + recentFiles = list; + recentFilesMenuOpen = true; + }, RECENT_HOVER_OPEN_MS); + } + + function handleOpenGroupLeave() { + clearRecentTimers(); + recentCloseTimer = setTimeout(() => { + recentFilesMenuOpen = false; + recentCloseTimer = null; + }, RECENT_HOVER_CLOSE_MS); + } + + async function handleOpenRecent(id: string) { + clearRecentTimers(); + recentFilesMenuOpen = false; + const result = await openRecentFile(id); + if (result.success && result.type === 'model') { + setTimeout(() => triggerFitView(), 100); + } else if (result.error) { + consoleStore.error(`[open recent] ${result.error}`); + } + } + + async function handleRemoveRecent(id: string, e: MouseEvent) { + e.stopPropagation(); + await removeRecentFile(id); + recentFiles = await listRecentFiles(); + if (recentFiles.length === 0) recentFilesMenuOpen = false; + } + /** * Expand GitHub shorthand to raw.githubusercontent.com URL * Format: owner/repo/path/to/file.pvm @@ -1214,9 +1302,35 @@ - + +
+ + {#if recentFilesSupported && recentFilesMenuOpen} + + {/if} +