diff options
Diffstat (limited to 'modules/core/virtual-fs.js')
| -rw-r--r-- | modules/core/virtual-fs.js | 320 |
1 files changed, 320 insertions, 0 deletions
diff --git a/modules/core/virtual-fs.js b/modules/core/virtual-fs.js new file mode 100644 index 0000000..a505699 --- /dev/null +++ b/modules/core/virtual-fs.js @@ -0,0 +1,320 @@ +class VirtualFileSystem { + constructor() { + this.root = { type: 'dir', content: {} }; + this.currentPath = []; + this.loadFromStorage(); + } + loadFromStorage() { + try { + const saved = localStorage.getItem('webx11_filesystem'); + if (saved) { + const data = JSON.parse(saved); + this.root = data.root || this.root; + this.currentPath = data.currentPath || []; + } + } catch (e) { console.error('FS load error:', e); } + } + saveToStorage() { + try { + localStorage.setItem('webx11_filesystem', JSON.stringify({ + root: this.root, currentPath: this.currentPath + })); + } catch (e) { console.error('FS save error:', e); } + } + 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'); + } +} |