class VirtualFileSystem { constructor() { this.root = { type: 'dir', content: {} }; this.currentPath = []; this.loadFromStorage(); } loadFromStorage() { try { const saved = localStorage.getItem('webx11_filesystem'); if (saved) { // Safety: refuse to load if data is excessively large (>512KB raw) if (saved.length > 524288) { console.warn('FS data too large (' + (saved.length/1024).toFixed(0) + 'KB), skipping load'); localStorage.removeItem('webx11_filesystem'); return; } const data = JSON.parse(saved); this.root = data.root || this.root; this.currentPath = data.currentPath || []; } } catch (e) { console.error('FS load error:', e); } } saveToStorage() { if (this._storageSavePending) return; this._storageSavePending = true; setTimeout(() => { this._storageSavePending = false; try { localStorage.setItem('webx11_filesystem', JSON.stringify({ root: this.root, currentPath: this.currentPath })); } catch (e) { console.error('FS save error:', e); } }, 2000); } getCurrentDir() { let dir = this.root; for (let part of this.currentPath) { if (dir.content[part] && dir.content[part].type === 'dir') { dir = dir.content[part]; } else { return null; } } return dir; } _toAbsoluteSegments(path) { if (!path || path === '.' || path === '') return [...this.currentPath]; if (path === '/') return []; const parts = path.split('/').filter(p => p && p !== '.'); let base = path.startsWith('/') ? [] : [...this.currentPath]; const result = [...base]; for (const p of parts) { if (p === '..') { if (result.length > 0) result.pop(); else return null; } else { result.push(p); } } return result; } _resolvePath(path) { const segs = this._toAbsoluteSegments(path); if (segs === null) return null; if (segs.length === 0) return { dir: this.root, name: null }; let dir = this.root; for (let i = 0; i < segs.length - 1; i++) { if (!dir.content[segs[i]] || dir.content[segs[i]].type !== 'dir') return null; dir = dir.content[segs[i]]; } return { dir, name: segs[segs.length - 1] }; } _resolveDir(path) { const segs = this._toAbsoluteSegments(path); if (segs === null) return null; if (segs.length === 0) return this.root; let dir = this.root; for (const seg of segs) { if (!dir.content[seg] || dir.content[seg].type !== 'dir') return null; dir = dir.content[seg]; } return dir; } ls(path) { let dir; if (path) { const resolved = this._resolvePath(path); if (!resolved) return []; if (resolved.name) { const node = resolved.dir.content[resolved.name]; if (node && node.type === 'dir') dir = node; else return []; } else { dir = resolved.dir; } } else { dir = this.getCurrentDir(); } if (!dir) return []; return Object.keys(dir.content).map(name => ({ name, type: dir.content[name].type, size: dir.content[name].content ? dir.content[name].content.length : 0 })); } writeFile(path, content) { const resolved = this._resolvePath(path); if (!resolved || !resolved.name) return false; const name = resolved.name; if (name === '.' || name === '..') return false; if (resolved.dir.content[name] && resolved.dir.content[name].type === 'dir') return false; resolved.dir.content[name] = { type: 'file', content: content, modified: new Date() }; this.saveToStorage(); return true; } readFile(path) { const resolved = this._resolvePath(path); if (!resolved || !resolved.name) return null; const node = resolved.dir.content[resolved.name]; if (node && node.type === 'file') return node.content; return null; } deleteFile(path) { const resolved = this._resolvePath(path); if (!resolved || !resolved.name) return false; const node = resolved.dir.content[resolved.name]; if (node && node.type === 'file') { delete resolved.dir.content[resolved.name]; this.saveToStorage(); return true; } return false; } mkdir(path) { const resolved = this._resolvePath(path); if (!resolved || !resolved.name) return false; const name = resolved.name; if (name === '.' || name === '..') return false; if (resolved.dir.content[name]) return false; resolved.dir.content[name] = { type: 'dir', content: {} }; this.saveToStorage(); return true; } mkdirRecursive(path) { const segs = this._toAbsoluteSegments(path); if (!segs || segs.length === 0) return true; let dir = this.root; for (const seg of segs) { if (!dir.content[seg]) { dir.content[seg] = { type: 'dir', content: {} }; } else if (dir.content[seg].type !== 'dir') { return false; } dir = dir.content[seg]; } this.saveToStorage(); return true; } readDir(path) { const resolved = this._resolvePath(path); if (!resolved) return null; if (resolved.name) { const node = resolved.dir.content[resolved.name]; return (node && node.type === 'dir') ? node : null; } return resolved.dir; } copyFile(srcPath, dstPath) { const src = this._resolvePath(srcPath); if (!src || !src.name) return false; const srcNode = src.dir.content[src.name]; if (!srcNode || srcNode.type !== 'file') return false; const dst = this._resolvePath(dstPath); if (!dst || !dst.name) return false; const copy = JSON.parse(JSON.stringify(srcNode)); dst.dir.content[dst.name] = copy; this.saveToStorage(); return true; } copyDir(srcPath, dstPath) { const src = this._resolvePath(srcPath); if (!src || !src.name) return false; const srcNode = src.dir.content[src.name]; if (!srcNode || srcNode.type !== 'dir') return false; const dst = this._resolvePath(dstPath); if (!dst || !dst.name) return false; if (dst.dir.content[dst.name]) return false; const copy = JSON.parse(JSON.stringify(srcNode)); dst.dir.content[dst.name] = copy; this.saveToStorage(); return true; } moveFile(srcPath, dstPath) { if (this.copyFile(srcPath, dstPath)) { const src = this._resolvePath(srcPath); if (src && src.name) { delete src.dir.content[src.name]; this.saveToStorage(); return true; } } return false; } moveDir(srcPath, dstPath) { if (this.copyDir(srcPath, dstPath)) { const src = this._resolvePath(srcPath); if (src && src.name) { delete src.dir.content[src.name]; this.saveToStorage(); return true; } } return false; } // Old-compat aliases for agent tools and SkillsModule fsRead(path) { const content = this.readFile(path); return content !== null ? content : null; } fsWrite(path, content) { return this.writeFile(path, content); } fsReadJSON(path) { const c = this.fsRead(path); if (c === null) return null; try { return JSON.parse(c); } catch (e) { return null; } } fsWriteJSON(path, obj) { return this.fsWrite(path, JSON.stringify(obj)); } findFiles(pattern) { const results = []; const search = (node, path) => { if (node.type === 'dir' && node.content) { for (const k of Object.keys(node.content)) { const sub = node.content[k]; const subPath = path ? path + '/' + k : k; if (sub.type === 'dir') search(sub, subPath); else if (sub.type === 'file' && k.toLowerCase().includes(pattern)) results.push(subPath); } } }; search(this.root, ''); return results.sort(); } deleteDirRecursive(path) { const resolved = this._resolvePath(path); if (!resolved || !resolved.name) return false; const node = resolved.dir.content[resolved.name]; if (!node || node.type !== 'dir') return false; delete resolved.dir.content[resolved.name]; this.saveToStorage(); return true; } // Full state serialization for session save/restore serializeState() { return JSON.stringify({ root: this.root, currentPath: this.currentPath, version: 1 }); } restoreState(json) { try { const data = JSON.parse(json); if (!data || !data.root || !data.root.type) return false; this.root = data.root; this.currentPath = data.currentPath || []; this.saveToStorage(); return true; } catch (e) { console.error('FS restore error:', e); return false; } } // Restore VFS from an exported dump file (replaces entire root) restoreFromExport(content) { if (typeof content === 'string') { try { content = JSON.parse(content); } catch (e) { return false; } } if (!content || !content.type) return false; this.root = content; this.currentPath = []; this.saveToStorage(); return true; } _snapshotDirExists() { return !!this.readDir('/_snapshots'); } checkpoint(name) { if (!name) name = new Date().toISOString(); const createdAt = new Date().toISOString(); const data = { version: 1, createdAt, root: JSON.parse(JSON.stringify(this.root)), currentPath: [...this.currentPath] }; if (!this.readDir('/_snapshots')) { this.mkdirRecursive('/_snapshots'); } this.fsWriteJSON('/_snapshots/checkpoint-' + name + '.json', data); this.saveToStorage(); // Auto-manage: keep max 10 checkpoints, delete oldest const all = this.listCheckpoints(); if (all.length > 10) { const sorted = all.slice().sort(); const toDelete = sorted.slice(0, all.length - 10); for (const old of toDelete) this.deleteCheckpoint(old); } return true; } rollback(name) { const raw = this.fsReadJSON('/_snapshots/checkpoint-' + name + '.json'); if (!raw || !raw.root) return false; this.root = raw.root; this.currentPath = raw.currentPath || []; this.saveToStorage(); return true; } listCheckpoints() { const dir = this.readDir('/_snapshots'); if (!dir || !dir.content) return []; const prefix = 'checkpoint-'; const suffix = '.json'; return Object.keys(dir.content) .filter(k => k.startsWith(prefix) && k.endsWith(suffix)) .map(k => k.slice(prefix.length, k.length - suffix.length)) .sort(); } deleteCheckpoint(name) { return this.deleteFile('/_snapshots/checkpoint-' + name + '.json'); } }