Files
herolib/lib/web/ui/static/js/heroprompt.js
Mahmoud-Emad 923f8c24e7 feat: Improve HeroPrompt file selection and workspace management
- Refactor Directory struct and its methods.
- Update file selection logic for directories and files.
- Enhance prompt generation with better file mapping.
- Add unit tests for directory and file operations.
- Improve workspace management with auto-save and logging.
2025-10-12 12:16:52 +03:00

2391 lines
84 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Global state
let currentWs = localStorage.getItem('heroprompt-current-ws') || 'default';
let selected = new Set(); // Selected file paths
let selectedDirs = new Set(); // Selected directory paths (for UI state only)
let expandedDirs = new Set();
let searchQuery = '';
// Utility functions
const el = (id) => document.getElementById(id);
const qs = (selector) => document.querySelector(selector);
const qsa = (selector) => document.querySelectorAll(selector);
// File extension detection utility
const getFileExtension = (filename) => {
const parts = filename.split('.');
return parts.length > 1 ? parts.pop().toLowerCase() : '';
};
// File size formatting utility
const 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];
};
// Date formatting utility
const 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();
};
// File icon mapping utility
const 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] || '📄';
};
// API helpers
async function api(url) {
try {
const r = await fetch(url);
if (!r.ok) {
console.warn(`API call failed: ${url} - ${r.status}`);
return { error: `HTTP ${r.status}` };
}
return await r.json();
} catch (e) {
console.warn(`API call error: ${url}`, e);
return { error: 'request failed' };
}
}
async function post(url, data) {
const form = new FormData();
Object.entries(data).forEach(([k, v]) => form.append(k, v));
try {
const r = await fetch(url, { method: 'POST', body: form });
if (!r.ok) {
console.warn(`POST failed: ${url} - ${r.status}`);
return { error: `HTTP ${r.status}` };
}
return await r.json();
} catch (e) {
console.warn(`POST error: ${url}`, e);
return { error: 'request failed' };
}
}
// Modal helpers
function showModal(id) {
const modalEl = el(id);
if (modalEl) {
const modal = new bootstrap.Modal(modalEl);
modal.show();
}
}
function hideModal(id) {
const modalEl = el(id);
if (modalEl) {
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) modal.hide();
}
}
// Tab management
function switchTab(tabName) {
// Update tab buttons
qsa('.tab').forEach(tab => {
tab.classList.remove('active');
if (tab.getAttribute('data-tab') === tabName) {
tab.classList.add('active');
}
});
// Update tab panes
qsa('.tab-pane').forEach(pane => {
pane.style.display = 'none';
if (pane.id === `tab-${tabName}`) {
pane.style.display = 'block';
}
});
}
// 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.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', async (e) => {
e.stopPropagation();
if (checkbox.checked) {
// For directories: mark as selected and select all children files
// For files: add the file to selection
if (item.type === 'directory') {
// Add directory to selectedDirs for UI state tracking
selectedDirs.add(path);
// Select all file children (adds to 'selected' set)
await this.selectDirectoryChildren(path, true);
// Auto-expand the directory and its checked subdirectories
// This will load subdirectories into the DOM
await this.expandDirectoryRecursive(path);
// NOW update subdirectory selection after they're in the DOM
this.updateSubdirectorySelection(path, true);
// Update all checkboxes to reflect the new state
this.updateVisibleCheckboxes();
} else {
selected.add(path);
this.updateSelectionUI();
}
} else {
// For files: remove from selection
// For directories: deselect directory and all children
if (item.type === 'directory') {
// Remove directory from selectedDirs
selectedDirs.delete(path);
// Deselect all file children
await this.selectDirectoryChildren(path, false);
// Update subdirectory selection
this.updateSubdirectorySelection(path, false);
// Update all checkboxes to reflect the new state
this.updateVisibleCheckboxes();
// Optionally collapse the directory when unchecked
// (commented out to leave expanded for user convenience)
// if (this.expandedDirs.has(path)) {
// await this.toggleDirectory(path);
// }
} else {
selected.delete(path);
this.updateSelectionUI();
}
}
});
// Expand/collapse button for directories
let expandBtn = null;
if (item.type === 'directory') {
expandBtn = document.createElement('button');
expandBtn.className = 'tree-expand-btn';
expandBtn.innerHTML = this.expandedDirs.has(path) ? '▼' : '▶';
expandBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleDirectory(path);
});
} else {
// Spacer for files to align with directories
expandBtn = document.createElement('span');
expandBtn.className = 'tree-expand-spacer';
}
// Icon
const icon = document.createElement('span');
icon.className = 'tree-icon';
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', (e) => {
e.stopPropagation();
if (item.type === 'file') {
// Toggle file selection when clicking on file name
checkbox.checked = !checkbox.checked;
if (checkbox.checked) {
selected.add(path);
} else {
selected.delete(path);
}
this.updateSelectionUI();
} else {
// Toggle directory expansion when clicking on directory name
this.toggleDirectory(path);
}
});
content.appendChild(checkbox);
content.appendChild(expandBtn);
content.appendChild(icon);
content.appendChild(label);
div.appendChild(content);
return div;
}
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
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 - update UI optimistically but revert on error
this.expandedDirs.add(dirPath);
if (expandBtn) expandBtn.innerHTML = '▼';
if (icon) icon.textContent = '📂';
// Try to load children
const success = await this.loadChildren(dirPath);
// If loading failed, revert the UI state
if (!success) {
this.expandedDirs.delete(dirPath);
if (expandBtn) expandBtn.innerHTML = '▶';
if (icon) icon.textContent = '📁';
} else {
// Loading succeeded - restore checkbox states for subdirectories
// Check if this directory or any parent is selected
const isSelected = selectedDirs.has(dirPath) || this.isParentDirectorySelected(dirPath);
if (isSelected) {
// Restore subdirectory selection states
this.updateSubdirectorySelection(dirPath, true);
}
// Update all visible checkboxes to reflect current state
this.updateVisibleCheckboxes();
}
}
}
// Expand a directory if it's not already expanded
async expandDirectory(dirPath) {
const isExpanded = this.expandedDirs.has(dirPath);
if (!isExpanded) {
await this.toggleDirectory(dirPath);
}
}
// Recursively expand a directory and all its checked subdirectories
async expandDirectoryRecursive(dirPath) {
// First, expand this directory
await this.expandDirectory(dirPath);
// Wait a bit for the DOM to update with children
await new Promise(resolve => setTimeout(resolve, 100));
// Find all subdirectories that are children of this directory
const childDirs = Array.from(document.querySelectorAll('.tree-item'))
.filter(item => {
const itemPath = item.dataset.path;
const itemType = item.dataset.type;
// Check if this is a direct or indirect child directory
return itemType === 'directory' &&
itemPath !== dirPath &&
itemPath.startsWith(dirPath + '/');
});
// Recursively expand checked subdirectories
for (const childDir of childDirs) {
const childPath = childDir.dataset.path;
const checkbox = childDir.querySelector('.tree-checkbox');
// If the subdirectory checkbox is checked, expand it recursively
if (checkbox && checkbox.checked) {
await this.expandDirectoryRecursive(childPath);
}
}
}
removeChildren(parentPath) {
const items = qsa('.tree-item');
const toRemove = [];
items.forEach(item => {
const itemPath = item.dataset.path;
if (itemPath !== parentPath && itemPath.startsWith(parentPath + '/')) {
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) {
// Always reload children to ensure fresh data
console.log('Loading children for:', 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);
return false; // Return false to indicate failure
}
// Sort items: directories first, then files
const items = (r.items || []).sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
// Find the parent element
const parentElement = qs(`[data-path="${parentPath}"]`);
if (!parentElement) {
console.warn('Parent element not found for path:', parentPath);
return false; // Return false to indicate failure
}
const parentDepth = parseInt(parentElement.dataset.depth || '0');
// Create document fragment for efficient DOM manipulation
const fragment = document.createDocumentFragment();
const childElements = [];
// Create all child elements first
for (const item of items) {
const childPath = parentPath.endsWith('/') ?
parentPath + item.name :
parentPath + '/' + item.name;
const childElement = this.createFileItem(item, childPath, parentDepth + 1);
// Prepare for animation
childElement.style.opacity = '0';
childElement.style.maxHeight = '0';
childElement.style.transition = 'opacity 0.2s ease, max-height 0.2s ease';
fragment.appendChild(childElement);
childElements.push(childElement);
}
// Insert all elements at once
parentElement.insertAdjacentElement('afterend', fragment.firstChild);
if (fragment.children.length > 1) {
let insertAfter = parentElement.nextElementSibling;
while (fragment.firstChild) {
insertAfter.insertAdjacentElement('afterend', fragment.firstChild);
insertAfter = insertAfter.nextElementSibling;
}
}
// Trigger animations with staggered delay
childElements.forEach((element, index) => {
setTimeout(() => {
element.style.opacity = '1';
element.style.maxHeight = '30px';
}, index * 20 + 10);
});
this.loadedPaths.add(parentPath);
return true; // Return true to indicate success
}
getDepth(path) {
// 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) {
const previewEl = el('preview');
if (!previewEl) return;
previewEl.innerHTML = '<div class="loading">Loading...</div>';
const r = await api(`/api/heroprompt/file?name=${currentWs}&path=${encodeURIComponent(filePath)}`);
if (r.error) {
previewEl.innerHTML = `<div class="error-message">Error: ${r.error}</div>`;
return;
}
previewEl.textContent = r.content || 'No content';
}
updateSelectionUI() {
const selCountEl = el('selCount');
const selCountTabEl = el('selCountTab');
const tokenCountEl = el('tokenCount');
const selectedCardsEl = el('selectedCards');
const count = selected.size;
if (selCountEl) selCountEl.textContent = count.toString();
if (selCountTabEl) selCountTabEl.textContent = count.toString();
// Update selection cards
if (selectedCardsEl) {
selectedCardsEl.innerHTML = '';
if (count === 0) {
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 and directories</small>
</div>
`;
} else {
Array.from(selected).forEach(path => {
const card = this.createFileCard(path);
selectedCardsEl.appendChild(card);
});
}
}
// Estimate token count (rough approximation)
const totalChars = Array.from(selected).join('\n').length;
const tokens = Math.ceil(totalChars / 4);
if (tokenCountEl) tokenCountEl.textContent = tokens.toString();
}
// Select or deselect all children of a directory recursively
async selectDirectoryChildren(dirPath, select) {
// Get all children from API to update the selection state
// This only selects FILES, not directories (API returns only files)
await this.selectDirectoryChildrenFromAPI(dirPath, select);
// Note: We don't call updateSubdirectorySelection() here because
// subdirectories might not be in the DOM yet. The caller should
// call it after expanding the directory.
// Update any currently visible children in the DOM
this.updateVisibleCheckboxes();
// Update the selection UI once at the end
this.updateSelectionUI();
}
// Update selectedDirs for all subdirectories under a path
updateSubdirectorySelection(dirPath, select) {
// Find all visible subdirectories under this path
const treeItems = document.querySelectorAll('.tree-item');
treeItems.forEach(item => {
const itemPath = item.dataset.path;
const itemType = item.dataset.type;
// Check if this is a subdirectory of dirPath
if (itemType === 'directory' && itemPath !== dirPath && itemPath.startsWith(dirPath + '/')) {
if (select) {
selectedDirs.add(itemPath);
} else {
selectedDirs.delete(itemPath);
}
}
});
}
// Update all visible checkboxes to match the current selection state
updateVisibleCheckboxes() {
const treeItems = document.querySelectorAll('.tree-item');
treeItems.forEach(item => {
const itemPath = item.dataset.path;
const itemType = item.dataset.type;
const checkbox = item.querySelector('.tree-checkbox');
if (checkbox && itemPath) {
if (itemType === 'file') {
// For files: check if the file path is in selected set
checkbox.checked = selected.has(itemPath);
} else if (itemType === 'directory') {
// For directories: check if all file children are selected
checkbox.checked = this.areAllChildrenSelected(itemPath);
}
}
});
}
// Check if a directory should be checked
// A directory is checked if:
// 1. It's in the selectedDirs set (explicitly selected), OR
// 2. Any parent directory is in selectedDirs (cascading)
areAllChildrenSelected(dirPath) {
// Check if this directory is explicitly selected
if (selectedDirs.has(dirPath)) {
return true;
}
// Check if any parent directory is selected (cascading)
if (this.isParentDirectorySelected(dirPath)) {
return true;
}
return false;
}
// Check if any parent directory of this path is selected
isParentDirectorySelected(dirPath) {
// Walk up the directory tree
let currentPath = dirPath;
while (currentPath.includes('/')) {
// Get parent directory
const parentPath = currentPath.substring(0, currentPath.lastIndexOf('/'));
// Check if parent is in selectedDirs
if (selectedDirs.has(parentPath)) {
return true;
}
currentPath = parentPath;
}
return false;
}
// Select directory children using API to get complete recursive list
async selectDirectoryChildrenFromAPI(dirPath, select) {
try {
const response = await fetch(`/api/heroprompt/workspaces/${encodeURIComponent(currentWs)}/list?path=${encodeURIComponent(dirPath)}`);
if (response.ok) {
const data = await response.json();
if (data.children) {
data.children.forEach(child => {
const childPath = child.path;
if (select) {
selected.add(childPath);
} else {
selected.delete(childPath);
}
});
}
} else {
console.error('Failed to fetch directory children:', response.status, response.statusText);
const errorText = await response.text();
console.error('Error response:', errorText);
}
} catch (error) {
console.error('Error selecting directory children:', error);
}
}
createFileCard(path) {
const card = document.createElement('div');
card.className = 'file-card';
// Get file info
const fileName = path.split('/').pop();
const extension = 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);
// 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">
${isDirectory ? '📁' : getFileIcon(extension)}
</div>
<div class="file-card-info">
<h4 class="file-card-name">${fileName}</h4>
<p class="file-card-path">${displayPath}</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;
}
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: formatFileSize(Math.floor(Math.random() * 100000) + 1000),
modified: formatDate(new Date(Date.now() - Math.floor(Math.random() * 30) * 24 * 60 * 60 * 1000))
};
}
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">${getFileIcon(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 = getFileExtension(filePath.split('/').pop());
// Create the code preview structure with synchronized scrolling
container.innerHTML = `
<div class="code-scroll-container">
<div class="line-numbers-container">
<div class="line-numbers-scroll">
${lines.map((_, index) => `<div class="line-number">${index + 1}</div>`).join('')}
</div>
</div>
<div class="code-content-container">
<pre class="code-text" data-language="${extension}"><code>${this.escapeHtml(content)}</code></pre>
</div>
</div>
`;
// Set up synchronized scrolling
this.setupSynchronizedScrolling(container);
}
setupSynchronizedScrolling(container) {
const lineNumbersContainer = container.querySelector('.line-numbers-container');
const codeContentContainer = container.querySelector('.code-content-container');
const lineNumbersScroll = container.querySelector('.line-numbers-scroll');
if (!lineNumbersContainer || !codeContentContainer || !lineNumbersScroll) {
return;
}
// Synchronize scrolling between code content and line numbers
codeContentContainer.addEventListener('scroll', () => {
const scrollTop = codeContentContainer.scrollTop;
lineNumbersContainer.scrollTop = scrollTop;
});
// Optional: Allow scrolling from line numbers to affect code content
lineNumbersContainer.addEventListener('scroll', () => {
const scrollTop = lineNumbersContainer.scrollTop;
codeContentContainer.scrollTop = scrollTop;
});
// Ensure line numbers container can scroll
lineNumbersContainer.style.overflow = 'hidden';
lineNumbersContainer.style.height = '100%';
// Make sure the line numbers scroll area matches the code content height
const codeText = container.querySelector('.code-text');
if (codeText) {
const codeHeight = codeText.scrollHeight;
lineNumbersScroll.style.height = `${codeHeight}px`;
}
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
copyModalContent() {
const contentEl = el('modalPreviewContent');
if (!contentEl) {
console.warn('Modal content element not found');
return;
}
const textContent = contentEl.textContent;
if (!textContent || textContent.trim().length === 0) {
console.warn('No content to copy');
return;
}
if (!navigator.clipboard) {
// Fallback for older browsers
this.fallbackCopyToClipboard(textContent);
return;
}
navigator.clipboard.writeText(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);
contentEl.innerHTML = '<div class="error-message">Failed to copy content</div>';
setTimeout(() => {
contentEl.innerHTML = originalContent;
}, 2000);
});
}
fallbackCopyToClipboard(text) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
console.log('Fallback: Content copied to clipboard');
} catch (err) {
console.error('Fallback: Failed to copy content', err);
}
document.body.removeChild(textArea);
}
removeFromSelection(path) {
selected.delete(path);
// Update checkbox
const checkbox = qs(`[data-path="${path}"] .tree-checkbox`);
if (checkbox) {
checkbox.checked = false;
}
this.updateSelectionUI();
}
selectAll() {
qsa('.tree-checkbox').forEach(checkbox => {
checkbox.checked = true;
const treeItem = checkbox.closest('.tree-item');
const path = treeItem.dataset.path;
const type = treeItem.dataset.type;
// Add files to selected set, directories to selectedDirs set
if (type === 'file') {
selected.add(path);
} else if (type === 'directory') {
selectedDirs.add(path);
}
});
this.updateSelectionUI();
}
clearSelection() {
selected.clear();
selectedDirs.clear();
qsa('.tree-checkbox').forEach(checkbox => {
checkbox.checked = false;
});
this.updateSelectionUI();
}
collapseAll() {
expandedDirs.clear();
qsa('.tree-expand-btn').forEach(btn => {
btn.innerHTML = '▶';
});
// Remove all children except root level
qsa('.tree-item').forEach(item => {
const depth = parseInt(item.style.paddingLeft) / 16;
if (depth > 0) {
item.remove();
}
});
this.loadedPaths.clear();
}
// Refresh a specific directory (collapse and re-expand to reload its contents)
async refreshDirectory(dirPath) {
const dirElement = document.querySelector(`[data-path="${dirPath}"][data-type="directory"]`);
if (!dirElement) {
console.warn('Directory element not found:', dirPath);
return;
}
const wasExpanded = this.expandedDirs.has(dirPath);
if (wasExpanded) {
// Collapse the directory
await this.toggleDirectory(dirPath);
// Wait a bit
await new Promise(resolve => setTimeout(resolve, 100));
// Re-expand to reload contents
await this.toggleDirectory(dirPath);
}
}
async search(query) {
searchQuery = query.toLowerCase().trim();
if (!searchQuery) {
// Show all items when search is cleared
qsa('.tree-item').forEach(item => {
item.style.display = 'block';
});
return;
}
try {
// Use the new search API to get all matching files across the workspace
const searchResults = await api(`/api/heroprompt/workspaces/${encodeURIComponent(currentWs)}/search?q=${encodeURIComponent(searchQuery)}`);
if (searchResults.error) {
console.warn('Search failed:', searchResults.error);
// Fallback to local search
this.localSearch(query);
return;
}
// Hide all current items
qsa('.tree-item').forEach(item => {
item.style.display = 'none';
});
// Show matching items and expand their parent directories
const matchingPaths = new Set();
searchResults.results.forEach(result => {
matchingPaths.add(result.path);
// Also add parent directory paths
const pathParts = result.path.split('/');
for (let i = 1; i < pathParts.length; i++) {
const parentPath = pathParts.slice(0, i).join('/');
if (parentPath) {
matchingPaths.add(parentPath);
}
}
});
// Show items that match or are parents of matches
// Get workspace info once
const workspaceInfo = await api(`/api/heroprompt/workspaces/${currentWs}`);
qsa('.tree-item').forEach(item => {
const itemPath = item.dataset.path;
if (itemPath) {
// Get relative path from workspace base
let relPath = itemPath;
if (workspaceInfo && workspaceInfo.base_path && itemPath.startsWith(workspaceInfo.base_path)) {
relPath = itemPath.substring(workspaceInfo.base_path.length);
if (relPath.startsWith('/')) {
relPath = relPath.substring(1);
}
}
if (matchingPaths.has(relPath) || relPath === '') {
item.style.display = 'block';
// Auto-expand directories that contain matches
if (item.dataset.type === 'directory' && !this.expandedDirs.has(itemPath)) {
this.toggleDirectory(itemPath);
}
}
}
});
} catch (error) {
console.warn('Search API error:', error);
// Fallback to local search
this.localSearch(query);
}
}
localSearch(query) {
const searchQuery = query.toLowerCase();
qsa('.tree-item').forEach(item => {
const label = item.querySelector('.tree-label');
if (label) {
const matches = !searchQuery || label.textContent.toLowerCase().includes(searchQuery);
item.style.display = matches ? 'block' : 'none';
}
});
}
async render(workspacePath) {
this.container.innerHTML = '<div class="loading">Loading workspace...</div>';
const r = await api(`/api/heroprompt/directory?name=${currentWs}&path=${encodeURIComponent(workspacePath)}`);
if (r.error) {
this.container.innerHTML = `<div class="error-message">${r.error}</div>`;
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) {
return a.type === 'directory' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
// Create document fragment for efficient DOM manipulation
const fragment = document.createDocumentFragment();
const elements = [];
// Create all elements first
for (const item of items) {
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';
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();
}
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
let fileTree = null;
// Workspace management
async function reloadWorkspaces() {
const sel = el('workspaceSelect');
if (!sel) return;
sel.innerHTML = '<option>Loading...</option>';
const names = await api('/api/heroprompt/workspaces');
sel.innerHTML = '';
if (names.error || !Array.isArray(names)) {
sel.innerHTML = '<option>Error loading workspaces</option>';
console.warn('Failed to load workspaces:', names);
return;
}
for (const n of names) {
const opt = document.createElement('option');
opt.value = n;
opt.textContent = n;
sel.appendChild(opt);
}
if (names.includes(currentWs)) {
sel.value = currentWs;
} else if (names.length > 0) {
currentWs = names[0];
sel.value = currentWs;
localStorage.setItem('heroprompt-current-ws', currentWs);
}
}
async function initWorkspace() {
const names = await api('/api/heroprompt/workspaces');
if (names.error || !Array.isArray(names) || names.length === 0) {
console.warn('No workspaces available');
const treeEl = el('tree');
if (treeEl) {
treeEl.innerHTML = `
<div class="empty-state">
<i class="icon-folder-open"></i>
<p>No workspaces available</p>
<small>Create one to get started</small>
</div>
`;
}
return;
}
if (!currentWs || !names.includes(currentWs)) {
currentWs = names[0];
localStorage.setItem('heroprompt-current-ws', currentWs);
}
const sel = el('workspaceSelect');
if (sel) sel.value = currentWs;
// 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>
`;
}
}
// Prompt generation
async function generatePrompt() {
const promptTextEl = el('promptText');
const outputEl = el('promptOutput');
if (!outputEl) {
console.error('Prompt output element not found');
return;
}
if (!currentWs) {
outputEl.innerHTML = '<div class="error-message">No workspace selected. Please select a workspace first.</div>';
return;
}
if (selected.size === 0) {
outputEl.innerHTML = '<div class="error-message">No files selected. Please select files first.</div>';
return;
}
const promptText = promptTextEl?.value?.trim() || '';
outputEl.innerHTML = '<div class="loading">Generating prompt...</div>';
try {
// Pass selections directly to prompt generation
const paths = Array.from(selected);
const formData = new URLSearchParams();
formData.append('text', promptText);
formData.append('selected_paths', JSON.stringify(paths));
const r = await fetch(`/api/heroprompt/workspaces/${encodeURIComponent(currentWs)}/prompt`, {
method: 'POST',
body: formData
});
if (!r.ok) {
throw new Error(`HTTP ${r.status}: ${r.statusText}`);
}
const result = await r.text();
if (result.trim().length === 0) {
outputEl.innerHTML = '<div class="error-message">Generated prompt is empty</div>';
} else {
outputEl.textContent = result;
}
} catch (e) {
console.warn('Generate prompt failed', e);
outputEl.innerHTML = `<div class="error-message">Failed to generate prompt: ${e.message}</div>`;
}
}
async function copyPrompt() {
const outputEl = el('promptOutput');
if (!outputEl) {
console.warn('Prompt output element not found');
showStatus('Copy failed - element not found', 'error');
return;
}
// 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;
}
// 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 {
const successful = document.execCommand('copy');
showStatus(successful ? 'Prompt copied!' : 'Copy failed', successful ? 'success' : 'error');
} catch (e) {
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');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
console.log('Fallback: Content copied to clipboard');
} catch (err) {
console.error('Fallback: Failed to copy content', err);
}
document.body.removeChild(textArea);
}
// 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();
// Load directories for new current workspace
await loadWorkspaceDirectories();
}
}
return { success: true };
} catch (e) {
console.warn('Delete workspace failed', e);
return { error: 'Failed to delete workspace' };
}
}
async function updateWorkspace(workspaceName, newName) {
try {
const formData = new FormData();
if (newName && newName !== workspaceName) {
formData.append('name', newName);
}
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();
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
const treeContainer = el('tree');
if (treeContainer) {
fileTree = new SimpleFileTree(treeContainer);
}
// Initialize workspaces
initWorkspace();
reloadWorkspaces();
// Tab switching
qsa('.tab').forEach(tab => {
tab.addEventListener('click', function (e) {
e.preventDefault();
const tabName = this.getAttribute('data-tab');
switchTab(tabName);
});
});
// Workspace selector
const workspaceSelect = el('workspaceSelect');
if (workspaceSelect) {
workspaceSelect.addEventListener('change', async (e) => {
currentWs = e.target.value;
localStorage.setItem('heroprompt-current-ws', currentWs);
// Load directories for the new workspace
await loadWorkspaceDirectories();
});
}
// Explorer controls
const collapseAllBtn = el('collapseAll');
if (collapseAllBtn) {
collapseAllBtn.addEventListener('click', () => {
if (fileTree) fileTree.collapseAll();
});
}
const refreshExplorerBtn = el('refreshExplorer');
if (refreshExplorerBtn) {
refreshExplorerBtn.addEventListener('click', async () => {
// Save currently expanded directories before refresh
const previouslyExpanded = new Set(expandedDirs);
// Reload workspace directories
await loadWorkspaceDirectories();
// Re-expand previously expanded directories
if (fileTree && previouslyExpanded.size > 0) {
// Wait a bit for the DOM to be ready
await new Promise(resolve => setTimeout(resolve, 100));
// Re-expand each previously expanded directory
for (const dirPath of previouslyExpanded) {
const dirElement = document.querySelector(`[data-path="${dirPath}"][data-type="directory"]`);
if (dirElement && !expandedDirs.has(dirPath)) {
// Expand this directory
await fileTree.toggleDirectory(dirPath);
}
}
}
});
}
const selectAllBtn = el('selectAll');
if (selectAllBtn) {
selectAllBtn.addEventListener('click', () => {
if (fileTree) fileTree.selectAll();
});
}
const clearSelectionBtn = el('clearSelection');
if (clearSelectionBtn) {
clearSelectionBtn.addEventListener('click', () => {
if (fileTree) fileTree.clearSelection();
});
}
const clearAllSelectionBtn = el('clearAllSelection');
if (clearAllSelectionBtn) {
clearAllSelectionBtn.addEventListener('click', () => {
if (fileTree) fileTree.clearSelection();
});
}
// Search functionality
const searchInput = el('search');
const clearSearchBtn = el('clearSearch');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
if (fileTree) {
fileTree.search(e.target.value);
}
});
}
if (clearSearchBtn) {
clearSearchBtn.addEventListener('click', () => {
if (searchInput) {
searchInput.value = '';
if (fileTree) {
fileTree.search('');
}
}
});
}
// Prompt generation
const generatePromptBtn = el('generatePrompt');
if (generatePromptBtn) {
generatePromptBtn.addEventListener('click', generatePrompt);
}
const copyPromptBtn = el('copyPrompt');
if (copyPromptBtn) {
copyPromptBtn.addEventListener('click', copyPrompt);
}
// Workspace creation modal
const wsCreateBtn = el('wsCreateBtn');
if (wsCreateBtn) {
wsCreateBtn.addEventListener('click', () => {
const nameEl = el('wcName');
const errorEl = el('wcError');
if (nameEl) nameEl.value = '';
if (errorEl) errorEl.textContent = '';
showModal('wsCreate');
});
}
const wcCreateBtn = el('wcCreate');
if (wcCreateBtn) {
wcCreateBtn.addEventListener('click', async () => {
const name = el('wcName')?.value?.trim() || '';
const errorEl = el('wcError');
if (!name) {
if (errorEl) errorEl.textContent = 'Workspace name is required.';
return;
}
const formData = { name: name };
const resp = await post('/api/heroprompt/workspaces', formData);
if (resp.error) {
if (errorEl) errorEl.textContent = resp.error;
return;
}
currentWs = resp.name || currentWs;
localStorage.setItem('heroprompt-current-ws', currentWs);
await reloadWorkspaces();
// 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');
});
}
// Workspace details modal
const wsDetailsBtn = el('wsDetailsBtn');
if (wsDetailsBtn) {
wsDetailsBtn.addEventListener('click', async () => {
const info = await api(`/api/heroprompt/workspaces/${currentWs}`);
if (info && !info.error) {
const nameEl = el('wdName');
const errorEl = el('wdError');
if (nameEl) nameEl.value = info.name || currentWs;
if (errorEl) errorEl.textContent = '';
showModal('wsDetails');
}
});
}
// Workspace details update
const wdUpdateBtn = el('wdUpdate');
if (wdUpdateBtn) {
wdUpdateBtn.addEventListener('click', async () => {
const name = el('wdName')?.value?.trim() || '';
const errorEl = el('wdError');
if (!name) {
if (errorEl) errorEl.textContent = 'Workspace name is required.';
return;
}
const result = await updateWorkspace(currentWs, name);
if (result.error) {
if (errorEl) errorEl.textContent = result.error;
return;
}
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');
});
});
}
// 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();
});
// Chat Interface Implementation
function initChatInterface() {
const chatInput = el('chatInput');
const sendBtn = el('sendChat');
const messagesContainer = el('chatMessages');
const charCount = el('charCount');
const chatStatus = el('chatStatus');
const typingIndicator = el('typingIndicator');
const newChatBtn = el('newChatBtn');
const chatList = el('chatList');
let chatHistory = [];
let isTyping = false;
let conversations = JSON.parse(localStorage.getItem('heroprompt-conversations') || '[]');
let currentConversationId = null;
// Initialize chat input functionality
if (chatInput && sendBtn) {
// Auto-resize textarea
chatInput.addEventListener('input', function () {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
// Update character count
if (charCount) {
const count = this.value.length;
charCount.textContent = count;
charCount.className = 'char-count';
if (count > 2000) charCount.classList.add('warning');
if (count > 4000) charCount.classList.add('error');
}
// Enable/disable send button
sendBtn.disabled = this.value.trim().length === 0;
});
// Handle Enter key
chatInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!sendBtn.disabled) {
sendMessage();
}
}
});
// Send button click
sendBtn.addEventListener('click', sendMessage);
}
// Chat action buttons
const clearChatBtn = el('clearChat');
const exportChatBtn = el('exportChat');
if (newChatBtn) {
newChatBtn.addEventListener('click', startNewChat);
}
if (clearChatBtn) {
clearChatBtn.addEventListener('click', clearChat);
}
if (exportChatBtn) {
exportChatBtn.addEventListener('click', exportChat);
}
async function sendMessage() {
const message = chatInput.value.trim();
if (!message || isTyping) return;
// Add user message to chat
addMessage('user', message);
chatInput.value = '';
chatInput.style.height = 'auto';
sendBtn.disabled = true;
if (charCount) charCount.textContent = '0';
// Show typing indicator
showTypingIndicator();
updateChatStatus('typing', 'AI is thinking...');
try {
// Simulate API call - replace with actual API endpoint
const response = await simulateAIResponse(message);
// Hide typing indicator
hideTypingIndicator();
// Add AI response
addMessage('assistant', response);
updateChatStatus('ready', 'Ready');
} catch (error) {
hideTypingIndicator();
addMessage('assistant', 'Sorry, I encountered an error. Please try again.');
updateChatStatus('error', 'Error occurred');
console.error('Chat error:', error);
}
}
function addMessage(role, content) {
const messageDiv = document.createElement('div');
messageDiv.className = `chat-message ${role}`;
const timestamp = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const messageId = `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
messageDiv.innerHTML = `
<div class="message-avatar ${role}">
<i class="icon-${role === 'user' ? 'user' : 'ai'}"></i>
</div>
<div class="message-content">
<div class="message-text">${formatMessageContent(content)}</div>
<div class="message-meta">
<span class="message-time">${timestamp}</span>
<div class="message-actions">
<button class="message-action" onclick="copyMessage('${messageId}')" title="Copy">
<i class="icon-copy"></i>
</button>
${role === 'assistant' ? `
<button class="message-action" onclick="regenerateMessage('${messageId}')" title="Regenerate">
<i class="icon-regenerate"></i>
</button>
` : ''}
</div>
</div>
</div>
`;
messageDiv.id = messageId;
// Remove welcome message if it exists
const welcomeMessage = messagesContainer.querySelector('.welcome-message');
if (welcomeMessage) {
welcomeMessage.remove();
}
messagesContainer.appendChild(messageDiv);
// Store in chat history
chatHistory.push({
id: messageId,
role: role,
content: content,
timestamp: new Date().toISOString()
});
// Save to conversation
if (window.saveMessageToConversation) {
window.saveMessageToConversation(role, content);
}
// Auto-scroll to bottom
scrollToBottom();
}
function formatMessageContent(content) {
// Basic markdown-like formatting
let formatted = content
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
.replace(/\n/g, '<br>');
return formatted;
}
function showTypingIndicator() {
if (typingIndicator) {
typingIndicator.style.display = 'flex';
isTyping = true;
}
}
function hideTypingIndicator() {
if (typingIndicator) {
typingIndicator.style.display = 'none';
isTyping = false;
}
}
function updateChatStatus(type, message) {
if (chatStatus) {
chatStatus.textContent = message;
chatStatus.className = `chat-status ${type}`;
}
}
function scrollToBottom() {
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
}
function startNewChat() {
clearChat();
addMessage('assistant', 'Hello! I\'m ready to help you with your code. What would you like to know?');
}
function clearChat() {
chatHistory = [];
if (messagesContainer) {
messagesContainer.innerHTML = `
<div class="welcome-message">
<div class="welcome-avatar">
<i class="icon-ai"></i>
</div>
<div class="welcome-content">
<h4>Welcome to AI Assistant</h4>
<p>I'm here to help you with your code! You can:</p>
<ul>
<li>Ask questions about your selected files</li>
<li>Request code explanations and improvements</li>
<li>Get suggestions for best practices</li>
<li>Debug issues and optimize performance</li>
</ul>
<small>Select some files from the explorer and start chatting!</small>
</div>
</div>
`;
}
updateChatStatus('ready', 'Ready');
}
function exportChat() {
if (chatHistory.length === 0) {
alert('No chat history to export');
return;
}
const exportData = {
timestamp: new Date().toISOString(),
messages: chatHistory,
workspace: currentWs,
selectedFiles: Array.from(selected)
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chat-export-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Simulate AI response - replace with actual API call
async function simulateAIResponse(userMessage) {
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 2000));
// Get context from selected files
const context = selected.size > 0 ?
`Based on your selected files (${Array.from(selected).join(', ')}), ` : '';
// Simple response generation - replace with actual AI API
const responses = [
`${context}I can help you analyze and improve your code. What specific aspect would you like me to focus on?`,
`${context}I notice you're working with these files. Would you like me to review the code structure or suggest improvements?`,
`${context}I can help explain the code, identify potential issues, or suggest optimizations. What would you like to know?`,
`${context}Let me analyze your code and provide insights. Is there a particular functionality you'd like me to examine?`
];
if (userMessage.toLowerCase().includes('error') || userMessage.toLowerCase().includes('bug')) {
return `${context}I can help you debug issues. Please share the specific error message or describe the unexpected behavior you're experiencing.`;
}
if (userMessage.toLowerCase().includes('optimize') || userMessage.toLowerCase().includes('performance')) {
return `${context}For performance optimization, I can analyze your code for bottlenecks, suggest algorithmic improvements, and recommend best practices.`;
}
if (userMessage.toLowerCase().includes('explain') || userMessage.toLowerCase().includes('how')) {
return `${context}I'd be happy to explain the code functionality. Which specific part would you like me to break down?`;
}
return responses[Math.floor(Math.random() * responses.length)];
}
}
// Global helper function for message formatting
function formatMessageContent(content) {
// Basic markdown-like formatting
let formatted = content
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
.replace(/\n/g, '<br>');
return formatted;
}
// Global functions for message actions
function copyMessage(messageId) {
const messageEl = document.getElementById(messageId);
if (!messageEl) return;
const textEl = messageEl.querySelector('.message-text');
if (!textEl) return;
const text = textEl.textContent || textEl.innerText;
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
showMessageFeedback(messageId, 'Copied!');
}).catch(err => {
console.error('Copy failed:', err);
fallbackCopyToClipboard(text);
});
} else {
fallbackCopyToClipboard(text);
}
}
function regenerateMessage(messageId) {
const messageEl = document.getElementById(messageId);
if (!messageEl) return;
// Find the previous user message
let prevMessage = messageEl.previousElementSibling;
while (prevMessage && !prevMessage.classList.contains('user')) {
prevMessage = prevMessage.previousElementSibling;
}
if (prevMessage) {
const userText = prevMessage.querySelector('.message-text').textContent;
// Remove the current AI message
messageEl.remove();
// Show typing indicator and regenerate
const typingIndicator = el('typingIndicator');
if (typingIndicator) {
typingIndicator.style.display = 'flex';
}
// Simulate regeneration
setTimeout(async () => {
try {
const response = await simulateAIResponse(userText);
if (typingIndicator) {
typingIndicator.style.display = 'none';
}
// Create a new message manually
const messageDiv = document.createElement('div');
messageDiv.className = 'chat-message assistant';
const timestamp = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const newMessageId = `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
messageDiv.innerHTML = `
<div class="message-avatar assistant">
<i class="icon-ai"></i>
</div>
<div class="message-content">
<div class="message-text">${formatMessageContent(response)}</div>
<div class="message-meta">
<span class="message-time">${timestamp}</span>
<div class="message-actions">
<button class="message-action" onclick="copyMessage('${newMessageId}')" title="Copy">
<i class="icon-copy"></i>
</button>
<button class="message-action" onclick="regenerateMessage('${newMessageId}')" title="Regenerate">
<i class="icon-regenerate"></i>
</button>
</div>
</div>
</div>
`;
messageDiv.id = newMessageId;
const messagesContainer = el('chatMessages');
if (messagesContainer) {
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
} catch (error) {
if (typingIndicator) {
typingIndicator.style.display = 'none';
}
console.error('Regeneration error:', error);
}
}, 1500);
}
}
function showMessageFeedback(messageId, text) {
const messageEl = document.getElementById(messageId);
if (!messageEl) return;
const actionsEl = messageEl.querySelector('.message-actions');
if (!actionsEl) return;
const originalHTML = actionsEl.innerHTML;
actionsEl.innerHTML = `<span style="color: var(--success-color); font-size: 11px;">${text}</span>`;
setTimeout(() => {
actionsEl.innerHTML = originalHTML;
}, 2000);
}
// Chat List Management Functions
function initChatList() {
const chatList = el('chatList');
const newChatBtn = el('newChatBtn');
if (!chatList) return;
let conversations = JSON.parse(localStorage.getItem('heroprompt-conversations') || '[]');
let currentConversationId = localStorage.getItem('heroprompt-current-conversation') || null;
function renderChatList() {
if (conversations.length === 0) {
chatList.innerHTML = `
<div class="empty-chat-list">
<i class="icon-chat"></i>
<p>No conversations yet</p>
<small>Start a new chat to begin</small>
</div>
`;
return;
}
const conversationsHtml = conversations.map(conv => {
const isActive = conv.id === currentConversationId;
const preview = conv.messages.length > 0 ?
conv.messages[conv.messages.length - 1].content.substring(0, 50) + '...' :
'New conversation';
const time = new Date(conv.updatedAt).toLocaleDateString();
return `
<div class="chat-conversation-item ${isActive ? 'active' : ''}" data-conversation-id="${conv.id}">
<div class="conversation-title">${conv.title}</div>
<div class="conversation-preview">${preview}</div>
<div class="conversation-meta">
<span class="conversation-time">${time}</span>
<div class="conversation-actions">
<button class="conversation-action" onclick="deleteConversation('${conv.id}')" title="Delete">
<i class="icon-clear"></i>
</button>
</div>
</div>
</div>
`;
}).join('');
chatList.innerHTML = `<div class="chat-conversations">${conversationsHtml}</div>`;
// Add click listeners to conversation items
chatList.querySelectorAll('.chat-conversation-item').forEach(item => {
item.addEventListener('click', (e) => {
if (!e.target.closest('.conversation-action')) {
const conversationId = item.dataset.conversationId;
loadConversation(conversationId);
}
});
});
}
function createNewConversation() {
const newConversation = {
id: 'conv-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9),
title: `Chat ${conversations.length + 1}`,
messages: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
conversations.unshift(newConversation);
localStorage.setItem('heroprompt-conversations', JSON.stringify(conversations));
loadConversation(newConversation.id);
renderChatList();
}
function loadConversation(conversationId) {
currentConversationId = conversationId;
localStorage.setItem('heroprompt-current-conversation', conversationId);
const messagesContainer = el('chatMessages');
if (!messagesContainer) return;
if (conversationId) {
const conversation = conversations.find(c => c.id === conversationId);
if (conversation && conversation.messages.length > 0) {
// Load existing conversation
messagesContainer.innerHTML = '';
conversation.messages.forEach(message => {
addMessageToDOM(message.role, message.content, message.timestamp);
});
} else {
// Show welcome message for empty conversation
showWelcomeMessage();
}
} else {
// New conversation
showWelcomeMessage();
}
renderChatList(); // Update active state
scrollToBottom();
}
function showWelcomeMessage() {
const messagesContainer = el('chatMessages');
if (!messagesContainer) return;
messagesContainer.innerHTML = `
<div class="welcome-message">
<div class="welcome-avatar">
<i class="icon-ai"></i>
</div>
<div class="welcome-content">
<h4>Welcome to AI Assistant</h4>
<p>I'm here to help you with your code! You can:</p>
<ul>
<li>Ask questions about your selected files</li>
<li>Request code explanations and improvements</li>
<li>Get suggestions for best practices</li>
<li>Debug issues and optimize performance</li>
</ul>
<small>Select some files from the explorer and start chatting!</small>
</div>
</div>
`;
}
function addMessageToDOM(role, content, timestamp) {
const messagesContainer = el('chatMessages');
if (!messagesContainer) return;
const messageDiv = document.createElement('div');
messageDiv.className = `chat-message ${role}`;
const time = timestamp ? new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) :
new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const messageId = `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
messageDiv.innerHTML = `
<div class="message-avatar ${role}">
<i class="icon-${role === 'user' ? 'user' : 'ai'}"></i>
</div>
<div class="message-content">
<div class="message-text">${formatMessageContent(content)}</div>
<div class="message-meta">
<span class="message-time">${time}</span>
<div class="message-actions">
<button class="message-action" onclick="copyMessage('${messageId}')" title="Copy">
<i class="icon-copy"></i>
</button>
${role === 'assistant' ? `
<button class="message-action" onclick="regenerateMessage('${messageId}')" title="Regenerate">
<i class="icon-regenerate"></i>
</button>
` : ''}
</div>
</div>
</div>
`;
messageDiv.id = messageId;
messagesContainer.appendChild(messageDiv);
}
function scrollToBottom() {
const messagesContainer = el('chatMessages');
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
}
// Initialize
renderChatList();
if (currentConversationId) {
loadConversation(currentConversationId);
} else {
showWelcomeMessage();
}
// Event listeners
if (newChatBtn) {
newChatBtn.addEventListener('click', createNewConversation);
}
// Expose functions globally
window.loadConversation = loadConversation;
window.deleteConversation = function (conversationId) {
conversations = conversations.filter(c => c.id !== conversationId);
localStorage.setItem('heroprompt-conversations', JSON.stringify(conversations));
if (currentConversationId === conversationId) {
currentConversationId = null;
localStorage.removeItem('heroprompt-current-conversation');
showWelcomeMessage();
}
renderChatList();
};
window.saveMessageToConversation = function (role, content) {
if (!currentConversationId) {
createNewConversation();
}
const conversation = conversations.find(c => c.id === currentConversationId);
if (conversation) {
const message = {
role: role,
content: content,
timestamp: new Date().toISOString()
};
conversation.messages.push(message);
conversation.updatedAt = new Date().toISOString();
// Update title based on first user message
if (role === 'user' && conversation.title.startsWith('Chat ')) {
conversation.title = content.substring(0, 30) + '...';
}
localStorage.setItem('heroprompt-conversations', JSON.stringify(conversations));
renderChatList();
}
};
}
// Initialize chat list when DOM is ready
document.addEventListener('DOMContentLoaded', function () {
// Add a small delay to ensure other initialization is complete
setTimeout(() => {
initChatList();
}, 100);
});