Skip to content

Commit d3a5ef3

Browse files
authored
Merge pull request #290 from pathsim/feature/indexeddb-autosave
Persistent recent-files list & IndexedDB-backed autosave
2 parents d0f166e + 8070509 commit d3a5ef3

3 files changed

Lines changed: 495 additions & 27 deletions

File tree

src/lib/schema/fileOps.ts

Lines changed: 145 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,20 @@ import { downloadJson } from '$lib/utils/download';
3232
import { confirmationStore } from '$lib/stores/confirmation';
3333
import { nodeRegistry } from '$lib/nodes';
3434
import { 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';
3749
const FILE_EXTENSION = '.pvm';
3850
const 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
*/
482520
export 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+
}

src/lib/schema/handleStore.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* IndexedDB-backed storage for autosave snapshots and persisted
3+
* File System Access API handles.
4+
*
5+
* Two object stores:
6+
* - `kv` — simple key/value (used for the autosave blob, single row)
7+
* - `recents` — LRU of file handles with metadata (last 10 entries)
8+
*
9+
* Handles are structured-cloneable; the browser persists them as-is and we
10+
* re-prompt for permission via `handle.requestPermission` when reopening.
11+
*/
12+
13+
const DB_NAME = 'pathview';
14+
const DB_VERSION = 1;
15+
const KV_STORE = 'kv';
16+
const RECENTS_STORE = 'recents';
17+
const RECENTS_LIMIT = 10;
18+
19+
export const AUTOSAVE_KEY = 'autosave';
20+
21+
export interface RecentFile {
22+
id: string;
23+
name: string;
24+
handle: FileSystemFileHandle;
25+
lastOpened: number;
26+
}
27+
28+
let dbPromise: Promise<IDBDatabase> | null = null;
29+
30+
function openDb(): Promise<IDBDatabase> {
31+
if (dbPromise) return dbPromise;
32+
dbPromise = new Promise((resolve, reject) => {
33+
const req = indexedDB.open(DB_NAME, DB_VERSION);
34+
req.onupgradeneeded = () => {
35+
const db = req.result;
36+
if (!db.objectStoreNames.contains(KV_STORE)) {
37+
db.createObjectStore(KV_STORE);
38+
}
39+
if (!db.objectStoreNames.contains(RECENTS_STORE)) {
40+
const store = db.createObjectStore(RECENTS_STORE, { keyPath: 'id' });
41+
store.createIndex('lastOpened', 'lastOpened');
42+
}
43+
};
44+
req.onsuccess = () => resolve(req.result);
45+
req.onerror = () => reject(req.error);
46+
req.onblocked = () => reject(new Error('IndexedDB open blocked'));
47+
});
48+
return dbPromise;
49+
}
50+
51+
function tx<T>(
52+
storeName: string,
53+
mode: IDBTransactionMode,
54+
run: (store: IDBObjectStore) => IDBRequest<T> | Promise<T>
55+
): Promise<T> {
56+
return openDb().then(
57+
(db) =>
58+
new Promise<T>((resolve, reject) => {
59+
const transaction = db.transaction(storeName, mode);
60+
const store = transaction.objectStore(storeName);
61+
let result: T;
62+
Promise.resolve(run(store)).then((r) => {
63+
if (r && typeof (r as IDBRequest).addEventListener === 'function') {
64+
const req = r as IDBRequest<T>;
65+
req.onsuccess = () => {
66+
result = req.result;
67+
};
68+
req.onerror = () => reject(req.error);
69+
} else {
70+
result = r as T;
71+
}
72+
});
73+
transaction.oncomplete = () => resolve(result);
74+
transaction.onerror = () => reject(transaction.error);
75+
transaction.onabort = () => reject(transaction.error);
76+
})
77+
);
78+
}
79+
80+
// ─── KV ────────────────────────────────────────────────────────────────────
81+
82+
export async function kvGet<T = unknown>(key: string): Promise<T | undefined> {
83+
return tx<T | undefined>(KV_STORE, 'readonly', (store) => store.get(key));
84+
}
85+
86+
export async function kvSet(key: string, value: unknown): Promise<void> {
87+
await tx(KV_STORE, 'readwrite', (store) => store.put(value, key));
88+
}
89+
90+
export async function kvDelete(key: string): Promise<void> {
91+
await tx(KV_STORE, 'readwrite', (store) => store.delete(key));
92+
}
93+
94+
export async function kvHas(key: string): Promise<boolean> {
95+
const v = await tx<IDBValidKey | undefined>(KV_STORE, 'readonly', (store) => store.getKey(key));
96+
return v !== undefined;
97+
}
98+
99+
// ─── Recent files ──────────────────────────────────────────────────────────
100+
101+
export async function recentsList(): Promise<RecentFile[]> {
102+
const all = await tx<RecentFile[]>(RECENTS_STORE, 'readonly', (store) => store.getAll());
103+
return all.sort((a, b) => b.lastOpened - a.lastOpened);
104+
}
105+
106+
export async function recentsAdd(entry: Omit<RecentFile, 'lastOpened'>): Promise<void> {
107+
const now = Date.now();
108+
await tx(RECENTS_STORE, 'readwrite', (store) => store.put({ ...entry, lastOpened: now }));
109+
// Trim to LRU_LIMIT — keep newest, evict the rest
110+
const all = await recentsList();
111+
if (all.length > RECENTS_LIMIT) {
112+
const toEvict = all.slice(RECENTS_LIMIT);
113+
await tx(RECENTS_STORE, 'readwrite', (store) => {
114+
toEvict.forEach((e) => store.delete(e.id));
115+
return store.count();
116+
});
117+
}
118+
}
119+
120+
export async function recentsRemove(id: string): Promise<void> {
121+
await tx(RECENTS_STORE, 'readwrite', (store) => store.delete(id));
122+
}
123+
124+
export async function recentsClear(): Promise<void> {
125+
await tx(RECENTS_STORE, 'readwrite', (store) => store.clear());
126+
}
127+
128+
/**
129+
* Stable id for a handle. Same file (by name + kind) collapses into one
130+
* recents row instead of accumulating duplicates across sessions.
131+
*/
132+
export function recentIdFor(handle: FileSystemFileHandle): string {
133+
return `${handle.kind}:${handle.name}`;
134+
}
135+
136+
export function hasFileSystemAccess(): boolean {
137+
return typeof window !== 'undefined' && 'showOpenFilePicker' in window;
138+
}

0 commit comments

Comments
 (0)