feat: add workspace management and file preview
- Add workspace update and delete API endpoints - Redesign selected files display to use interactive cards - Implement VS Code-style modal for file content preview - Enhance file tree with animations and local state - Update UI styles for explorer, forms, and modals
This commit is contained in:
@@ -30,7 +30,7 @@ pub fn (app &App) api_heroprompt_list(mut ctx Context) veb.Result {
|
||||
|
||||
@['/api/heroprompt/workspaces'; post]
|
||||
pub fn (app &App) api_heroprompt_create(mut ctx Context) veb.Result {
|
||||
name := ctx.form['name'] or { 'default' }
|
||||
name_input := ctx.form['name'] or { '' }
|
||||
base_path_in := ctx.form['base_path'] or { '' }
|
||||
if base_path_in.len == 0 {
|
||||
return ctx.text('{"error":"base_path required"}')
|
||||
@@ -40,13 +40,20 @@ pub fn (app &App) api_heroprompt_create(mut ctx Context) veb.Result {
|
||||
home := os.home_dir()
|
||||
base_path = os.join_path(home, base_path.all_after('~'))
|
||||
}
|
||||
_ := hp.get(name: name, create: true, path: base_path) or {
|
||||
|
||||
// If no name provided, generate a random name
|
||||
mut name := name_input.trim(' \t\n\r')
|
||||
if name.len == 0 {
|
||||
name = hp.generate_random_workspace_name()
|
||||
}
|
||||
|
||||
wsp := hp.get(name: name, create: true, path: base_path) or {
|
||||
return ctx.text('{"error":"create failed"}')
|
||||
}
|
||||
ctx.set_content_type('application/json')
|
||||
return ctx.text(json.encode({
|
||||
'name': name
|
||||
'base_path': base_path
|
||||
'name': wsp.name
|
||||
'base_path': wsp.base_path
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -63,6 +70,61 @@ pub fn (app &App) api_heroprompt_get(mut ctx Context, name string) veb.Result {
|
||||
}))
|
||||
}
|
||||
|
||||
@['/api/heroprompt/workspaces/:name'; put]
|
||||
pub fn (app &App) api_heroprompt_update(mut ctx Context, name string) veb.Result {
|
||||
wsp := hp.get(name: name, create: false) or {
|
||||
return ctx.text('{"error":"workspace not found"}')
|
||||
}
|
||||
|
||||
new_name := ctx.form['name'] or { name }
|
||||
new_base_path_in := ctx.form['base_path'] or { wsp.base_path }
|
||||
|
||||
mut new_base_path := new_base_path_in
|
||||
if new_base_path.starts_with('~') {
|
||||
home := os.home_dir()
|
||||
new_base_path = os.join_path(home, new_base_path.all_after('~'))
|
||||
}
|
||||
|
||||
// Update the workspace using the update_workspace method
|
||||
updated_wsp := wsp.update_workspace(
|
||||
name: new_name
|
||||
base_path: new_base_path
|
||||
) or { return ctx.text('{"error":"failed to update workspace"}') }
|
||||
|
||||
ctx.set_content_type('application/json')
|
||||
return ctx.text(json.encode({
|
||||
'name': updated_wsp.name
|
||||
'base_path': updated_wsp.base_path
|
||||
}))
|
||||
}
|
||||
|
||||
@['/api/heroprompt/workspaces/:name'; delete]
|
||||
pub fn (app &App) api_heroprompt_delete(mut ctx Context, name string) veb.Result {
|
||||
wsp := hp.get(name: name, create: false) or {
|
||||
return ctx.text('{"error":"workspace not found"}')
|
||||
}
|
||||
|
||||
// Delete the workspace
|
||||
wsp.delete_workspace() or { return ctx.text('{"error":"failed to delete workspace"}') }
|
||||
|
||||
ctx.set_content_type('application/json')
|
||||
return ctx.text('{"ok":true}')
|
||||
}
|
||||
|
||||
// Alternative delete endpoint using POST with action parameter
|
||||
@['/api/heroprompt/workspaces/:name/delete'; post]
|
||||
pub fn (app &App) api_heroprompt_delete_alt(mut ctx Context, name string) veb.Result {
|
||||
wsp := hp.get(name: name, create: false) or {
|
||||
return ctx.text('{"error":"workspace not found"}')
|
||||
}
|
||||
|
||||
// Delete the workspace
|
||||
wsp.delete_workspace() or { return ctx.text('{"error":"failed to delete workspace"}') }
|
||||
|
||||
ctx.set_content_type('application/json')
|
||||
return ctx.text('{"ok":true}')
|
||||
}
|
||||
|
||||
@['/api/heroprompt/directory'; get]
|
||||
pub fn (app &App) api_heroprompt_directory(mut ctx Context) veb.Result {
|
||||
wsname := ctx.query['name'] or { 'default' }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -78,29 +78,33 @@ function switchTab(tabName) {
|
||||
});
|
||||
}
|
||||
|
||||
// Simple and clean file tree implementation
|
||||
// Enhanced file tree implementation with better spacing and reliability
|
||||
class SimpleFileTree {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.loadedPaths = new Set();
|
||||
this.expandedDirs = new Set(); // Track expanded state locally
|
||||
}
|
||||
|
||||
createFileItem(item, path, depth = 0) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'tree-item';
|
||||
div.style.paddingLeft = `${depth * 16}px`;
|
||||
div.dataset.path = path;
|
||||
div.dataset.type = item.type;
|
||||
div.dataset.depth = depth;
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'tree-item-content';
|
||||
// Use consistent 20px indentation per level
|
||||
content.style.paddingLeft = `${depth * 20 + 8}px`;
|
||||
|
||||
// Checkbox
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.className = 'tree-checkbox';
|
||||
checkbox.checked = selected.has(path);
|
||||
checkbox.addEventListener('change', () => {
|
||||
checkbox.addEventListener('change', (e) => {
|
||||
e.stopPropagation();
|
||||
if (checkbox.checked) {
|
||||
selected.add(path);
|
||||
} else {
|
||||
@@ -114,10 +118,10 @@ class SimpleFileTree {
|
||||
if (item.type === 'directory') {
|
||||
expandBtn = document.createElement('button');
|
||||
expandBtn.className = 'tree-expand-btn';
|
||||
expandBtn.innerHTML = expandedDirs.has(path) ? '▼' : '▶';
|
||||
expandBtn.innerHTML = this.expandedDirs.has(path) ? '▼' : '▶';
|
||||
expandBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleDirectory(path, expandBtn);
|
||||
this.toggleDirectory(path);
|
||||
});
|
||||
} else {
|
||||
// Spacer for files to align with directories
|
||||
@@ -128,17 +132,19 @@ class SimpleFileTree {
|
||||
// Icon
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'tree-icon';
|
||||
icon.textContent = item.type === 'directory' ? '📁' : '📄';
|
||||
icon.textContent = item.type === 'directory' ?
|
||||
(this.expandedDirs.has(path) ? '📂' : '<27>') : '📄';
|
||||
|
||||
// Label
|
||||
const label = document.createElement('span');
|
||||
label.className = 'tree-label';
|
||||
label.textContent = item.name;
|
||||
label.addEventListener('click', () => {
|
||||
label.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (item.type === 'file') {
|
||||
this.previewFile(path);
|
||||
} else {
|
||||
this.toggleDirectory(path, expandBtn);
|
||||
this.toggleDirectory(path);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -151,37 +157,54 @@ class SimpleFileTree {
|
||||
return div;
|
||||
}
|
||||
|
||||
async toggleDirectory(dirPath, expandBtn) {
|
||||
const isExpanded = expandedDirs.has(dirPath);
|
||||
async toggleDirectory(dirPath) {
|
||||
const isExpanded = this.expandedDirs.has(dirPath);
|
||||
const dirElement = qs(`[data-path="${dirPath}"]`);
|
||||
const expandBtn = dirElement?.querySelector('.tree-expand-btn');
|
||||
const icon = dirElement?.querySelector('.tree-icon');
|
||||
|
||||
if (isExpanded) {
|
||||
// Collapse
|
||||
expandedDirs.delete(dirPath);
|
||||
expandBtn.innerHTML = '▶';
|
||||
this.expandedDirs.delete(dirPath);
|
||||
if (expandBtn) expandBtn.innerHTML = '▶';
|
||||
if (icon) icon.textContent = '📁';
|
||||
this.removeChildren(dirPath);
|
||||
// Remove from loaded paths so it can be reloaded when expanded again
|
||||
this.loadedPaths.delete(dirPath);
|
||||
} else {
|
||||
// Expand
|
||||
expandedDirs.add(dirPath);
|
||||
expandBtn.innerHTML = '▼';
|
||||
this.expandedDirs.add(dirPath);
|
||||
if (expandBtn) expandBtn.innerHTML = '▼';
|
||||
if (icon) icon.textContent = '📂';
|
||||
await this.loadChildren(dirPath);
|
||||
}
|
||||
}
|
||||
|
||||
removeChildren(parentPath) {
|
||||
const items = qsa('.tree-item');
|
||||
const toRemove = [];
|
||||
|
||||
items.forEach(item => {
|
||||
const itemPath = item.dataset.path;
|
||||
if (itemPath !== parentPath && itemPath.startsWith(parentPath + '/')) {
|
||||
item.remove();
|
||||
toRemove.push(item);
|
||||
// Also remove from expanded dirs if it was expanded
|
||||
this.expandedDirs.delete(itemPath);
|
||||
this.loadedPaths.delete(itemPath);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove elements with animation
|
||||
toRemove.forEach(item => {
|
||||
item.style.transition = 'opacity 0.2s ease, max-height 0.2s ease';
|
||||
item.style.opacity = '0';
|
||||
item.style.maxHeight = '0';
|
||||
setTimeout(() => item.remove(), 200);
|
||||
});
|
||||
}
|
||||
|
||||
async loadChildren(parentPath) {
|
||||
if (this.loadedPaths.has(parentPath)) {
|
||||
return; // Already loaded
|
||||
}
|
||||
|
||||
// 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)}`);
|
||||
|
||||
@@ -190,8 +213,6 @@ class SimpleFileTree {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('API response for', parentPath, ':', r);
|
||||
|
||||
// Sort items: directories first, then files
|
||||
const items = (r.items || []).sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
@@ -200,8 +221,6 @@ class SimpleFileTree {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
console.log('Sorted items:', items);
|
||||
|
||||
// Find the parent element
|
||||
const parentElement = qs(`[data-path="${parentPath}"]`);
|
||||
if (!parentElement) {
|
||||
@@ -209,30 +228,41 @@ class SimpleFileTree {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentDepth = this.getDepth(parentPath);
|
||||
console.log('Parent depth:', parentDepth);
|
||||
const parentDepth = parseInt(parentElement.dataset.depth || '0');
|
||||
|
||||
// Insert children after parent
|
||||
// Insert children after parent with animation
|
||||
let insertAfter = parentElement;
|
||||
for (const item of items) {
|
||||
const childPath = parentPath.endsWith('/') ?
|
||||
parentPath + item.name :
|
||||
parentPath + '/' + item.name;
|
||||
|
||||
console.log('Creating child:', item.name, 'at path:', childPath, 'depth:', parentDepth + 1);
|
||||
const childElement = this.createFileItem(item, childPath, parentDepth + 1);
|
||||
|
||||
// Add animation
|
||||
childElement.style.opacity = '0';
|
||||
childElement.style.maxHeight = '0';
|
||||
childElement.style.transition = 'opacity 0.2s ease, max-height 0.2s ease';
|
||||
|
||||
insertAfter.insertAdjacentElement('afterend', childElement);
|
||||
|
||||
// Trigger animation
|
||||
setTimeout(() => {
|
||||
childElement.style.opacity = '1';
|
||||
childElement.style.maxHeight = '30px';
|
||||
}, 10);
|
||||
|
||||
insertAfter = childElement;
|
||||
}
|
||||
|
||||
this.loadedPaths.add(parentPath);
|
||||
console.log('Finished loading children for:', parentPath);
|
||||
}
|
||||
|
||||
getDepth(path) {
|
||||
const depth = (path.match(/\//g) || []).length;
|
||||
console.log('Depth for path', path, ':', depth);
|
||||
return depth;
|
||||
// Calculate depth based on forward slashes, but handle root paths better
|
||||
if (!path || path === '/') return 0;
|
||||
const cleanPath = path.replace(/^\/+|\/+$/g, ''); // Remove leading/trailing slashes
|
||||
return cleanPath ? cleanPath.split('/').length - 1 : 0;
|
||||
}
|
||||
|
||||
async previewFile(filePath) {
|
||||
@@ -255,44 +285,29 @@ class SimpleFileTree {
|
||||
const selCountEl = el('selCount');
|
||||
const selCountTabEl = el('selCountTab');
|
||||
const tokenCountEl = el('tokenCount');
|
||||
const selectedEl = el('selected');
|
||||
const selectedCardsEl = el('selectedCards');
|
||||
|
||||
const count = selected.size;
|
||||
|
||||
if (selCountEl) selCountEl.textContent = count.toString();
|
||||
if (selCountTabEl) selCountTabEl.textContent = count.toString();
|
||||
|
||||
// Update selection list
|
||||
if (selectedEl) {
|
||||
selectedEl.innerHTML = '';
|
||||
// Update selection cards
|
||||
if (selectedCardsEl) {
|
||||
selectedCardsEl.innerHTML = '';
|
||||
|
||||
if (count === 0) {
|
||||
selectedEl.innerHTML = `
|
||||
<li class="empty-selection">
|
||||
selectedCardsEl.innerHTML = `
|
||||
<div class="empty-selection-cards">
|
||||
<i class="icon-empty"></i>
|
||||
<p>No files selected</p>
|
||||
<small>Use checkboxes in the explorer to select files</small>
|
||||
</li>
|
||||
<small>Use checkboxes in the explorer to select files and directories</small>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
Array.from(selected).forEach(path => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'selected-item';
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.className = 'item-path';
|
||||
span.textContent = path;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn btn-xs btn-ghost';
|
||||
btn.innerHTML = '<i class="icon-close"></i>';
|
||||
btn.onclick = () => {
|
||||
this.removeFromSelection(path);
|
||||
};
|
||||
|
||||
li.appendChild(span);
|
||||
li.appendChild(btn);
|
||||
selectedEl.appendChild(li);
|
||||
const card = this.createFileCard(path);
|
||||
selectedCardsEl.appendChild(card);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -303,6 +318,249 @@ class SimpleFileTree {
|
||||
if (tokenCountEl) tokenCountEl.textContent = tokens.toString();
|
||||
}
|
||||
|
||||
createFileCard(path) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'file-card';
|
||||
|
||||
// Get file info
|
||||
const fileName = path.split('/').pop();
|
||||
const extension = this.getFileExtension(fileName);
|
||||
const isDirectory = this.isDirectory(path);
|
||||
|
||||
card.dataset.type = isDirectory ? 'directory' : 'file';
|
||||
if (extension) {
|
||||
card.dataset.extension = extension;
|
||||
}
|
||||
|
||||
// Get file stats (mock data for now - could be enhanced with real file stats)
|
||||
const stats = this.getFileStats(path);
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="file-card-header">
|
||||
<div class="file-card-icon">
|
||||
${isDirectory ? '📁' : this.getFileIcon(extension)}
|
||||
</div>
|
||||
<div class="file-card-info">
|
||||
<h4 class="file-card-name">${fileName}</h4>
|
||||
<p class="file-card-path">${path}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-card-metadata">
|
||||
<div class="metadata-item">
|
||||
<span class="icon">📄</span>
|
||||
<span>${isDirectory ? 'Directory' : 'File'}</span>
|
||||
</div>
|
||||
${extension ? `
|
||||
<div class="metadata-item">
|
||||
<span class="icon">🏷️</span>
|
||||
<span>${extension.toUpperCase()}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="metadata-item">
|
||||
<span class="icon">📏</span>
|
||||
<span>${stats.size}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="icon">📅</span>
|
||||
<span>${stats.modified}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-card-actions">
|
||||
${!isDirectory ? `
|
||||
<button class="card-btn card-btn-primary" onclick="fileTree.previewFileInModal('${path}')">
|
||||
<i class="icon-file"></i>
|
||||
Preview
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="card-btn card-btn-danger" onclick="fileTree.removeFromSelection('${path}')">
|
||||
<i class="icon-close"></i>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
getFileExtension(filename) {
|
||||
const parts = filename.split('.');
|
||||
return parts.length > 1 ? parts.pop().toLowerCase() : '';
|
||||
}
|
||||
|
||||
getFileIcon(extension) {
|
||||
const iconMap = {
|
||||
'js': '📜',
|
||||
'ts': '📜',
|
||||
'html': '🌐',
|
||||
'css': '🎨',
|
||||
'json': '📋',
|
||||
'md': '📝',
|
||||
'txt': '📄',
|
||||
'v': '⚡',
|
||||
'go': '🐹',
|
||||
'py': '🐍',
|
||||
'java': '☕',
|
||||
'cpp': '⚙️',
|
||||
'c': '⚙️',
|
||||
'rs': '🦀',
|
||||
'php': '🐘',
|
||||
'rb': '💎',
|
||||
'sh': '🐚',
|
||||
'yml': '📄',
|
||||
'yaml': '📄',
|
||||
'xml': '📄',
|
||||
'svg': '🖼️',
|
||||
'png': '🖼️',
|
||||
'jpg': '🖼️',
|
||||
'jpeg': '🖼️',
|
||||
'gif': '🖼️',
|
||||
'pdf': '📕',
|
||||
'zip': '📦',
|
||||
'tar': '📦',
|
||||
'gz': '📦'
|
||||
};
|
||||
return iconMap[extension] || '📄';
|
||||
}
|
||||
|
||||
isDirectory(path) {
|
||||
// Check if path corresponds to a directory in the tree
|
||||
const treeItem = qs(`[data-path="${path}"]`);
|
||||
return treeItem && treeItem.dataset.type === 'directory';
|
||||
}
|
||||
|
||||
getFileStats(path) {
|
||||
// Mock file stats - in a real implementation, this would come from the API
|
||||
return {
|
||||
size: this.formatFileSize(Math.floor(Math.random() * 100000) + 1000),
|
||||
modified: this.formatDate(new Date(Date.now() - Math.floor(Math.random() * 30) * 24 * 60 * 60 * 1000))
|
||||
};
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
formatDate(date) {
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now - date);
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
if (diffDays < 30) return `${Math.ceil(diffDays / 7)} weeks ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
async previewFileInModal(filePath) {
|
||||
// Create and show modal for file preview
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal fade file-preview-modal';
|
||||
modal.id = 'filePreviewModal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content modal-content-dark">
|
||||
<div class="modal-header modal-header-dark">
|
||||
<div class="modal-title-container">
|
||||
<h5 class="modal-title">${this.getFileIcon(this.getFileExtension(filePath.split('/').pop()))} ${filePath.split('/').pop()}</h5>
|
||||
<span class="modal-subtitle">${filePath}</span>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-dark" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body modal-body-dark">
|
||||
<div id="modalPreviewContent" class="code-preview-container">
|
||||
<div class="loading">Loading file content...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer modal-footer-dark">
|
||||
<div class="file-info">
|
||||
<span id="fileStats" class="file-stats"></span>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary btn-dark" data-bs-dismiss="modal">
|
||||
<i class="icon-close"></i> Close
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="fileTree.copyModalContent()">
|
||||
<i class="icon-copy"></i> Copy Content
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
const bootstrapModal = new bootstrap.Modal(modal);
|
||||
bootstrapModal.show();
|
||||
|
||||
// Load file content
|
||||
const r = await api(`/api/heroprompt/file?name=${currentWs}&path=${encodeURIComponent(filePath)}`);
|
||||
const contentEl = el('modalPreviewContent');
|
||||
|
||||
if (r.error) {
|
||||
contentEl.innerHTML = `<div class="error-message">Error: ${r.error}</div>`;
|
||||
} else {
|
||||
const content = r.content || 'No content';
|
||||
this.renderCodePreview(contentEl, content, filePath);
|
||||
|
||||
// Update file stats
|
||||
const statsEl = el('fileStats');
|
||||
if (statsEl) {
|
||||
const lines = content.split('\n').length;
|
||||
const chars = content.length;
|
||||
const words = content.split(/\s+/).filter(w => w.length > 0).length;
|
||||
statsEl.textContent = `${lines} lines, ${words} words, ${chars} characters`;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up modal when closed
|
||||
modal.addEventListener('hidden.bs.modal', () => {
|
||||
modal.remove();
|
||||
});
|
||||
}
|
||||
|
||||
renderCodePreview(container, content, filePath) {
|
||||
const lines = content.split('\n');
|
||||
const extension = this.getFileExtension(filePath.split('/').pop());
|
||||
|
||||
// Create the code preview structure
|
||||
container.innerHTML = `
|
||||
<div class="code-editor">
|
||||
<div class="line-numbers">
|
||||
${lines.map((_, index) => `<div class="line-number">${index + 1}</div>`).join('')}
|
||||
</div>
|
||||
<div class="code-content">
|
||||
<pre class="code-text" data-language="${extension}"><code>${this.escapeHtml(content)}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
copyModalContent() {
|
||||
const contentEl = el('modalPreviewContent');
|
||||
if (contentEl) {
|
||||
navigator.clipboard.writeText(contentEl.textContent).then(() => {
|
||||
// Show success feedback
|
||||
const originalContent = contentEl.innerHTML;
|
||||
contentEl.innerHTML = '<div class="success-message">Content copied to clipboard!</div>';
|
||||
setTimeout(() => {
|
||||
contentEl.innerHTML = originalContent;
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy content:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
removeFromSelection(path) {
|
||||
selected.delete(path);
|
||||
|
||||
@@ -371,6 +629,11 @@ class SimpleFileTree {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset state
|
||||
this.loadedPaths.clear();
|
||||
this.expandedDirs.clear();
|
||||
expandedDirs.clear();
|
||||
|
||||
// Sort items: directories first, then files
|
||||
const items = (r.items || []).sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
@@ -379,13 +642,25 @@ class SimpleFileTree {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
for (const item of items) {
|
||||
// Add items with staggered animation
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const fullPath = workspacePath.endsWith('/') ?
|
||||
workspacePath + item.name :
|
||||
workspacePath + '/' + item.name;
|
||||
|
||||
const element = this.createFileItem(item, fullPath, 0);
|
||||
element.style.opacity = '0';
|
||||
element.style.transform = 'translateY(-10px)';
|
||||
element.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
|
||||
|
||||
this.container.appendChild(element);
|
||||
|
||||
// Staggered animation
|
||||
setTimeout(() => {
|
||||
element.style.opacity = '1';
|
||||
element.style.transform = 'translateY(0)';
|
||||
}, i * 50);
|
||||
}
|
||||
|
||||
this.updateSelectionUI();
|
||||
@@ -514,6 +789,106 @@ async function copyPrompt() {
|
||||
}
|
||||
}
|
||||
|
||||
// Confirmation modal helper
|
||||
function showConfirmationModal(message, onConfirm) {
|
||||
const messageEl = el('confirmDeleteMessage');
|
||||
const confirmBtn = el('confirmDeleteBtn');
|
||||
|
||||
if (messageEl) messageEl.textContent = message;
|
||||
|
||||
// Remove any existing event listeners
|
||||
const newConfirmBtn = confirmBtn.cloneNode(true);
|
||||
confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn);
|
||||
|
||||
// Add new event listener
|
||||
newConfirmBtn.addEventListener('click', () => {
|
||||
hideModal('confirmDeleteModal');
|
||||
onConfirm();
|
||||
});
|
||||
|
||||
showModal('confirmDeleteModal');
|
||||
}
|
||||
|
||||
// Workspace management functions
|
||||
async function deleteWorkspace(workspaceName) {
|
||||
try {
|
||||
const encodedName = encodeURIComponent(workspaceName);
|
||||
const response = await fetch(`/api/heroprompt/workspaces/${encodedName}/delete`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Delete failed:', response.status, errorText);
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
// If we deleted the current workspace, switch to another one
|
||||
if (workspaceName === currentWs) {
|
||||
const names = await api('/api/heroprompt/workspaces');
|
||||
if (names && Array.isArray(names) && names.length > 0) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
console.warn('Delete workspace failed', e);
|
||||
return { error: 'Failed to delete workspace' };
|
||||
}
|
||||
}
|
||||
|
||||
async function updateWorkspace(workspaceName, newName, newPath) {
|
||||
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}`, {
|
||||
method: 'PUT',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Update failed:', response.status, errorText);
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Update current workspace if it was renamed
|
||||
if (workspaceName === currentWs && result.name && result.name !== workspaceName) {
|
||||
currentWs = result.name;
|
||||
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);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.warn('Update workspace failed', e);
|
||||
return { error: 'Failed to update workspace' };
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize everything when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Initialize file tree
|
||||
@@ -693,53 +1068,42 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
});
|
||||
}
|
||||
|
||||
// Workspace management modal
|
||||
const openWsManageBtn = el('openWsManage');
|
||||
if (openWsManageBtn) {
|
||||
openWsManageBtn.addEventListener('click', async () => {
|
||||
const list = el('wmList');
|
||||
const err = el('wmError');
|
||||
if (!list) return;
|
||||
// Workspace details update
|
||||
const wdUpdateBtn = el('wdUpdate');
|
||||
if (wdUpdateBtn) {
|
||||
wdUpdateBtn.addEventListener('click', async () => {
|
||||
const name = el('wdName')?.value?.trim() || '';
|
||||
const path = el('wdPath')?.value?.trim() || '';
|
||||
const errorEl = el('wdError');
|
||||
|
||||
if (err) err.textContent = '';
|
||||
list.innerHTML = '<div class="loading">Loading workspaces...</div>';
|
||||
|
||||
const names = await api('/api/heroprompt/workspaces');
|
||||
list.innerHTML = '';
|
||||
|
||||
if (names.error || !Array.isArray(names)) {
|
||||
list.innerHTML = '<div class="error-message">Failed to load workspaces</div>';
|
||||
if (!path) {
|
||||
if (errorEl) errorEl.textContent = 'Path is required.';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const n of names) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.textContent = n;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn btn-sm btn-primary';
|
||||
btn.textContent = 'Use';
|
||||
btn.onclick = async () => {
|
||||
currentWs = n;
|
||||
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);
|
||||
}
|
||||
hideModal('wsManage');
|
||||
};
|
||||
|
||||
item.appendChild(span);
|
||||
item.appendChild(btn);
|
||||
list.appendChild(item);
|
||||
const result = await updateWorkspace(currentWs, name, path);
|
||||
if (result.error) {
|
||||
if (errorEl) errorEl.textContent = result.error;
|
||||
return;
|
||||
}
|
||||
|
||||
showModal('wsManage');
|
||||
hideModal('wsDetails');
|
||||
});
|
||||
}
|
||||
|
||||
// Workspace details delete
|
||||
const wdDeleteBtn = el('wdDelete');
|
||||
if (wdDeleteBtn) {
|
||||
wdDeleteBtn.addEventListener('click', async () => {
|
||||
showConfirmationModal(`Are you sure you want to delete workspace "${currentWs}"?`, async () => {
|
||||
const result = await deleteWorkspace(currentWs);
|
||||
if (result.error) {
|
||||
const errorEl = el('wdError');
|
||||
if (errorEl) errorEl.textContent = result.error;
|
||||
return;
|
||||
}
|
||||
hideModal('wsDetails');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -35,12 +35,9 @@
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h5 class="mb-0">Heroprompt</h5>
|
||||
<div class="ms-auto">
|
||||
<button id="wsCreateBtn" class="btn btn-primary btn-sm me-2">
|
||||
<button id="wsCreateBtn" class="btn btn-primary btn-sm">
|
||||
+ New Workspace
|
||||
</button>
|
||||
<button id="refreshWs" class="btn btn-outline-secondary btn-sm">
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,19 +49,13 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0 text-uppercase fw-bold explorer-title">Explorer</h6>
|
||||
<div class="explorer-actions">
|
||||
<button id="collapseAll" class="btn btn-sm btn-ghost me-1" title="Collapse All">
|
||||
<i class="icon-collapse"></i>
|
||||
</button>
|
||||
<button id="refreshExplorer" class="btn btn-sm btn-ghost me-1" title="Refresh">
|
||||
<button id="refreshExplorer" class="btn btn-lg btn-ghost me-2"
|
||||
title="Refresh Workspace">
|
||||
<i class="icon-refresh"></i>
|
||||
</button>
|
||||
<button id="wsDetailsBtn" class="btn btn-sm btn-ghost me-1"
|
||||
title="Workspace Settings">
|
||||
<button id="wsDetailsBtn" class="btn btn-lg btn-ghost" title="Workspace Details">
|
||||
<i class="icon-settings"></i>
|
||||
</button>
|
||||
<button id="openWsManage" class="btn btn-sm btn-ghost" title="Manage Workspaces">
|
||||
<i class="icon-manage"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,13 +86,6 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="selection-actions">
|
||||
<button id="selectAll" class="btn btn-xs btn-ghost" title="Select All Visible">
|
||||
Select All
|
||||
</button>
|
||||
<button id="clearSelection" class="btn btn-xs btn-ghost"
|
||||
title="Clear Selection">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,52 +135,19 @@
|
||||
<div class="selection-workspace">
|
||||
<div class="selection-header">
|
||||
<h6 class="section-title">Selected Files & Directories</h6>
|
||||
<div class="selection-actions">
|
||||
<button id="exportSelection" class="btn btn-sm btn-ghost"
|
||||
title="Export Selection">
|
||||
<i class="icon-export"></i>
|
||||
</button>
|
||||
<button id="importSelection" class="btn btn-sm btn-ghost"
|
||||
title="Import Selection">
|
||||
<i class="icon-import"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selection-content">
|
||||
<div class="selection-list-panel">
|
||||
<div class="selection-cards-container">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Selected Items</span>
|
||||
<button id="clearAllSelection" class="btn btn-xs btn-ghost">Clear
|
||||
All</button>
|
||||
<span class="panel-title">Selected Files & Directories</span>
|
||||
</div>
|
||||
<div class="selection-list">
|
||||
<ul id="selected" class="selected-items">
|
||||
<li class="empty-selection">
|
||||
<i class="icon-empty"></i>
|
||||
<p>No files selected</p>
|
||||
<small>Use checkboxes in the explorer to select files</small>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">File Preview</span>
|
||||
<div class="preview-actions">
|
||||
<button id="copyPreview" class="btn btn-xs btn-ghost"
|
||||
title="Copy Content">
|
||||
<i class="icon-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
<div id="preview" class="file-preview">
|
||||
<div class="empty-preview">
|
||||
<i class="icon-file"></i>
|
||||
<p>Select a file to preview</p>
|
||||
</div>
|
||||
<div id="selectedCards" class="selection-cards">
|
||||
<div class="empty-selection-cards">
|
||||
<i class="icon-empty"></i>
|
||||
<p>No files selected</p>
|
||||
<small>Use checkboxes in the explorer to select files and
|
||||
directories</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -361,7 +312,7 @@ Example:
|
||||
<button type="button" class="btn btn-danger" id="wdDelete">Delete</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"
|
||||
id="wdCancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="wdSave">Save</button>
|
||||
<button type="button" class="btn btn-primary" id="wdUpdate">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -384,6 +335,25 @@ Example:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Modal -->
|
||||
<div class="modal fade" id="confirmDeleteModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="confirmDeleteMessage">Are you sure you want to delete this workspace?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
Reference in New Issue
Block a user