aboutsummaryrefslogtreecommitdiff
path: root/.bkp-rca-20261018_181308/virtual-fs.js
diff options
context:
space:
mode:
Diffstat (limited to '.bkp-rca-20261018_181308/virtual-fs.js')
-rw-r--r--.bkp-rca-20261018_181308/virtual-fs.js975
1 files changed, 975 insertions, 0 deletions
diff --git a/.bkp-rca-20261018_181308/virtual-fs.js b/.bkp-rca-20261018_181308/virtual-fs.js
new file mode 100644
index 0000000..8f11d25
--- /dev/null
+++ b/.bkp-rca-20261018_181308/virtual-fs.js
@@ -0,0 +1,975 @@
+class VirtualFileSystem {
+ constructor() {
+ this.root = { type: 'dir', content: {} };
+ this.currentPath = [];
+ this._ready = this.loadFromStorage();
+ this._virtualMounts = null; // lazy-init on first /sys/ access
+ // Register /sys/ in emit-change exclusion (also added below)
+ }
+ // ── Auto-purge trash older than 24 hours ──
+ _purgeTrash() {
+ try {
+ const trash = this.root.content && this.root.content['.trash'];
+ if (!trash || !trash.content) return;
+ const now = Date.now();
+ const maxAge = 86400000; // 24 hours
+ let purged = 0;
+ for (const name of Object.keys(trash.content)) {
+ const tsMatch = name.match(/^(\d+)/);
+ if (tsMatch) {
+ const ts = parseInt(tsMatch[1], 10);
+ if (now - ts > maxAge) {
+ delete trash.content[name];
+ purged++;
+ }
+ }
+ }
+ if (purged > 0) {
+ this.saveToStorage();
+ }
+ } catch (e) { /* silent */ }
+ }
+ // ── Virtual /sys/ filesystem (live runtime state, never persisted) ──
+ _initVirtualMounts() {
+ if (this._virtualMounts) return;
+ const vm = (name, factory) => ({ _factory: factory, _name: name });
+ const dir = () => ({ _dir: true, content: {} });
+ const vmCache = {}; // factories can cache costly results within same tick
+ const mm = () => typeof window !== 'undefined' ? window.webx11?.moduleManager?.modules : null;
+ const ai = () => mm()?.WebX11AIAssistantModule || null;
+ const ds = () => mm()?.DebugSupervisorModule || null;
+ const heap = () => typeof performance !== 'undefined' && performance.memory ? performance.memory : null;
+ const fmt = (bytes) => {
+ if (!bytes && bytes !== 0) return 'N/A';
+ const kb = bytes / 1024;
+ if (kb < 1024) return kb.toFixed(1) + ' KB';
+ return (kb / 1024).toFixed(1) + ' MB';
+ };
+ const fmtBytes = (bytes) => {
+ if (!bytes && bytes !== 0) return 'N/A';
+ if (bytes < 1024) return bytes + ' B';
+ const kb = bytes / 1024;
+ if (kb < 1024) return kb.toFixed(1) + ' KB';
+ return (kb / 1024).toFixed(1) + ' MB';
+ };
+ // Walk VFS root to count files/dirs and find largest
+ const vfsWalk = (dir, prefix) => {
+ let files = 0, dirs = 0, total = 0, largest = [];
+ const walk = (node, p) => {
+ if (!node) return;
+ if (node._factory || node._dir) return; // skip virtual
+ if (node.type === 'file') {
+ files++;
+ const size = node.content ? node.content.length : 0;
+ total += size;
+ largest.push({ path: p, size });
+ } else if (node.type === 'dir') {
+ dirs++;
+ for (const [name, child] of Object.entries(node.content || {})) {
+ walk(child, p + '/' + name);
+ }
+ }
+ };
+ walk(dir, prefix);
+ largest.sort((a, b) => b.size - a.size);
+ return { files, dirs, total, largest: largest.slice(0, 20) };
+ };
+ this._virtualMounts = {
+ // /sys/info
+ info: vm('info', () => [
+ '=== newOS4 Runtime Debug (/sys/) ===',
+ `Version: ${window.NEWOS4_VERSION || '?'}`,
+ `JS heap: ${fmt(heap()?.usedJSHeapSize)} / ${fmt(heap()?.jsHeapSizeLimit)}`,
+ `DOM nodes: ${typeof document !== 'undefined' ? document.querySelectorAll('*').length : 'N/A'}`,
+ `Loaded modules: ${mm() ? Object.keys(mm()).length : 'N/A'}`,
+ '',
+ ' cat /sys/heap/stats — Memory overview',
+ ' cat /sys/heap/breakdown — What consumes what',
+ ' cat /sys/logs/tool-calls — AI tool call log',
+ ' cat /sys/logs/errors — AI error log',
+ ' cat /sys/vfs/stats — VFS file/dir counts',
+ ' cat /sys/vfs/largest — Top 20 largest files',
+ ' ls /sys/modules/ — Loaded modules',
+ ' cat /sys/config/ai — AI config parameters',
+ ' cat /sys/dom/node-count — DOM element breakdown',
+ ].join('\n')),
+ // /sys/heap/
+ heap: { _dir: true, content: {
+ stats: vm('stats', () => {
+ const h = heap();
+ const lsSize = (k) => { try { return (localStorage.getItem(k) || '').length / 1024; } catch(e) { return 0; } };
+ return [
+ '=== Heap Stats ===',
+ ` JS heap used: ${fmt(h?.usedJSHeapSize)}`,
+ ` JS heap limit: ${fmt(h?.jsHeapSizeLimit)}`,
+ ` JS heap % used: ${h ? ((h.usedJSHeapSize / h.jsHeapSizeLimit) * 100).toFixed(1) + '%' : 'N/A'}`,
+ '',
+ ` VFS localStorage: ${lsSize('webx11_filesystem_v2').toFixed(1)} KB`,
+ ` AI localStorage: ${lsSize('webx11_ai_context').toFixed(1)} KB`,
+ ` Config localStorage: ${lsSize('webx11_ai_config').toFixed(1)} KB`,
+ ].join('\n');
+ }),
+ breakdown: vm('breakdown', () => {
+ const a = ai();
+ const cCount = a?.context?.length || 0;
+ const lCount = a?._toolCallLog?.length || 0;
+ const eCount = ds() ? Object.keys(ds()._errorDB?.errors || {}).length : 0;
+ const modules = mm() ? Object.keys(mm()).length : 0;
+ const domNodes = typeof document !== 'undefined' ? document.querySelectorAll('*').length : 0;
+ return [
+ '=== Heap Breakdown (estimates) ===',
+ ` Module code + state: ~${(modules * 1.2).toFixed(1)} MB (${modules} modules × ~1.2MB)`,
+ ` VFS tree (in-memory): varies with file count`,
+ ` AI context messages: ${cCount} (max: ${a?.maxContext || '?'})`,
+ ` AI tool call log: ${lCount} (cap: 50)`,
+ ` AI error log: ${eCount} (cap: 20)`,
+ ` DOM nodes: ${domNodes}`,
+ ].join('\n');
+ })
+ }},
+ // /sys/modules/
+ modules: { _dir: true, content: {
+ list: vm('list', () => {
+ const m = mm();
+ if (!m) return '(moduleManager not available)';
+ return Object.keys(m).sort().join('\n');
+ })
+ // module subdirectories are dynamic — handled in _virtualLs
+ }},
+ // /sys/vfs/
+ vfs: { _dir: true, content: {
+ stats: vm('stats', () => {
+ const w = vfsWalk(this.root, '');
+ return [
+ '=== VFS Stats ===',
+ ` Files: ${w.files}`,
+ ` Directories: ${w.dirs}`,
+ ` Total content: ${fmtBytes(w.total)}`,
+ ` localStorage: ${((localStorage.getItem('webx11_filesystem_v2') || '').length / 1024).toFixed(1)} KB (compressed)`,
+ ].join('\n');
+ }),
+ largest: vm('largest', () => {
+ const w = vfsWalk(this.root, '');
+ if (w.largest.length === 0) return '(empty VFS)';
+ const maxW = Math.max(...w.largest.map(f => f.path.length), 8);
+ let out = '=== Top 20 Largest Files ===\n';
+ w.largest.forEach((f, i) => {
+ out += ` ${(i + 1).toString().padStart(2)}. ${f.path.padEnd(maxW)} ${fmtBytes(f.size)}\n`;
+ });
+ return out;
+ })
+ }},
+ // /sys/logs/
+ logs: { _dir: true, content: {
+ 'tool-calls': vm('tool-calls', () => {
+ const a = ai();
+ if (!a || !a._toolCallLog || a._toolCallLog.length === 0) return '(empty)';
+ return a._toolCallLog.map((e, i) => `[${i}] ${JSON.stringify(e)}`).join('\n');
+ }),
+ 'errors': vm('errors', () => {
+ const d = ds();
+ if (!d || !d._errorDB || Object.keys(d._errorDB.errors).length === 0) return '(empty)';
+ return Object.entries(d._errorDB.errors)
+ .sort((a, b) => b[1].lastSeen - a[1].lastSeen)
+ .map(([hash, e]) => `[${hash}] ${e.type}/${e.severity} ×${e.count}: ${e.message.substring(0, 100)}`)
+ .join('\n');
+ }),
+ 'commands': vm('commands', () => {
+ const a = ai();
+ if (!a || !a._commandFreq || Object.keys(a._commandFreq).length === 0) return '(empty)';
+ const sorted = Object.entries(a._commandFreq).sort((a,b) => b[1] - a[1]);
+ return sorted.map(([cmd, n]) => `${cmd.padEnd(20)} ${n}x`).join('\n');
+ })
+ }},
+ // /sys/dom/
+ dom: { _dir: true, content: {
+ 'node-count': vm('node-count', () => {
+ if (typeof document === 'undefined') return 'N/A';
+ const all = document.querySelectorAll('*');
+ const byTag = {};
+ all.forEach(el => { const t = el.tagName.toLowerCase(); byTag[t] = (byTag[t] || 0) + 1; });
+ const sorted = Object.entries(byTag).sort((a,b) => b[1] - a[1]);
+ let out = `Total DOM nodes: ${all.length}\n\n=== By Tag ===\n`;
+ sorted.forEach(([tag, count]) => {
+ out += ` ${tag.padEnd(15)} ${count}\n`;
+ });
+ return out;
+ })
+ }},
+ // /sys/config/
+ config: { _dir: true, content: {
+ version: vm('version', () => {
+ return `NEWOS4_VERSION: ${window.NEWOS4_VERSION || '?'}\n`;
+ }),
+ ai: vm('ai', () => {
+ const a = ai();
+ if (!a) return '(AI module not loaded)';
+ return [
+ '=== AI Config ===',
+ ` maxContext: ${a.maxContext}`,
+ ` MAX_TOOL_TURNS: ${a.MAX_TOOL_TURNS}`,
+ ` maxContextTokens: ${a.maxContextTokens}`,
+ ` confirmTimeoutMs: ${a._confirmTimeoutMs}`,
+ ` skillDebounceMs: ${a._skillDebounceMs}`,
+ ` context messages: ${a.context?.length || 0}`,
+ ` tool call log cap: 50 (auto-capped)`,
+ ` error log cap: 20 (auto-capped)`,
+ ].join('\n');
+ })
+ }}
+ };
+ }
+ // Check if a path is under /sys/
+ _isVirtualPath(path) {
+ if (!path) return false;
+ const normalized = path.startsWith('/') ? path : '/' + path;
+ return normalized === '/sys' || normalized.startsWith('/sys/');
+ }
+ // Resolve a virtual path: returns {dir: virtualNode, name: lastSegment} or null
+ _resolveVirtualPath(path) {
+ this._initVirtualMounts();
+ const normalized = path.startsWith('/') ? path : '/' + path;
+ let segments = normalized.split('/').filter(s => s);
+ // Remove leading 'sys'
+ if (segments[0] === 'sys') segments = segments.slice(1);
+ if (segments.length === 0) {
+ // /sys itself — return the root virtual mounts node as a dir
+ const allContent = {};
+ for (const [k, v] of Object.entries(this._virtualMounts)) {
+ if (v._dir) allContent[k] = { type: 'dir', content: v.content, _virtual: true };
+ else allContent[k] = { type: 'file', content: null, _virtual: true, _factory: v._factory };
+ }
+ // Add dynamic module subdirs to modules listing
+ if (this._virtualMounts.modules?._dir) {
+ const mm = typeof window !== 'undefined' ? window.webx11?.moduleManager?.modules : null;
+ if (mm) {
+ for (const modName of Object.keys(mm)) {
+ if (!allContent[modName]) {
+ allContent[modName] = { type: 'dir', content: {}, _virtual: true };
+ }
+ }
+ }
+ }
+ return { dir: { content: allContent }, name: null };
+ }
+ // Walk the tree
+ let node = this._virtualMounts;
+ for (let i = 0; i < segments.length - 1; i++) {
+ const seg = segments[i];
+ if (!node[seg]) {
+ // Check dynamic module subdir under /sys/modules/
+ const mods = typeof window !== 'undefined' ? window.webx11?.moduleManager?.modules : null;
+ if (node === this._virtualMounts?.modules?.content && mods && mods[seg]) {
+ // Dynamic module subdir — create virtual node
+ node = {
+ _dir: true,
+ content: {
+ info: { type: 'file', content: null, _virtual: true, _factory: () => {
+ const mod = mods[seg];
+ const cmdKeys = mod.commands ? Object.keys(mod.commands) : [];
+ return [
+ `Module: ${seg}`,
+ ` Type: ${typeof mod}`,
+ ` Commands: ${cmdKeys.join(', ') || '(none)'}`,
+ ` Has help: ${mod.getHelpInfo ? 'yes' : 'no'}`,
+ ` Constructor: ${mod.constructor?.name || 'anonymous'}`,
+ ].join('\n');
+ }}
+ }
+ };
+ continue;
+ }
+ return null;
+ }
+ node = node[seg];
+ if (node && node._dir) node = node.content || {};
+ }
+ const lastName = segments[segments.length - 1];
+ if (node && node[lastName]) {
+ const entry = node[lastName];
+ if (entry._dir) {
+ // Static subdir with known content
+ const allContent = {};
+ for (const [k, v] of Object.entries(entry.content || {})) {
+ if (v._dir) allContent[k] = { type: 'dir', content: v.content, _virtual: true };
+ else allContent[k] = { type: 'file', content: null, _virtual: true, _factory: v._factory };
+ }
+ // Add dynamic module subdirs
+ if (segments[0] === 'modules' || (segments.length >= 2 && segments[segments.length - 2] === 'modules')) {
+ const mm = typeof window !== 'undefined' ? window.webx11?.moduleManager?.modules : null;
+ if (mm) {
+ for (const modName of Object.keys(mm)) {
+ if (!allContent[modName]) {
+ allContent[modName] = { type: 'dir', content: {}, _virtual: true };
+ }
+ }
+ }
+ }
+ return { dir: { content: allContent }, name: null };
+ } else {
+ return { dir: { content: node }, name: lastName };
+ }
+ }
+ // Check if it's a module subdirectory itself (e.g. /sys/modules/calc)
+ const mods = typeof window !== 'undefined' ? window.webx11?.moduleManager?.modules : null;
+ if (node && mods && mods[lastName] && segments.length >= 1) {
+ // It's a module name — return its info dir
+ return {
+ dir: { content: {
+ info: { type: 'file', content: null, _virtual: true, _factory: () => {
+ const mod = mods[lastName];
+ const cmdKeys = mod.commands ? Object.keys(mod.commands) : [];
+ return [
+ `Module: ${lastName}`,
+ ` Type: ${typeof mod}`,
+ ` Commands: ${cmdKeys.join(', ') || '(none)'}`,
+ ` Has help: ${mod.getHelpInfo ? 'yes' : 'no'}`,
+ ` Constructor: ${mod.constructor?.name || 'anonymous'}`,
+ ].join('\n');
+ }}
+ }},
+ name: null
+ };
+ }
+ return null;
+ }
+ _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 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 || [];
+ this._purgeTrash();
+ return true;
+ } catch (e) {
+ console.error('FS v2 decompress error:', e);
+ return this._loadLegacy();
+ }
+ }
+ // P0: Check IndexedDB fallback if localStorage is empty
+ try {
+ const idbData = await this._loadFromIndexedDB();
+ if (idbData) {
+ const text = await this._decompress(idbData);
+ const data = JSON.parse(text);
+ this.root = data.root || this.root;
+ this.currentPath = data.currentPath || [];
+ this._usingIndexedDB = true;
+ this._purgeTrash();
+ console.info('FS loaded from IndexedDB fallback');
+ return true;
+ }
+ } catch (e) { /* IndexedDB not available or empty */ }
+ 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;
+ }
+ _emitChange(path) {
+ // Notify NeuralVFS via window event — zero coupling
+ // Skip internal index/system files to prevent re-trigger loops
+ if (!path) return;
+ const normalized = path.startsWith('/') ? path : '/' + path;
+ const sysPaths = ['/.semantic_index', '/.memory_graph', '/.trash', '/_snapshots', '/_sessions', '/logs', '/memory', '/system', '/_mods', '/backups', '/sys'];
+ if (sysPaths.some(p => normalized.startsWith(p))) return;
+ if (typeof window !== 'undefined' && window.__nvfsMarkDirty) {
+ window.__nvfsMarkDirty(path);
+ }
+ }
+ saveToStorage() {
+ if (this._storageSavePending) return;
+ this._storageSavePending = true;
+ setTimeout(async () => {
+ this._storageSavePending = false;
+ try {
+ const json = JSON.stringify({ root: this.root, currentPath: this.currentPath });
+ const compressed = await this._compress(json);
+ if (compressed) {
+ try {
+ localStorage.setItem('webx11_filesystem_v2', compressed);
+ localStorage.removeItem('webx11_filesystem');
+ } catch (quotaErr) {
+ // P0: IndexedDB fallback when localStorage quota exceeded
+ console.warn('localStorage quota exceeded, falling back to IndexedDB:', quotaErr.message);
+ await this._saveToIndexedDB(compressed);
+ this._usingIndexedDB = true;
+ }
+ } else {
+ localStorage.setItem('webx11_filesystem', json);
+ }
+ } catch (e) { console.error('FS save error:', e); }
+ }, 2000);
+ }
+ // P0: IndexedDB fallback storage for large VFS data
+ _openIDB() {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open('webx11_vfs', 1);
+ req.onupgradeneeded = () => {
+ req.result.createObjectStore('vfs');
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+ }
+ async _saveToIndexedDB(data) {
+ const db = await this._openIDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction('vfs', 'readwrite');
+ tx.objectStore('vfs').put(data, 'filesystem_v2');
+ tx.oncomplete = () => { db.close(); resolve(true); };
+ tx.onerror = () => { db.close(); reject(tx.error); };
+ });
+ }
+ async _loadFromIndexedDB() {
+ const db = await this._openIDB();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction('vfs', 'readonly');
+ const req = tx.objectStore('vfs').get('filesystem_v2');
+ req.onsuccess = () => { db.close(); resolve(req.result || null); };
+ req.onerror = () => { db.close(); reject(req.error); };
+ });
+ }
+ 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) {
+ if (this._isVirtualPath(path)) return this._resolveVirtualPath(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) {
+ if (this._isVirtualPath(path)) {
+ const r = this._resolveVirtualPath(path);
+ return r ? r.dir : null;
+ }
+ 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 [];
+ const entries = Object.keys(dir.content).map(name => ({
+ name, type: dir.content[name].type,
+ size: dir.content[name]._factory ? 0 : (dir.content[name].content ? dir.content[name].content.length : 0),
+ _virtual: !!dir.content[name]._virtual
+ }));
+ // Inject virtual /sys/ at root level
+ if ((!path || path === '/' || path === '') && !entries.some(e => e.name === 'sys' && e._virtual)) {
+ entries.push({ name: 'sys', type: 'dir', size: 0, _virtual: true });
+ }
+ return entries;
+ }
+ writeFile(path, content) {
+ if (this._isVirtualPath(path)) return false;
+ 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();
+ this._emitChange(path);
+ 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._factory) {
+ return node._factory();
+ }
+ if (node && node.type === 'file') return node.content;
+ return null;
+ }
+ deleteFile(path) {
+ if (this._isVirtualPath(path)) return false;
+ const resolved = this._resolvePath(path);
+ if (!resolved || !resolved.name) return false;
+ const node = resolved.dir.content[resolved.name];
+ if (node && node.type === 'file') {
+ this.trashFile(path, node);
+ return true;
+ }
+ return false;
+ }
+ 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();
+ this._emitChange(path);
+ } catch (e) { console.error('Trash error:', e); }
+ }
+ trashDir(path) {
+ const resolved = this._resolvePath(path);
+ if (!resolved || !resolved.name) return false;
+ 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'];
+ // Keep the subtree intact so it can be restored later; the old flatten loop
+ // turned every descendant into a sibling file and lost empty dirs entirely.
+ const baseName = ts + '-dir-' + name;
+ let unique = baseName;
+ let ctr = 0;
+ while (trash.content[unique]) { unique = baseName + '-' + (++ctr); }
+ trash.content[unique] = JSON.parse(JSON.stringify(node));
+ delete resolved.dir.content[resolved.name];
+ this.saveToStorage();
+ this._emitChange(path);
+ return true;
+ } catch (e) { console.error('Trash dir error:', e); }
+ }
+ return false;
+ }
+ deleteDirRecursive(path) {
+ return this.trashDir(path);
+ }
+ mkdirRecursive(path) {
+ if (this._isVirtualPath(path)) return false;
+ const segs = this._toAbsoluteSegments(path);
+ if (!segs) return false;
+ 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();
+ this._emitChange(path);
+ return true;
+ }
+ rename(oldPath, newPath) {
+ if (this._isVirtualPath(oldPath) || this._isVirtualPath(newPath)) return false;
+ const src = this._resolvePath(oldPath);
+ if (!src || !src.name) return false;
+ const dst = this._resolvePath(newPath);
+ if (!dst || !dst.name) return false;
+ 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();
+ this._emitChange(oldPath);
+ this._emitChange(newPath);
+ return true;
+ }
+ copy(srcPath, dstPath) {
+ if (this._isVirtualPath(srcPath) || this._isVirtualPath(dstPath)) return false;
+ const src = this._resolvePath(srcPath);
+ if (!src || !src.name) return false;
+ const dst = this._resolvePath(dstPath);
+ if (!dst || !dst.name) return false;
+ const node = src.dir.content[src.name];
+ if (!node) return false;
+ dst.dir.content[dst.name] = JSON.parse(JSON.stringify(node));
+ this.saveToStorage();
+ this._emitChange(dstPath);
+ 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) {
+ if (this._isVirtualPath(path)) return false;
+ 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;
+ }
+ rmdir(path) {
+ if (this._isVirtualPath(path)) return false;
+ 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();
+ this._emitChange(path);
+ return true;
+ }
+ return false;
+ }
+ touch(path) {
+ if (this._isVirtualPath(path)) return false;
+ return this.writeFile(path, '');
+ }
+ 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' || node._virtual)) dir = node;
+ else return null;
+ } else {
+ dir = resolved.dir;
+ }
+ const entries = Object.keys(dir.content).map(name => ({
+ name,
+ type: dir.content[name].type || (dir.content[name]._virtual ? 'file' : '?'),
+ size: dir.content[name]._factory ? 0 : (dir.content[name].content ? dir.content[name].content.length : 0),
+ _virtual: !!dir.content[name]._virtual
+ }));
+ // Inject virtual /sys/ at root level
+ if ((!path || path === '/' || path === '') && !entries.some(e => e.name === 'sys' && e._virtual)) {
+ entries.push({ name: 'sys', type: 'dir', size: 0, _virtual: true });
+ }
+ return entries;
+ }
+ fsWriteJSON(path, obj) {
+ return this.writeFile(path, JSON.stringify(obj));
+ }
+ fsReadJSON(path) {
+ const content = this.readFile(path);
+ if (!content) return null;
+ try {
+ return JSON.parse(content);
+ } catch (e) {
+ return null;
+ }
+ }
+ 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();
+ // Inject /sys/ at root level in tree view
+ const atRoot = !path || path === '' || path === '/';
+ if (atRoot && !entries.includes('sys')) entries.push('sys');
+ 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' || dir.content[name]._virtual) {
+ const ext = isLast ? ' ' : '│ ';
+ result += this.tree(path + '/' + name, prefix + ext);
+ }
+ }
+ return result;
+ }
+ find(pattern, path = '') {
+ if (this._isVirtualPath(path || '/')) return [];
+ 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 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 });
+ }
+ 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;
+ }
+ chmod(path, mode) {
+ if (this._isVirtualPath(path)) return false;
+ const resolved = this._resolvePath(path);
+ if (!resolved || !resolved.name) return false;
+ const node = resolved.dir.content[resolved.name];
+ if (!node) return false;
+ node.mode = mode;
+ this.saveToStorage();
+ return true;
+ }
+ du(path = '') {
+ if (this._isVirtualPath(path || '/')) return 0;
+ 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);
+ }
+ 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) {
+ if (typeof content === 'string') {
+ try { content = JSON.parse(content); } catch (e) { return false; }
+ }
+ if (!content || !content.type) return false;
+ // 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;
+ }
+ _snapshotDirExists() {
+ return !!this.readDir('/_snapshots');
+ }
+ checkpoint(name) {
+ if (!name) name = new Date().toISOString();
+ const createdAt = new Date().toISOString();
+ // Strip system dirs to prevent fractal bloat
+ const rootClone = JSON.parse(JSON.stringify(this.root));
+ if (rootClone && rootClone.content) {
+ delete rootClone.content['_sessions'];
+ delete rootClone.content['_snapshots'];
+ delete rootClone.content['.trash'];
+ }
+ const data = {
+ version: 1,
+ createdAt,
+ root: rootClone,
+ currentPath: [...this.currentPath]
+ };
+ if (!this.readDir('/_snapshots')) {
+ this.mkdirRecursive('/_snapshots');
+ }
+ this.fsWriteJSON('/_snapshots/checkpoint-' + name + '.json', data);
+ this.saveToStorage();
+ // Auto-manage: keep max 3 checkpoints, delete oldest
+ const all = this.listCheckpoints();
+ if (all.length > 3) {
+ const sorted = all.slice().sort();
+ const toDelete = sorted.slice(0, all.length - 3);
+ for (const old of toDelete) this.deleteCheckpoint(old);
+ }
+ return true;
+ }
+ rollback(name) {
+ const raw = this.fsReadJSON('/_snapshots/checkpoint-' + name + '.json');
+ 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();
+ return true;
+ }
+ listCheckpoints() {
+ const dir = this.readDir('/_snapshots');
+ 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) {
+ 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); }
+ reset() {
+ this.root = { type: 'dir', content: {} };
+ this.currentPath = [];
+ // Wipe all localStorage keys
+ ['webx11_filesystem', 'webx11_filesystem_v2'].forEach(k => localStorage.removeItem(k));
+ // P0: Also clear IndexedDB fallback
+ try { indexedDB.deleteDatabase('webx11_vfs'); } catch (e) {}
+ this._usingIndexedDB = false;
+ return true;
+ }
+}
+
+// Also expose for module system
+window.VirtualFileSystem = VirtualFileSystem;