// ========================================== // Chat Switchboard — FileTree Component // ========================================== // v0.25.0: Extracted from editor-mode.js (lines 336-535). // Workspace file browser with directory expand/collapse, git status // badges, context menu, and file icon mapping. // // Usage: // const tree = FileTree.create({ // id: 'editor', // workspaceId: 'ws-123', // onSelect: (path) => codeEditor.openFile(path), // onDelete: (path) => { ... }, // onNewFile: () => { ... }, // }); // await tree.refresh(); // tree.setActiveFile('src/main.go'); // tree.destroy(); // // Exports: window.FileTree const FileTree = { ...createComponentRegistry('FileTree'), create(opts) { const pfx = opts.id || 'editor'; const instance = componentMixin({ id: pfx, itemsEl: document.getElementById(pfx + 'TreeItems'), newFileEl: document.getElementById(pfx + 'TreeNewFile'), workspaceId: opts.workspaceId || null, onSelect: opts.onSelect || null, onDelete: opts.onDelete || null, onNewFile: opts.onNewFile || null, _treeData: [], _expandedDirs: new Set(['']), _gitStatusMap: new Map(), _activeFile: null, // ── Public API ────────────────────── setWorkspace(wsId) { this.workspaceId = wsId; }, async refresh() { if (!this.workspaceId) return; try { const resp = await API.listWorkspaceFiles(this.workspaceId, '', true); this._treeData = resp.files || resp.data || resp || []; this._render(); } catch (e) { console.error('[FileTree] Failed to load:', e); if (this.itemsEl) this.itemsEl.innerHTML = '
Failed to load files
'; } // Non-blocking git status refresh this._refreshGitStatus(); }, setActiveFile(path) { this._activeFile = path; if (!this.itemsEl) return; this.itemsEl.querySelectorAll('.file-tree-row').forEach(row => { row.classList.toggle('active', row.dataset.path === path); }); }, getActiveFile() { return this._activeFile; }, // ── Git Status ────────────────────── async _refreshGitStatus() { if (!this.workspaceId) return; this._gitStatusMap.clear(); try { const resp = await API.getWorkspaceGitStatus(this.workspaceId); const files = resp.files || []; for (const f of files) { this._gitStatusMap.set(f.path, f.status || '?'); } // Apply to rendered rows if (this.itemsEl) { this.itemsEl.querySelectorAll('.file-tree-row').forEach(row => { const p = row.dataset.path; const st = this._gitStatusMap.get(p); row.classList.remove('git-modified', 'git-added', 'git-untracked', 'git-deleted'); if (st === 'M') row.classList.add('git-modified'); else if (st === 'A') row.classList.add('git-added'); else if (st === '?') row.classList.add('git-untracked'); else if (st === 'D') row.classList.add('git-deleted'); }); } } catch (_) { // Git not configured — ignore } }, // ── Tree Rendering ────────────────── _render() { if (!this.itemsEl) return; this.itemsEl.innerHTML = ''; if (!this._treeData.length) { this.itemsEl.innerHTML = '
No files
'; return; } const tree = this._buildStructure(this._treeData); this._renderNodes(tree, this.itemsEl, 0); }, _buildStructure(files) { const root = { name: '', path: '', isDir: true, children: [], file: null }; const dirs = new Map(); dirs.set('', root); const sorted = [...files].sort((a, b) => { if (a.is_directory !== b.is_directory) return a.is_directory ? -1 : 1; return a.path.localeCompare(b.path); }); for (const f of sorted) { const parts = f.path.split('/'); const name = parts[parts.length - 1]; const parentPath = parts.slice(0, -1).join('/'); let parent = dirs.get(parentPath); if (!parent) { let accumulated = ''; for (let i = 0; i < parts.length - 1; i++) { const prev = accumulated; accumulated = accumulated ? accumulated + '/' + parts[i] : parts[i]; if (!dirs.has(accumulated)) { const dirNode = { name: parts[i], path: accumulated, isDir: true, children: [], file: null }; dirs.set(accumulated, dirNode); const p = dirs.get(prev); if (p) p.children.push(dirNode); } } parent = dirs.get(parentPath); } if (f.is_directory) { let existing = dirs.get(f.path); if (!existing) { existing = { name, path: f.path, isDir: true, children: [], file: f }; dirs.set(f.path, existing); if (parent) parent.children.push(existing); } else { existing.file = f; } } else { const node = { name, path: f.path, isDir: false, children: [], file: f }; if (parent) parent.children.push(node); } } return root.children; }, _renderNodes(nodes, container, depth) { const self = this; for (const node of nodes) { const row = document.createElement('div'); row.className = 'file-tree-row' + (node.path === this._activeFile ? ' active' : ''); row.style.paddingLeft = (12 + depth * 16) + 'px'; row.dataset.path = node.path; if (node.isDir) { const expanded = this._expandedDirs.has(node.path); row.innerHTML = '' + (expanded ? '▾' : '▸') + '' + '📁' + '' + esc(node.name) + ''; row.addEventListener('click', () => { if (self._expandedDirs.has(node.path)) { self._expandedDirs.delete(node.path); } else { self._expandedDirs.add(node.path); } self._render(); }); container.appendChild(row); if (expanded && node.children.length) { this._renderNodes(node.children, container, depth + 1); } } else { row.innerHTML = '' + _ftFileIcon(node.name) + '' + '' + esc(node.name) + ''; row.addEventListener('click', () => { if (self.onSelect) self.onSelect(node.path); }); row.addEventListener('contextmenu', (e) => { e.preventDefault(); self._showContextMenu(e, node); }); container.appendChild(row); } } }, // ── Context Menu ──────────────────── _showContextMenu(e, node) { const existing = document.querySelector('.file-tree-ctx-menu'); if (existing) existing.remove(); const menu = document.createElement('div'); menu.className = 'file-tree-ctx-menu'; menu.style.left = e.clientX + 'px'; menu.style.top = e.clientY + 'px'; const items = [ { label: 'Open', action: () => { if (this.onSelect) this.onSelect(node.path); } }, { label: 'Delete', action: () => { if (this.onDelete) this.onDelete(node.path); } }, ]; for (const item of items) { const row = document.createElement('div'); row.className = 'file-tree-ctx-item'; row.textContent = item.label; row.addEventListener('click', () => { menu.remove(); item.action(); }); menu.appendChild(row); } document.body.appendChild(menu); const dismiss = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', dismiss); } }; setTimeout(() => document.addEventListener('click', dismiss), 0); }, // ── Event Wiring ──────────────────── bind() { this._on(this.newFileEl, 'click', () => { if (this.onNewFile) this.onNewFile(); }); }, _cleanup() { this._treeData = []; this._expandedDirs = new Set(['']); this._gitStatusMap.clear(); }, }, FileTree); FileTree._register(pfx, instance); return instance; }, // v0.31.0: Self-mount — creates DOM + binds in one call. mount(container, opts) { const pfx = opts.id || 'ft'; const el = document.createElement('div'); el.className = 'file-tree'; el.id = pfx + 'FileTree'; el.innerHTML = '
' + '' + 'Files' + '' + '
' + '
'; container.appendChild(el); const instance = this.create(opts); instance.bind(); return instance; }, }; // ── Helpers ───────────────────────────────── function _ftFileIcon(name) { const ext = name.split('.').pop()?.toLowerCase(); const icons = { js: '📜', ts: '📜', jsx: '⚛', tsx: '⚛', go: '🔵', py: '🐍', rs: '🦀', rb: '💎', html: '🌐', css: '🎨', scss: '🎨', less: '🎨', json: '📋', yaml: '📋', yml: '📋', toml: '📋', md: '📝', txt: '📄', svg: '🖼', sql: '🗃', sh: '⚙', bash: '⚙', zsh: '⚙', dockerfile: '🐳', makefile: '🔧', mod: '📦', sum: '📦', lock: '🔒', }; return icons[ext] || '📄'; } // ── Exports ───────────────────────────────── sb.ns('FileTree', FileTree);