mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
feat: add file browser panel and compact settings grid layout
- Add File Browser panel (right-side) showing project files in a tree view - API endpoints: /api/sessions/:id/files, /api/sessions/:id/file-content - Tree view with expand/collapse, filtering, file icons by type - File preview overlay for text, images, and video - Excludes common large directories (.git, node_modules, etc.) - Refactor App Settings with compact 2-column grid layout - Display tab: Header Displays, Panels, Subagent Options sections - Notifications tab: Master Control, Alerts, Notification Levels sections - Smaller switches (28x16px) with tooltips instead of form hints - ~35% vertical space reduction Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,7 @@ Claudeman is a Claude Code session manager with a web interface and autonomous R
|
|||||||
|
|
||||||
When adding new features, always ask: "Will this maintain responsiveness with 20 sessions and 50 agent windows?"
|
When adding new features, always ask: "Will this maintain responsiveness with 20 sessions and 50 agent windows?"
|
||||||
|
|
||||||
**Version**: 0.1392 (must match `package.json`)
|
**Version**: 0.1393 (must match `package.json`)
|
||||||
|
|
||||||
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty
|
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudeman",
|
"name": "claudeman",
|
||||||
"version": "0.1392",
|
"version": "0.1393",
|
||||||
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -2332,6 +2332,16 @@ class ClaudemanApp {
|
|||||||
// Update subagent window visibility for active session
|
// Update subagent window visibility for active session
|
||||||
this.updateSubagentWindowVisibility();
|
this.updateSubagentWindowVisibility();
|
||||||
|
|
||||||
|
// Load file browser if enabled
|
||||||
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
|
if (settings.showFileBrowser) {
|
||||||
|
const fileBrowserPanel = this.$('fileBrowserPanel');
|
||||||
|
if (fileBrowserPanel) {
|
||||||
|
fileBrowserPanel.classList.add('visible');
|
||||||
|
this.loadFileBrowser(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.terminal.focus();
|
this.terminal.focus();
|
||||||
this.terminal.scrollToBottom();
|
this.terminal.scrollToBottom();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -7074,6 +7084,313 @@ class ClaudemanApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== File Browser Panel ==========
|
||||||
|
|
||||||
|
// File tree data and state
|
||||||
|
fileBrowserData = null;
|
||||||
|
fileBrowserExpandedDirs = new Set();
|
||||||
|
fileBrowserFilter = '';
|
||||||
|
fileBrowserAllExpanded = false;
|
||||||
|
filePreviewContent = '';
|
||||||
|
|
||||||
|
async loadFileBrowser(sessionId) {
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
const treeEl = this.$('fileBrowserTree');
|
||||||
|
const statusEl = this.$('fileBrowserStatus');
|
||||||
|
if (!treeEl) return;
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
treeEl.innerHTML = '<div class="file-browser-loading">Loading files...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sessions/${sessionId}/files?depth=5&showHidden=false`);
|
||||||
|
if (!res.ok) throw new Error('Failed to load files');
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
if (!result.success) throw new Error(result.error || 'Failed to load files');
|
||||||
|
|
||||||
|
this.fileBrowserData = result.data;
|
||||||
|
this.renderFileBrowserTree();
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
if (statusEl) {
|
||||||
|
const { totalFiles, totalDirectories, truncated } = result.data;
|
||||||
|
statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load file browser:', err);
|
||||||
|
treeEl.innerHTML = `<div class="file-browser-empty">Failed to load files: ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFileBrowserTree() {
|
||||||
|
const treeEl = this.$('fileBrowserTree');
|
||||||
|
if (!treeEl || !this.fileBrowserData) return;
|
||||||
|
|
||||||
|
const { tree } = this.fileBrowserData;
|
||||||
|
if (!tree || tree.length === 0) {
|
||||||
|
treeEl.innerHTML = '<div class="file-browser-empty">No files found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = [];
|
||||||
|
const filter = this.fileBrowserFilter.toLowerCase();
|
||||||
|
|
||||||
|
const renderNode = (node, depth) => {
|
||||||
|
const isDir = node.type === 'directory';
|
||||||
|
const isExpanded = this.fileBrowserExpandedDirs.has(node.path);
|
||||||
|
const matchesFilter = !filter || node.name.toLowerCase().includes(filter);
|
||||||
|
|
||||||
|
// For directories, check if any children match
|
||||||
|
let hasMatchingChildren = false;
|
||||||
|
if (isDir && filter && node.children) {
|
||||||
|
hasMatchingChildren = this.hasMatchingChild(node, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldShow = matchesFilter || hasMatchingChildren;
|
||||||
|
const hiddenClass = !shouldShow && filter ? ' hidden-by-filter' : '';
|
||||||
|
|
||||||
|
const icon = isDir
|
||||||
|
? (isExpanded ? '\uD83D\uDCC2' : '\uD83D\uDCC1')
|
||||||
|
: this.getFileIcon(node.extension);
|
||||||
|
|
||||||
|
const expandIcon = isDir
|
||||||
|
? `<span class="file-tree-expand${isExpanded ? ' expanded' : ''}">\u25B6</span>`
|
||||||
|
: '<span class="file-tree-expand"></span>';
|
||||||
|
|
||||||
|
const sizeStr = !isDir && node.size !== undefined
|
||||||
|
? `<span class="file-tree-size">${this.formatFileSize(node.size)}</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
|
||||||
|
|
||||||
|
html.push(`
|
||||||
|
<div class="file-tree-item${hiddenClass}" data-path="${this.escapeHtml(node.path)}" data-type="${node.type}" data-depth="${depth}">
|
||||||
|
${expandIcon}
|
||||||
|
<span class="file-tree-icon">${icon}</span>
|
||||||
|
<span class="${nameClass}">${this.escapeHtml(node.name)}</span>
|
||||||
|
${sizeStr}
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Render children if directory is expanded
|
||||||
|
if (isDir && isExpanded && node.children) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
renderNode(child, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const node of tree) {
|
||||||
|
renderNode(node, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
treeEl.innerHTML = html.join('');
|
||||||
|
|
||||||
|
// Add click handlers
|
||||||
|
treeEl.querySelectorAll('.file-tree-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const path = item.dataset.path;
|
||||||
|
const type = item.dataset.type;
|
||||||
|
|
||||||
|
if (type === 'directory') {
|
||||||
|
this.toggleFileBrowserFolder(path);
|
||||||
|
} else {
|
||||||
|
this.openFilePreview(path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMatchingChild(node, filter) {
|
||||||
|
if (!node.children) return false;
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child.name.toLowerCase().includes(filter)) return true;
|
||||||
|
if (child.type === 'directory' && this.hasMatchingChild(child, filter)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFileBrowserFolder(path) {
|
||||||
|
if (this.fileBrowserExpandedDirs.has(path)) {
|
||||||
|
this.fileBrowserExpandedDirs.delete(path);
|
||||||
|
} else {
|
||||||
|
this.fileBrowserExpandedDirs.add(path);
|
||||||
|
}
|
||||||
|
this.renderFileBrowserTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
filterFileBrowser(value) {
|
||||||
|
this.fileBrowserFilter = value;
|
||||||
|
// Auto-expand all if filtering
|
||||||
|
if (value) {
|
||||||
|
this.expandAllDirectories(this.fileBrowserData?.tree || []);
|
||||||
|
}
|
||||||
|
this.renderFileBrowserTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
expandAllDirectories(nodes) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === 'directory') {
|
||||||
|
this.fileBrowserExpandedDirs.add(node.path);
|
||||||
|
if (node.children) {
|
||||||
|
this.expandAllDirectories(node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collapseAllDirectories() {
|
||||||
|
this.fileBrowserExpandedDirs.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFileBrowserExpand() {
|
||||||
|
this.fileBrowserAllExpanded = !this.fileBrowserAllExpanded;
|
||||||
|
const btn = this.$('fileBrowserExpandBtn');
|
||||||
|
|
||||||
|
if (this.fileBrowserAllExpanded) {
|
||||||
|
this.expandAllDirectories(this.fileBrowserData?.tree || []);
|
||||||
|
if (btn) btn.innerHTML = '\u229F'; // Collapse icon
|
||||||
|
} else {
|
||||||
|
this.collapseAllDirectories();
|
||||||
|
if (btn) btn.innerHTML = '\u229E'; // Expand icon
|
||||||
|
}
|
||||||
|
this.renderFileBrowserTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshFileBrowser() {
|
||||||
|
if (this.activeSessionId) {
|
||||||
|
this.fileBrowserExpandedDirs.clear();
|
||||||
|
this.fileBrowserFilter = '';
|
||||||
|
this.fileBrowserAllExpanded = false;
|
||||||
|
const searchInput = this.$('fileBrowserSearch');
|
||||||
|
if (searchInput) searchInput.value = '';
|
||||||
|
this.loadFileBrowser(this.activeSessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeFileBrowserPanel() {
|
||||||
|
const panel = this.$('fileBrowserPanel');
|
||||||
|
if (panel) {
|
||||||
|
panel.classList.remove('visible');
|
||||||
|
}
|
||||||
|
// Save setting
|
||||||
|
const settings = this.loadAppSettingsFromStorage();
|
||||||
|
settings.showFileBrowser = false;
|
||||||
|
localStorage.setItem('claudeman-app-settings', JSON.stringify(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
async openFilePreview(filePath) {
|
||||||
|
if (!this.activeSessionId || !filePath) return;
|
||||||
|
|
||||||
|
const overlay = this.$('filePreviewOverlay');
|
||||||
|
const titleEl = this.$('filePreviewTitle');
|
||||||
|
const bodyEl = this.$('filePreviewBody');
|
||||||
|
const footerEl = this.$('filePreviewFooter');
|
||||||
|
|
||||||
|
if (!overlay || !bodyEl) return;
|
||||||
|
|
||||||
|
// Show overlay with loading state
|
||||||
|
overlay.classList.add('visible');
|
||||||
|
titleEl.textContent = filePath;
|
||||||
|
bodyEl.innerHTML = '<div class="binary-message">Loading...</div>';
|
||||||
|
footerEl.textContent = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sessions/${this.activeSessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
|
||||||
|
if (!res.ok) throw new Error('Failed to load file');
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
if (!result.success) throw new Error(result.error || 'Failed to load file');
|
||||||
|
|
||||||
|
const data = result.data;
|
||||||
|
|
||||||
|
if (data.type === 'image') {
|
||||||
|
bodyEl.innerHTML = `<img src="${data.url}" alt="${this.escapeHtml(filePath)}">`;
|
||||||
|
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||||
|
} else if (data.type === 'video') {
|
||||||
|
bodyEl.innerHTML = `<video src="${data.url}" controls autoplay></video>`;
|
||||||
|
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||||
|
} else if (data.type === 'binary') {
|
||||||
|
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview</div>`;
|
||||||
|
footerEl.textContent = data.extension || 'binary';
|
||||||
|
} else {
|
||||||
|
// Text content
|
||||||
|
this.filePreviewContent = data.content;
|
||||||
|
bodyEl.innerHTML = `<pre><code>${this.escapeHtml(data.content)}</code></pre>`;
|
||||||
|
const truncNote = data.truncated ? ` (showing 500/${data.totalLines} lines)` : '';
|
||||||
|
footerEl.textContent = `${data.totalLines} lines \u2022 ${this.formatFileSize(data.size)}${truncNote}`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to preview file:', err);
|
||||||
|
bodyEl.innerHTML = `<div class="binary-message">Error: ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeFilePreview() {
|
||||||
|
const overlay = this.$('filePreviewOverlay');
|
||||||
|
if (overlay) {
|
||||||
|
overlay.classList.remove('visible');
|
||||||
|
}
|
||||||
|
this.filePreviewContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
copyFilePreviewContent() {
|
||||||
|
if (this.filePreviewContent) {
|
||||||
|
navigator.clipboard.writeText(this.filePreviewContent).then(() => {
|
||||||
|
this.showToast('Copied to clipboard', 'success');
|
||||||
|
}).catch(() => {
|
||||||
|
this.showToast('Failed to copy', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileIcon(ext) {
|
||||||
|
if (!ext) return '\uD83D\uDCC4'; // Default file
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
// TypeScript/JavaScript
|
||||||
|
'ts': '\uD83D\uDCD8', 'tsx': '\uD83D\uDCD8', 'js': '\uD83D\uDCD2', 'jsx': '\uD83D\uDCD2',
|
||||||
|
'mjs': '\uD83D\uDCD2', 'cjs': '\uD83D\uDCD2',
|
||||||
|
// Python
|
||||||
|
'py': '\uD83D\uDC0D', 'pyx': '\uD83D\uDC0D', 'pyw': '\uD83D\uDC0D',
|
||||||
|
// Rust/Go/C
|
||||||
|
'rs': '\uD83E\uDD80', 'go': '\uD83D\uDC39', 'c': '\u2699\uFE0F', 'cpp': '\u2699\uFE0F',
|
||||||
|
'h': '\u2699\uFE0F', 'hpp': '\u2699\uFE0F',
|
||||||
|
// Web
|
||||||
|
'html': '\uD83C\uDF10', 'htm': '\uD83C\uDF10', 'css': '\uD83C\uDFA8', 'scss': '\uD83C\uDFA8',
|
||||||
|
'sass': '\uD83C\uDFA8', 'less': '\uD83C\uDFA8',
|
||||||
|
// Data
|
||||||
|
'json': '\uD83D\uDCCB', 'yaml': '\uD83D\uDCCB', 'yml': '\uD83D\uDCCB', 'xml': '\uD83D\uDCCB',
|
||||||
|
'toml': '\uD83D\uDCCB', 'csv': '\uD83D\uDCCB',
|
||||||
|
// Docs
|
||||||
|
'md': '\uD83D\uDCDD', 'markdown': '\uD83D\uDCDD', 'txt': '\uD83D\uDCDD', 'rst': '\uD83D\uDCDD',
|
||||||
|
// Images
|
||||||
|
'png': '\uD83D\uDDBC\uFE0F', 'jpg': '\uD83D\uDDBC\uFE0F', 'jpeg': '\uD83D\uDDBC\uFE0F',
|
||||||
|
'gif': '\uD83D\uDDBC\uFE0F', 'svg': '\uD83D\uDDBC\uFE0F', 'webp': '\uD83D\uDDBC\uFE0F',
|
||||||
|
'ico': '\uD83D\uDDBC\uFE0F', 'bmp': '\uD83D\uDDBC\uFE0F',
|
||||||
|
// Video/Audio
|
||||||
|
'mp4': '\uD83C\uDFAC', 'webm': '\uD83C\uDFAC', 'mov': '\uD83C\uDFAC',
|
||||||
|
'mp3': '\uD83C\uDFB5', 'wav': '\uD83C\uDFB5', 'ogg': '\uD83C\uDFB5',
|
||||||
|
// Config/Shell
|
||||||
|
'sh': '\uD83D\uDCBB', 'bash': '\uD83D\uDCBB', 'zsh': '\uD83D\uDCBB',
|
||||||
|
'env': '\uD83D\uDD10', 'gitignore': '\uD83D\uDEAB', 'dockerfile': '\uD83D\uDC33',
|
||||||
|
// Lock files
|
||||||
|
'lock': '\uD83D\uDD12',
|
||||||
|
};
|
||||||
|
|
||||||
|
return icons[ext.toLowerCase()] || '\uD83D\uDCC4';
|
||||||
|
}
|
||||||
|
|
||||||
|
formatFileSize(bytes) {
|
||||||
|
if (bytes === undefined || bytes === null) return '';
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Log Viewer Windows (Floating File Streamers) ==========
|
// ========== Log Viewer Windows (Floating File Streamers) ==========
|
||||||
|
|
||||||
openLogViewerWindow(filePath, sessionId) {
|
openLogViewerWindow(filePath, sessionId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user