import type { FileSystemNode, FileMetadata, IFilesystem } from '../types'; import type { IStorageAdapter } from './IStorageAdapter'; import { PathResolver } from '../utils/PathResolver'; import { IndexedDBAdapter } from '../adapters/IndexedDBAdapter'; /** * In-memory filesystem implementing IFilesystem. * Zero DOM/node dependencies — the portable, edge/browser/node-safe default backend. * Persistence is a no-op here; persistent backends override persist()/load(). */ export class MemFilesystem implements IFilesystem { protected root: FileSystemNode; protected cwd: string = '/'; private changeListeners: Array<() => void> = []; private cwdChangeListeners: Array<(cwd: string) => void> = []; constructor() { this.root = this.createNode('/', 'directory'); } onFilesystemChange(listener: () => void): () => void { this.changeListeners.push(listener); return () => { const index = this.changeListeners.indexOf(listener); if (index > -1) { this.changeListeners.splice(index, 1); } }; } onCwdChange(listener: (cwd: string) => void): () => void { this.cwdChangeListeners.push(listener); return () => { const index = this.cwdChangeListeners.indexOf(listener); if (index > -1) { this.cwdChangeListeners.splice(index, 1); } }; } protected notifyChange(): void { this.changeListeners.forEach(listener => listener()); } protected notifyCwdChange(): void { this.cwdChangeListeners.forEach(listener => listener(this.cwd)); } private createNode(name: string, type: 'file' | 'directory', content: string = ''): FileSystemNode { const now = new Date(); return { name, type, content: type === 'file' ? content : undefined, children: type === 'directory' ? new Map() : undefined, metadata: { created: now, modified: now, size: content.length, permissions: type === 'directory' ? 'drwxr-xr-x' : '-rw-r--r--', isExecutable: false, }, }; } protected async getNode(path: string): Promise { const normalized = PathResolver.normalize(path); if (normalized === '/') { return this.root; } const parts = normalized.split('/').filter(p => p); let current = this.root; for (const part of parts) { if (!current.children) { return null; } const next = current.children.get(part); if (!next) { return null; } current = next; } return current; } protected async getParentNode(path: string): Promise { const dirname = PathResolver.dirname(path); return this.getNode(dirname); } resolvePath(path: string, cwd?: string): string { return PathResolver.resolve(path, cwd || this.cwd); } getCwd(): string { return this.cwd; } setCwd(path: string): void { this.cwd = PathResolver.normalize(path); this.notifyCwdChange(); } async readFile(path: string): Promise { const resolved = this.resolvePath(path); const node = await this.getNode(resolved); if (!node) { throw new Error(`File not found: ${path}`); } if (node.type !== 'file') { throw new Error(`Not a file: ${path}`); } return node.content || ''; } async writeFile(path: string, content: string): Promise { const resolved = this.resolvePath(path); const basename = PathResolver.basename(resolved); const parent = await this.getParentNode(resolved); if (!parent) { throw new Error(`Parent directory does not exist: ${path}`); } if (!parent.children) { throw new Error(`Parent is not a directory: ${path}`); } const existing = parent.children.get(basename); if (existing) { if (existing.type !== 'file') { throw new Error(`Cannot write to directory: ${path}`); } existing.content = content; existing.metadata.modified = new Date(); existing.metadata.size = content.length; } else { const newFile = this.createNode(basename, 'file', content); parent.children.set(basename, newFile); } await this.persist(); this.notifyChange(); } async deleteFile(path: string): Promise { const resolved = this.resolvePath(path); const basename = PathResolver.basename(resolved); const parent = await this.getParentNode(resolved); if (!parent || !parent.children) { throw new Error(`Parent directory does not exist: ${path}`); } const node = parent.children.get(basename); if (!node) { throw new Error(`File not found: ${path}`); } if (node.type === 'directory' && node.children && node.children.size > 0) { throw new Error(`Directory not empty: ${path}`); } parent.children.delete(basename); await this.persist(); this.notifyChange(); } async readDir(path: string): Promise { const resolved = this.resolvePath(path); const node = await this.getNode(resolved); if (!node) { throw new Error(`Directory not found: ${path}`); } if (node.type !== 'directory' || !node.children) { throw new Error(`Not a directory: ${path}`); } return Array.from(node.children.keys()); } async createDir(path: string): Promise { const resolved = this.resolvePath(path); const basename = PathResolver.basename(resolved); const parent = await this.getParentNode(resolved); if (!parent) { throw new Error(`Parent directory does not exist: ${path}`); } if (!parent.children) { throw new Error(`Parent is not a directory: ${path}`); } if (parent.children.has(basename)) { throw new Error(`File or directory already exists: ${path}`); } const newDir = this.createNode(basename, 'directory'); parent.children.set(basename, newDir); await this.persist(); this.notifyChange(); } async exists(path: string): Promise { const resolved = this.resolvePath(path); const node = await this.getNode(resolved); return node !== null; } async stat(path: string): Promise { const resolved = this.resolvePath(path); const node = await this.getNode(resolved); if (!node) { throw new Error(`File not found: ${path}`); } return node.metadata; } async isDirectory(path: string): Promise { const resolved = this.resolvePath(path); const node = await this.getNode(resolved); return node?.type === 'directory'; } async isFile(path: string): Promise { const resolved = this.resolvePath(path); const node = await this.getNode(resolved); return node?.type === 'file'; } async getNodeType(path: string): Promise<'file' | 'directory' | null> { const resolved = this.resolvePath(path); const node = await this.getNode(resolved); return node?.type || null; } /** No-op for the in-memory backend; persistent backends override. */ protected async persist(): Promise {} /** No-op for the in-memory backend; persistent backends override. */ async load(): Promise {} async initializeDefaultStructure(): Promise { // Create default directories const dirs = ['/home', '/bin', '/etc', '/tmp']; for (const dir of dirs) { if (!(await this.exists(dir))) { await this.createDir(dir); } } // Set cwd to /home this.setCwd('/home'); await this.persist(); } protected serializeNode(node: FileSystemNode): any { return { name: node.name, type: node.type, content: node.content, children: node.children ? Array.from(node.children.entries()).map(([key, child]) => [key, this.serializeNode(child)]) : undefined, metadata: { ...node.metadata, created: node.metadata.created.toISOString(), modified: node.metadata.modified.toISOString(), }, }; } protected deserializeNode(data: any): FileSystemNode { return { name: data.name, type: data.type, content: data.content, children: data.children ? new Map(data.children.map(([key, child]: [string, any]) => [key, this.deserializeNode(child)])) : undefined, metadata: { ...data.metadata, created: new Date(data.metadata.created), modified: new Date(data.metadata.modified), }, }; } } /** * Browser-persistent filesystem: in-memory tree mirrored to IndexedDB. * Touches the `indexedDB` global only inside persist()/load() — importing this * module stays edge-safe; it only fails if instantiated+used without IndexedDB. */ export class IndexedDbFilesystem extends MemFilesystem { private dbName = 'wcli-fs'; private storeName = 'filesystem'; private async openDB(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, 1); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; if (!db.objectStoreNames.contains(this.storeName)) { db.createObjectStore(this.storeName); } }; }); } protected async persist(): Promise { if (typeof indexedDB === 'undefined') return; // no-op where IndexedDB is unavailable (node/edge/tests) try { const db = await this.openDB(); const transaction = db.transaction([this.storeName], 'readwrite'); const store = transaction.objectStore(this.storeName); const data = { root: this.serializeNode(this.root), cwd: this.cwd, }; store.put(data, 'filesystem'); return new Promise((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); }); } catch (error) { console.error('Failed to persist filesystem:', error); } } async load(): Promise { if (typeof indexedDB === 'undefined') return; // no-op where IndexedDB is unavailable (node/edge/tests) try { const db = await this.openDB(); const transaction = db.transaction([this.storeName], 'readonly'); const store = transaction.objectStore(this.storeName); const request = store.get('filesystem'); const data = await new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); if (data) { this.root = this.deserializeNode(data.root); this.cwd = data.cwd; } } catch (error) { console.error('Failed to load filesystem:', error); } } } /** * Pluggable-persistence filesystem: delegates durability to an IStorageAdapter * (IndexedDB / localStorage / memory). This is the default the Vue terminal app * constructs (`new VirtualFilesystem(storageAdapter)`); back-compat no-arg * construction falls back to the IndexedDB adapter. */ export class VirtualFilesystem extends MemFilesystem { private storage: IStorageAdapter; constructor(storage?: IStorageAdapter) { super(); this.storage = storage || new IndexedDBAdapter('wcli-fs', 'filesystem'); } async persist(): Promise { try { const data = { root: this.serializeNode(this.root), cwd: this.cwd, }; await this.storage.save('filesystem', data); } catch (error) { console.error('Failed to persist filesystem:', error); } } async load(): Promise { try { const data = await this.storage.load('filesystem'); if (data) { this.root = this.deserializeNode(data.root); this.cwd = data.cwd; } } catch (error) { console.error('Failed to load filesystem:', error); } } }