// ==========================================
// 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 =
'' +
'';
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);