Changeset 0.25.0 (#160)
This commit is contained in:
278
src/js/file-tree.js
Normal file
278
src/js/file-tree.js
Normal file
@@ -0,0 +1,278 @@
|
||||
// ==========================================
|
||||
// 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();
|
||||
|
||||
const FileTree = {
|
||||
_instances: new Map(),
|
||||
|
||||
create(opts) {
|
||||
const pfx = opts.id || 'editor';
|
||||
const instance = {
|
||||
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,
|
||||
_listeners: [],
|
||||
|
||||
// ── 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">' + _ftEsc(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">' + _ftEsc(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();
|
||||
});
|
||||
},
|
||||
|
||||
// ── Lifecycle ───────────────────────
|
||||
|
||||
destroy() {
|
||||
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
|
||||
this._listeners = [];
|
||||
this._treeData = [];
|
||||
this._expandedDirs = new Set(['']);
|
||||
this._gitStatusMap.clear();
|
||||
FileTree._instances.delete(this.id);
|
||||
},
|
||||
|
||||
_on(el, event, handler) {
|
||||
if (!el) return;
|
||||
el.addEventListener(event, handler);
|
||||
this._listeners.push({ el, event, handler });
|
||||
},
|
||||
};
|
||||
|
||||
FileTree._instances.set(pfx, instance);
|
||||
return instance;
|
||||
},
|
||||
|
||||
get(id) { return this._instances.get(id) || null; },
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
function _ftEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
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] || '📄';
|
||||
}
|
||||
Reference in New Issue
Block a user