Skip to content
166 changes: 145 additions & 21 deletions src/lib/schema/fileOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<void> {
try {
const file = createGraphFile('Autosave');
localStorage.setItem(STORAGE_KEY, JSON.stringify(file));
await kvSet(AUTOSAVE_KEY, file);
} catch (error) {
console.warn('Autosave failed:', error);
}
Expand All @@ -337,48 +350,71 @@ 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<boolean> {
async function migrateLegacyAutosave(): Promise<GraphFile | null> {
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<boolean> {
try {
let file = await kvGet<GraphFile>(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;
}

await loadGraphFile(file);
return true;
} catch (error) {
console.warn('Failed to restore autosave, clearing:', error);
clearAutoSave();
await clearAutoSave();
return false;
}
}

/**
* Clear autosave
*/
export function clearAutoSave(): void {
localStorage.removeItem(STORAGE_KEY);
export async function clearAutoSave(): Promise<void> {
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<boolean> {
if (await kvHas(AUTOSAVE_KEY)) return true;
const migrated = await migrateLegacyAutosave();
return migrated !== null;
}

/**
Expand All @@ -393,6 +429,7 @@ export async function saveFile(): Promise<boolean> {
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
Expand Down Expand Up @@ -431,6 +468,7 @@ export async function saveAsFile(): Promise<boolean> {
// Update current file reference
currentFileHandle = handle;
currentFileNameStore.set(name);
void rememberRecent(handle);
return true;
} catch (error: any) {
if (error.name === 'AbortError') {
Expand Down Expand Up @@ -471,7 +509,7 @@ export function newGraph(): void {
consoleStore.clear();
settingsStore.reset();
historyStore.clear();
clearAutoSave();
void clearAutoSave();
clearCurrentFile();
}

Expand All @@ -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);
}

Expand Down Expand Up @@ -677,6 +717,7 @@ async function importModel(
componentFile.metadata.name ||
null
);
if (currentFileHandle) void rememberRecent(currentFileHandle);

return { success: true, type: 'model' };
}
Expand Down Expand Up @@ -830,3 +871,86 @@ export async function openImportDialog(
input.click();
});
}

// =============================================================================
// RECENT FILES (FileSystemFileHandle LRU in IndexedDB)
// =============================================================================

async function rememberRecent(handle: FileSystemFileHandle): Promise<void> {
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<RecentFile[]> {
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<ImportResult> {
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<PermissionState>;
requestPermission?: (d: { mode: 'readwrite' }) => Promise<PermissionState>;
};
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<void> {
await recentsRemove(id);
}
138 changes: 138 additions & 0 deletions src/lib/schema/handleStore.ts
Original file line number Diff line number Diff line change
@@ -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<IDBDatabase> | null = null;

function openDb(): Promise<IDBDatabase> {
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<T>(
storeName: string,
mode: IDBTransactionMode,
run: (store: IDBObjectStore) => IDBRequest<T> | Promise<T>
): Promise<T> {
return openDb().then(
(db) =>
new Promise<T>((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<T>;
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<T = unknown>(key: string): Promise<T | undefined> {
return tx<T | undefined>(KV_STORE, 'readonly', (store) => store.get(key));
}

export async function kvSet(key: string, value: unknown): Promise<void> {
await tx(KV_STORE, 'readwrite', (store) => store.put(value, key));
}

export async function kvDelete(key: string): Promise<void> {
await tx(KV_STORE, 'readwrite', (store) => store.delete(key));
}

export async function kvHas(key: string): Promise<boolean> {
const v = await tx<IDBValidKey | undefined>(KV_STORE, 'readonly', (store) => store.getKey(key));
return v !== undefined;
}

// ─── Recent files ──────────────────────────────────────────────────────────

export async function recentsList(): Promise<RecentFile[]> {
const all = await tx<RecentFile[]>(RECENTS_STORE, 'readonly', (store) => store.getAll());
return all.sort((a, b) => b.lastOpened - a.lastOpened);
}

export async function recentsAdd(entry: Omit<RecentFile, 'lastOpened'>): Promise<void> {
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<void> {
await tx(RECENTS_STORE, 'readwrite', (store) => store.delete(id));
}

export async function recentsClear(): Promise<void> {
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;
}
Loading
Loading