Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
291 lines
13 KiB
JavaScript
291 lines
13 KiB
JavaScript
// ==========================================
|
|
// 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 = '<div class="file-tree-error">Failed to load files</div>';
|
|
}
|
|
// 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 = '<div class="file-tree-empty">No files</div>';
|
|
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 =
|
|
'<span class="file-tree-arrow ' + (expanded ? 'expanded' : '') + '">' + (expanded ? '▾' : '▸') + '</span>' +
|
|
'<span class="file-tree-icon">📁</span>' +
|
|
'<span class="file-tree-name">' + esc(node.name) + '</span>';
|
|
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 =
|
|
'<span class="file-tree-icon">' + _ftFileIcon(node.name) + '</span>' +
|
|
'<span class="file-tree-name">' + esc(node.name) + '</span>';
|
|
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 =
|
|
'<div class="file-tree-header" id="' + pfx + 'TreeHeader">' +
|
|
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>' +
|
|
'<span class="file-tree-title">Files</span>' +
|
|
'<button class="icon-btn" id="' + pfx + 'TreeNewFile" title="New file">' +
|
|
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
|
|
'</button>' +
|
|
'</div>' +
|
|
'<div class="file-tree-items" id="' + pfx + 'TreeItems"></div>';
|
|
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);
|