aboutsummaryrefslogtreecommitdiff
path: root/modules/core/virtual-fs.js
diff options
context:
space:
mode:
Diffstat (limited to 'modules/core/virtual-fs.js')
-rw-r--r--modules/core/virtual-fs.js320
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');
+ }
+}