feat: Support multi-root workspaces

- Remove `base_path` from Workspace struct and APIs
- Enable adding multiple root directories to a workspace
- Update file tree UI to display all workspace roots
- Refactor file map generation for multi-root display
- Improve prompt output clipboard copy with status
This commit is contained in:
Mahmoud-Emad
2025-09-07 14:40:17 +03:00
parent 145c6d8714
commit 63c0b81fc9
7 changed files with 483 additions and 166 deletions

View File

@@ -252,7 +252,7 @@ class SimpleFileTree {
async loadChildren(parentPath) {
// Always reload children to ensure fresh data
console.log('Loading children for:', parentPath);
const r = await api(`/api/heroprompt/directory?name=${currentWs}&path=${encodeURIComponent(parentPath)}`);
const r = await api(`/api/heroprompt/directory?name=${currentWs}&base=${encodeURIComponent(parentPath)}&path=`);
if (r.error) {
console.warn('Failed to load directory:', parentPath, r.error);
@@ -395,6 +395,9 @@ class SimpleFileTree {
// Get file stats (mock data for now - could be enhanced with real file stats)
const stats = this.getFileStats(path);
// Show full path for directories to help differentiate between same-named directories
const displayPath = isDirectory ? path : path;
card.innerHTML = `
<div class="file-card-header">
<div class="file-card-icon">
@@ -402,7 +405,7 @@ class SimpleFileTree {
</div>
<div class="file-card-info">
<h4 class="file-card-name">${fileName}</h4>
<p class="file-card-path">${path}</p>
<p class="file-card-path">${displayPath}</p>
</div>
</div>
<div class="file-card-metadata">
@@ -825,6 +828,66 @@ class SimpleFileTree {
this.updateSelectionUI();
}
async renderWorkspaceDirectories(directories) {
this.container.innerHTML = '<div class="loading">Loading workspace directories...</div>';
// Reset state
this.loadedPaths.clear();
this.expandedDirs.clear();
expandedDirs.clear();
if (!directories || directories.length === 0) {
this.container.innerHTML = `
<div class="empty-state">
<i class="icon-folder-open"></i>
<p>No directories added yet</p>
<small>Use the "Add Dir" button to add directories to this workspace</small>
</div>
`;
return;
}
// Create document fragment for efficient DOM manipulation
const fragment = document.createDocumentFragment();
const elements = [];
// Create elements for each workspace directory
for (const dir of directories) {
if (!dir.path || dir.path.cat !== 'dir') continue;
const dirPath = dir.path.path;
const dirName = dir.name || dirPath.split('/').pop();
// Create a directory item that can be expanded
const item = {
name: dirName,
type: 'directory'
};
const element = this.createFileItem(item, dirPath, 0);
element.style.opacity = '0';
element.style.transform = 'translateY(-10px)';
element.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
fragment.appendChild(element);
elements.push(element);
}
// Clear container and add all elements at once
this.container.innerHTML = '';
this.container.appendChild(fragment);
// Trigger staggered animations
elements.forEach((element, i) => {
setTimeout(() => {
element.style.opacity = '1';
element.style.transform = 'translateY(0)';
}, i * 50);
});
this.updateSelectionUI();
}
}
// Global tree instance
@@ -886,10 +949,56 @@ async function initWorkspace() {
const sel = el('workspaceSelect');
if (sel) sel.value = currentWs;
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
const base = info?.base_path || '';
if (base && fileTree) {
await fileTree.render(base);
// Load and display workspace directories
await loadWorkspaceDirectories();
}
async function loadWorkspaceDirectories() {
const treeEl = el('tree');
if (!treeEl) return;
try {
const children = await api(`/api/heroprompt/workspaces/${currentWs}/children`);
if (children.error) {
console.warn('Failed to load workspace children:', children.error);
treeEl.innerHTML = `
<div class="empty-state">
<i class="icon-folder-open"></i>
<p>No directories added yet</p>
<small>Use the "Add Dir" button to add directories to this workspace</small>
</div>
`;
return;
}
// Filter only directories
const directories = children.filter(child => child.path && child.path.cat === 'dir');
if (directories.length === 0) {
treeEl.innerHTML = `
<div class="empty-state">
<i class="icon-folder-open"></i>
<p>No directories added yet</p>
<small>Use the "Add Dir" button to add directories to this workspace</small>
</div>
`;
return;
}
// Create file tree with workspace directories as roots
if (fileTree) {
await fileTree.renderWorkspaceDirectories(directories);
}
} catch (error) {
console.error('Error loading workspace directories:', error);
treeEl.innerHTML = `
<div class="empty-state">
<i class="icon-folder-open"></i>
<p>Error loading directories</p>
<small>Please try refreshing the page</small>
</div>
`;
}
}
@@ -952,41 +1061,64 @@ async function copyPrompt() {
const outputEl = el('promptOutput');
if (!outputEl) {
console.warn('Prompt output element not found');
showStatus('Copy failed - element not found', 'error');
return;
}
const text = outputEl.textContent;
console.log('text', text);
if (!text || text.trim().length === 0 || text.includes('No files selected') || text.includes('Generated prompt will appear here')) {
console.warn('No valid content to copy');
// Grab the visible prompt text, stripping HTML and empty-state placeholders
const text = outputEl.innerText.trim();
if (!text || text.includes('Generated prompt will appear here') || text.includes('No files selected')) {
showStatus('Nothing to copy', 'warning');
return;
}
if (!navigator.clipboard) {
// Fallback for older browsers
fallbackCopyToClipboard(text);
return;
// Try the modern Clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(text);
showStatus('Prompt copied to clipboard!', 'success');
return;
} catch (e) {
console.warn('Clipboard API failed, falling back', e);
}
}
// Fallback to hidden textarea method
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed'; // avoid scrolling to bottom
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
await navigator.clipboard.writeText(text);
// Show success feedback
const originalContent = outputEl.innerHTML;
outputEl.innerHTML = '<div class="success-message">Prompt copied to clipboard!</div>';
setTimeout(() => {
outputEl.innerHTML = originalContent;
}, 2000);
const successful = document.execCommand('copy');
showStatus(successful ? 'Prompt copied!' : 'Copy failed', successful ? 'success' : 'error');
} catch (e) {
console.warn('Copy failed', e);
const originalContent = outputEl.innerHTML;
outputEl.innerHTML = '<div class="error-message">Failed to copy prompt</div>';
setTimeout(() => {
outputEl.innerHTML = originalContent;
}, 2000);
console.error('Fallback copy failed', e);
showStatus('Copy failed', 'error');
} finally {
document.body.removeChild(textarea);
}
}
/* Helper show a transient message inside the output pane */
function showStatus(msg, type = 'info') {
const out = el('promptOutput');
if (!out) return;
const original = out.innerHTML;
const statusClass = type === 'success' ? 'success-message' :
type === 'error' ? 'error-message' :
type === 'warning' ? 'warning-message' : 'info-message';
out.innerHTML = `<div class="${statusClass}">${msg}</div>`;
setTimeout(() => {
out.innerHTML = original;
}, 2000);
}
// Global fallback function for clipboard operations
function fallbackCopyToClipboard(text) {
const textArea = document.createElement('textarea');
@@ -1049,11 +1181,9 @@ async function deleteWorkspace(workspaceName) {
currentWs = names[0];
localStorage.setItem('heroprompt-current-ws', currentWs);
await reloadWorkspaces();
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
const base = info?.base_path || '';
if (base && fileTree) {
await fileTree.render(base);
}
// Load directories for new current workspace
await loadWorkspaceDirectories();
}
}
@@ -1064,15 +1194,12 @@ async function deleteWorkspace(workspaceName) {
}
}
async function updateWorkspace(workspaceName, newName, newPath) {
async function updateWorkspace(workspaceName, newName) {
try {
const formData = new FormData();
if (newName && newName !== workspaceName) {
formData.append('name', newName);
}
if (newPath) {
formData.append('base_path', newPath);
}
const encodedName = encodeURIComponent(workspaceName);
const response = await fetch(`/api/heroprompt/workspaces/${encodedName}`, {
@@ -1095,12 +1222,6 @@ async function updateWorkspace(workspaceName, newName, newPath) {
}
await reloadWorkspaces();
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
const base = info?.base_path || '';
if (base && fileTree) {
await fileTree.render(base);
}
return result;
} catch (e) {
console.warn('Update workspace failed', e);
@@ -1135,11 +1256,9 @@ document.addEventListener('DOMContentLoaded', function () {
workspaceSelect.addEventListener('change', async (e) => {
currentWs = e.target.value;
localStorage.setItem('heroprompt-current-ws', currentWs);
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
const base = info?.base_path || '';
if (base && fileTree) {
await fileTree.render(base);
}
// Load directories for the new workspace
await loadWorkspaceDirectories();
});
}
@@ -1154,11 +1273,8 @@ document.addEventListener('DOMContentLoaded', function () {
const refreshExplorerBtn = el('refreshExplorer');
if (refreshExplorerBtn) {
refreshExplorerBtn.addEventListener('click', async () => {
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
const base = info?.base_path || '';
if (base && fileTree) {
await fileTree.render(base);
}
// Reload workspace directories
await loadWorkspaceDirectories();
});
}
@@ -1222,11 +1338,9 @@ document.addEventListener('DOMContentLoaded', function () {
if (wsCreateBtn) {
wsCreateBtn.addEventListener('click', () => {
const nameEl = el('wcName');
const pathEl = el('wcPath');
const errorEl = el('wcError');
if (nameEl) nameEl.value = '';
if (pathEl) pathEl.value = '';
if (errorEl) errorEl.textContent = '';
showModal('wsCreate');
@@ -1237,16 +1351,14 @@ document.addEventListener('DOMContentLoaded', function () {
if (wcCreateBtn) {
wcCreateBtn.addEventListener('click', async () => {
const name = el('wcName')?.value?.trim() || '';
const path = el('wcPath')?.value?.trim() || '';
const errorEl = el('wcError');
if (!path) {
if (errorEl) errorEl.textContent = 'Path is required.';
if (!name) {
if (errorEl) errorEl.textContent = 'Workspace name is required.';
return;
}
const formData = { base_path: path };
if (name) formData.name = name;
const formData = { name: name };
const resp = await post('/api/heroprompt/workspaces', formData);
if (resp.error) {
@@ -1258,10 +1370,18 @@ document.addEventListener('DOMContentLoaded', function () {
localStorage.setItem('heroprompt-current-ws', currentWs);
await reloadWorkspaces();
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
const base = info?.base_path || '';
if (base && fileTree) {
await fileTree.render(base);
// Clear the file tree since new workspace has no directories yet
if (fileTree) {
const treeEl = el('tree');
if (treeEl) {
treeEl.innerHTML = `
<div class="empty-state">
<i class="icon-folder-open"></i>
<p>No directories added yet</p>
<small>Use the "Add Dir" button to add directories to this workspace</small>
</div>
`;
}
}
hideModal('wsCreate');
@@ -1275,11 +1395,9 @@ document.addEventListener('DOMContentLoaded', function () {
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
if (info && !info.error) {
const nameEl = el('wdName');
const pathEl = el('wdPath');
const errorEl = el('wdError');
if (nameEl) nameEl.value = info.name || currentWs;
if (pathEl) pathEl.value = info.base_path || '';
if (errorEl) errorEl.textContent = '';
showModal('wsDetails');
@@ -1292,15 +1410,14 @@ document.addEventListener('DOMContentLoaded', function () {
if (wdUpdateBtn) {
wdUpdateBtn.addEventListener('click', async () => {
const name = el('wdName')?.value?.trim() || '';
const path = el('wdPath')?.value?.trim() || '';
const errorEl = el('wdError');
if (!path) {
if (errorEl) errorEl.textContent = 'Path is required.';
if (!name) {
if (errorEl) errorEl.textContent = 'Workspace name is required.';
return;
}
const result = await updateWorkspace(currentWs, name, path);
const result = await updateWorkspace(currentWs, name);
if (result.error) {
if (errorEl) errorEl.textContent = result.error;
return;
@@ -1326,6 +1443,52 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
// Add Directory functionality
const addDirBtn = el('addDirBtn');
if (addDirBtn) {
addDirBtn.addEventListener('click', () => {
const pathEl = el('addDirPath');
const errorEl = el('addDirError');
if (pathEl) pathEl.value = '';
if (errorEl) errorEl.textContent = '';
showModal('addDirModal');
});
}
const addDirConfirm = el('addDirConfirm');
if (addDirConfirm) {
addDirConfirm.addEventListener('click', async () => {
const path = el('addDirPath')?.value?.trim() || '';
const errorEl = el('addDirError');
if (!path) {
if (errorEl) errorEl.textContent = 'Directory path is required.';
return;
}
// Add directory via API
const result = await post(`/api/heroprompt/workspaces/${encodeURIComponent(currentWs)}/dirs`, {
path: path
});
if (result.error) {
if (errorEl) errorEl.textContent = result.error;
return;
}
// Success - close modal and refresh the file tree
hideModal('addDirModal');
// Reload workspace directories to show the newly added directory
await loadWorkspaceDirectories();
// Show success message
showStatus('Directory added successfully!', 'success');
});
}
// Chat functionality
initChatInterface();
});