diff options
Diffstat (limited to 'modules/core/virtual-fs.js')
| -rw-r--r-- | modules/core/virtual-fs.js | 440 |
1 files changed, 319 insertions, 121 deletions
diff --git a/modules/core/virtual-fs.js b/modules/core/virtual-fs.js index 68d02bf..d2d3103 100644 --- a/modules/core/virtual-fs.js +++ b/modules/core/virtual-fs.js @@ -2,33 +2,83 @@ class VirtualFileSystem { constructor() { this.root = { type: 'dir', content: {} }; this.currentPath = []; - this.loadFromStorage(); - } - loadFromStorage() { + this._ready = this.loadFromStorage(); + } + _u8ToBase64(u8) { + let s = ''; + for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); + return btoa(s); + } + _base64ToU8(b64) { + const s = atob(b64); + const u8 = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i); + return u8; + } + async _compress(text) { + if (typeof CompressionStream === 'undefined') return null; + const blob = new Blob([text]); + const stream = blob.stream().pipeThrough(new CompressionStream('gzip')); + const resp = new Response(stream); + const buf = await resp.arrayBuffer(); + return this._u8ToBase64(new Uint8Array(buf)); + } + async _decompress(b64) { + if (typeof DecompressionStream === 'undefined') throw new Error('DecompressionStream not available'); + const u8 = this._base64ToU8(b64); + const blob = new Blob([u8]); + const stream = blob.stream().pipeThrough(new DecompressionStream('gzip')); + const resp = new Response(stream); + return await resp.text(); + } + async 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 v2 = localStorage.getItem('webx11_filesystem_v2'); + if (v2) { + try { + const text = await this._decompress(v2); + const data = JSON.parse(text); + this.root = data.root || this.root; + this.currentPath = data.currentPath || []; + return true; + } catch (e) { + console.error('FS v2 decompress error:', e); + return this._loadLegacy(); } - const data = JSON.parse(saved); - this.root = data.root || this.root; - this.currentPath = data.currentPath || []; } - } catch (e) { console.error('FS load error:', e); } + return this._loadLegacy(); + } catch (e) { console.error('FS load error:', e); return false; } + } + _loadLegacy() { + const saved = localStorage.getItem('webx11_filesystem'); + if (saved) { + if (saved.length > 524288) { + console.warn('FS data too large (' + (saved.length/1024).toFixed(0) + 'KB), skipping load'); + localStorage.removeItem('webx11_filesystem'); + return false; + } + const data = JSON.parse(saved); + this.root = data.root || this.root; + this.currentPath = data.currentPath || []; + console.info('Loaded legacy FS; will migrate to compressed on next save.'); + return true; + } + return false; } saveToStorage() { if (this._storageSavePending) return; this._storageSavePending = true; - setTimeout(() => { + setTimeout(async () => { this._storageSavePending = false; try { - localStorage.setItem('webx11_filesystem', JSON.stringify({ - root: this.root, currentPath: this.currentPath - })); + const json = JSON.stringify({ root: this.root, currentPath: this.currentPath }); + const compressed = await this._compress(json); + if (compressed) { + localStorage.setItem('webx11_filesystem_v2', compressed); + localStorage.removeItem('webx11_filesystem'); + } else { + localStorage.setItem('webx11_filesystem', json); + } } catch (e) { console.error('FS save error:', e); } }, 2000); } @@ -122,25 +172,62 @@ class VirtualFileSystem { 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(); + this.trashFile(path, node); return true; } return false; } - mkdir(path) { + trashFile(path, node) { + try { + const name = path.split('/').pop() || 'unknown'; + const ts = Date.now(); + if (!this.root.content['.trash']) { + this.root.content['.trash'] = { type: 'dir', content: {} }; + } + const trash = this.root.content['.trash']; + const trashName = ts + '-' + name; + trash.content[trashName] = JSON.parse(JSON.stringify(node)); + const resolved = this._resolvePath(path); + if (resolved) delete resolved.dir.content[resolved.name]; + this.saveToStorage(); + } catch (e) { console.error('Trash error:', e); } + } + trashDir(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; + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') { + try { + const name = resolved.name; + const ts = Date.now(); + if (!this.root.content['.trash']) { + this.root.content['.trash'] = { type: 'dir', content: {} }; + } + const trash = this.root.content['.trash']; + const flatten = (n, prefix) => { + if (n.type === 'file') { + const flatName = prefix + '-' + name; + let ctr = 0; + let unique = flatName; + while (trash.content[unique]) { unique = flatName + '-' + (++ctr); } + trash.content[unique] = JSON.parse(JSON.stringify(n)); + } else if (n.type === 'dir') { + for (const [childName, child] of Object.entries(n.content || {})) { + flatten(child, prefix + '-' + childName); + } + } + }; + flatten(node, ts + '-dir-' + name); + delete resolved.dir.content[resolved.name]; + this.saveToStorage(); + return true; + } catch (e) { console.error('Trash dir error:', e); } + } + return false; } mkdirRecursive(path) { const segs = this._toAbsoluteSegments(path); - if (!segs || segs.length === 0) return true; + if (!segs) return false; let dir = this.root; for (const seg of segs) { if (!dir.content[seg]) { @@ -153,122 +240,209 @@ class VirtualFileSystem { 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); + rename(oldPath, newPath) { + const src = this._resolvePath(oldPath); 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); + const dst = this._resolvePath(newPath); if (!dst || !dst.name) return false; - const copy = JSON.parse(JSON.stringify(srcNode)); - dst.dir.content[dst.name] = copy; + const node = src.dir.content[src.name]; + if (!node) return false; + dst.dir.content[dst.name] = node; + delete src.dir.content[src.name]; this.saveToStorage(); return true; } - copyDir(srcPath, dstPath) { + copy(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; + const node = src.dir.content[src.name]; + if (!node) return false; + dst.dir.content[dst.name] = JSON.parse(JSON.stringify(node)); 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; - } + move(srcPath, dstPath) { + return this.rename(srcPath, dstPath); + } + cp(srcPath, dstPath) { + return this.copy(srcPath, dstPath); + } + mv(srcPath, dstPath) { + return this.rename(srcPath, dstPath); + } + rm(path) { + const resolved = this._resolvePath(path); + if (!resolved || !resolved.name) return false; + const node = resolved.dir.content[resolved.name]; + if (!node) return false; + if (node.type === 'file') { + this.trashFile(path, node); + return true; + } + if (node.type === 'dir') { + this.trashDir(path); + 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; - } + rmdir(path) { + const resolved = this._resolvePath(path); + if (!resolved || !resolved.name) return false; + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') { + if (Object.keys(node.content).length > 0) return false; + delete resolved.dir.content[resolved.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; + touch(path) { + return this.writeFile(path, ''); } - fsWrite(path, content) { - return this.writeFile(path, content); + readDir(path) { + const resolved = this._resolvePath(path); + if (!resolved) return null; + let dir; + if (resolved.name) { + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') dir = node; + else return null; + } else { + dir = resolved.dir; + } + return Object.keys(dir.content).map(name => ({ + name, + type: dir.content[name].type, + size: dir.content[name].content ? dir.content[name].content.length : 0 + })); + } + fsWriteJSON(path, obj) { + return this.writeFile(path, JSON.stringify(obj)); } fsReadJSON(path) { - const c = this.fsRead(path); - if (c === null) return null; - try { return JSON.parse(c); } catch (e) { return null; } + const content = this.readFile(path); + if (!content) return null; + try { + return JSON.parse(content); + } catch (e) { + return null; + } } - fsWriteJSON(path, obj) { - return this.fsWrite(path, JSON.stringify(obj)); + tree(path = '', prefix = '') { + const resolved = this._resolvePath(path); + if (!resolved) return ''; + let dir; + if (resolved.name) { + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') dir = node; + else return ''; + } else { + dir = resolved.dir; + } + let result = ''; + const entries = Object.keys(dir.content).sort(); + for (let i = 0; i < entries.length; i++) { + const name = entries[i]; + const isLast = i === entries.length - 1; + const connector = isLast ? '└── ' : '├── '; + result += prefix + connector + name + '\n'; + if (dir.content[name].type === 'dir') { + const ext = isLast ? ' ' : '│ '; + result += this.tree(path + '/' + name, prefix + ext); + } + } + return result; } - findFiles(pattern) { + find(pattern, path = '') { + const resolved = this._resolvePath(path); + if (!resolved) return []; + let dir; + if (resolved.name) { + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') dir = node; + else return []; + } else { + dir = resolved.dir; + } 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); - } + const regex = new RegExp(pattern, 'i'); + for (const name of Object.keys(dir.content)) { + const fullPath = path ? path + '/' + name : name; + if (regex.test(name)) { + results.push({ path: fullPath, type: dir.content[name].type }); } - }; - search(this.root, ''); - return results.sort(); + if (dir.content[name].type === 'dir') { + results.push(...this.find(pattern, fullPath)); + } + } + return results; + } + grep(pattern, path = '') { + const results = []; + const regex = new RegExp(pattern, 'i'); + const files = this.find('.', path).filter(e => e.type === 'file'); + for (const file of files) { + const content = this.readFile(file.path); + if (content && regex.test(content)) { + results.push(file.path); + } + } + return results; } - deleteDirRecursive(path) { + chmod(path, mode) { 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]; + if (!node) return false; + node.mode = mode; this.saveToStorage(); return true; } - // Full state serialization for session save/restore - serializeState() { - return JSON.stringify({ - root: this.root, - currentPath: this.currentPath, - version: 1 - }); + du(path = '') { + const resolved = this._resolvePath(path); + if (!resolved) return 0; + let dir; + if (resolved.name) { + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') dir = node; + else return node.content ? node.content.length : 0; + } else { + dir = resolved.dir; + } + let size = 0; + for (const name of Object.keys(dir.content)) { + const node = dir.content[name]; + if (node.type === 'file') { + size += node.content ? node.content.length : 0; + } else if (node.type === 'dir') { + size += this.du(path ? path + '/' + name : name); + } + } + return size; + } + getSize(path) { + const resolved = this._resolvePath(path); + if (!resolved || !resolved.name) return 0; + const node = resolved.dir.content[resolved.name]; + if (!node) return 0; + if (node.type === 'file') return node.content ? node.content.length : 0; + return this.du(path); } - 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; + exportToJSON(path = '') { + const resolved = this._resolvePath(path); + if (!resolved) return null; + let dir; + if (resolved.name) { + const node = resolved.dir.content[resolved.name]; + if (node && node.type === 'dir') dir = node; + else return JSON.stringify(node); + } else { + dir = resolved.dir; } + return JSON.stringify(dir, null, 2); } // Restore VFS from an exported dump file (replaces entire root) restoreFromExport(content) { @@ -276,8 +450,15 @@ class VirtualFileSystem { try { content = JSON.parse(content); } catch (e) { return false; } } if (!content || !content.type) return false; - this.root = content; - this.currentPath = []; + // Handle v2 structured export: { type, version, root, currentPath, ... } + if (content.version === 2 || content.root) { + this.root = content.root; + this.currentPath = content.currentPath || []; + } else { + // Legacy format: content is just the root object + this.root = content; + this.currentPath = []; + } this.saveToStorage(); return true; } @@ -309,7 +490,10 @@ class VirtualFileSystem { } rollback(name) { const raw = this.fsReadJSON('/_snapshots/checkpoint-' + name + '.json'); - if (!raw || !raw.root) return false; + if (!raw || !raw.root) { + console.error('Rollback failed: checkpoint not found or invalid'); + return false; + } this.root = raw.root; this.currentPath = raw.currentPath || []; this.saveToStorage(); @@ -317,15 +501,29 @@ class VirtualFileSystem { } 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(); + if (!dir) return []; + return dir + .filter(e => e.type === 'file' && e.name.startsWith('checkpoint-') && e.name.endsWith('.json')) + .map(e => e.name.replace('checkpoint-', '').replace('.json', '')); } deleteCheckpoint(name) { - return this.deleteFile('/_snapshots/checkpoint-' + name + '.json'); + const path = '/_snapshots/checkpoint-' + name + '.json'; + const resolved = this._resolvePath(path); + if (resolved && resolved.name) { + delete resolved.dir.content[resolved.name]; + this.saveToStorage(); + return true; + } + return false; } + // Old-compat aliases for agent tools and SkillsModule + read(path) { return this.readFile(path); } + write(path, content) { return this.writeFile(path, content); } + delete(path) { return this.deleteFile(path); } + mkdir(path) { return this.mkdirRecursive(path); } + list(path) { return this.ls(path); } + dir(path) { return this.ls(path); } } + +// Also expose for module system +window.VirtualFileSystem = VirtualFileSystem; |