aboutsummaryrefslogtreecommitdiff
path: root/modules/core/virtual-fs.js
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-05-27 06:00:43 +0200
committerAgando <roduccomm@gmail.com>2026-05-27 06:00:43 +0200
commit212708b464c2bdbdd9670c00804623dca0729d2c (patch)
treeee190cbb0d1524350baea641df65aa19817744b5 /modules/core/virtual-fs.js
parentf4d934d341056142da1212c845eeeb170fd452c2 (diff)
auto: 2026-05-27_06:00 changes
Diffstat (limited to 'modules/core/virtual-fs.js')
-rw-r--r--modules/core/virtual-fs.js440
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;