/** * Folder Explorer Modal * Visual folder browser for selecting project directories * Following CodeNomad's UX patterns */ (function() { 'use strict'; let currentModal = null; let selectedPath = null; let currentPath = '~'; let expandedNodes = new Set(); let loadingNodes = new Set(); /** * Show folder explorer modal */ async function showFolderExplorer() { closeFolderExplorer(); // Create modal overlay const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.id = 'folder-explorer-overlay'; // Create modal content const modal = document.createElement('div'); modal.className = 'folder-explorer-modal'; modal.id = 'folder-explorer-modal'; modal.innerHTML = `

Select Project Folder

Loading locations...
Loading folders...
`; overlay.appendChild(modal); document.body.appendChild(overlay); // Prevent body scroll document.body.style.overflow = 'hidden'; // Trigger animation setTimeout(() => { overlay.classList.add('visible'); modal.classList.add('visible'); }, 10); // Load content await loadQuickAccess(); await loadFolderTree(currentPath); // Event listeners overlay.addEventListener('click', (e) => { if (e.target === overlay) { closeFolderExplorer(); } }); // Escape key to close const escapeHandler = (e) => { if (e.key === 'Escape') { closeFolderExplorer(); document.removeEventListener('keydown', escapeHandler); } }; document.addEventListener('keydown', escapeHandler); currentModal = { overlay, escapeHandler }; } /** * Load quick access locations */ async function loadQuickAccess() { const container = document.getElementById('quick-access-bar'); if (!container) return; try { const res = await fetch('/api/filesystem/quick-access', { credentials: 'same-origin' }); if (!res.ok) { throw new Error('Failed to load quick access'); } const data = await res.json(); if (data.success && data.locations) { renderQuickAccess(data.locations); } } catch (error) { console.error('Error loading quick access:', error); container.innerHTML = `
Failed to load locations
`; } } /** * Render quick access bar */ function renderQuickAccess(locations) { const container = document.getElementById('quick-access-bar'); if (!container) return; const html = locations.map(loc => `
${loc.icon || '📁'} ${escapeHtml(loc.name)} ${loc.exists ? `${loc.count} folders` : ''}
`).join(''); container.innerHTML = html; } /** * Load folder tree for path */ async function loadFolderTree(path) { const container = document.getElementById('folder-tree'); if (!container) return; currentPath = path; loadingNodes.add(path); renderLoadingState(); updateBreadcrumbs(path); try { const res = await fetch(`/api/filesystem/list?path=${encodeURIComponent(path)}`, { credentials: 'same-origin' }); if (!res.ok) { throw new Error('Failed to load folder'); } const data = await res.json(); loadingNodes.delete(path); if (data.success) { renderFolderTree(data.items, data.path, data.displayPath); } else { container.innerHTML = `
⚠️ ${escapeHtml(data.error || 'Failed to load folders')}
`; } } catch (error) { loadingNodes.delete(path); console.error('Error loading folder tree:', error); container.innerHTML = `
⚠️ ${escapeHtml(error.message)}
`; } } /** * Render folder tree */ function renderFolderTree(items, fullPath, displayPath) { const container = document.getElementById('folder-tree'); if (!container) return; if (!items || items.length === 0) { container.innerHTML = `
📭 This folder is empty
`; return; } const html = items.map(item => `
${item.hasChildren ? '▶' : '•'} ${item.locked ? '🔒' : '📁'} ${escapeHtml(item.name)} ${item.hasChildren ? `${item.children} folders` : ''}
`).join(''); container.innerHTML = html; // Add click handlers container.querySelectorAll('.folder-node').forEach(node => { node.addEventListener('click', (e) => { const nodePath = node.dataset.path; // Check if clicked on expand icon (span or its parent) const clickedExpandIcon = e.target.classList.contains('folder-expand') || e.target.parentElement?.classList.contains('folder-expand'); const expandIcon = node.querySelector('.folder-expand'); const isExpanded = expandIcon && expandIcon.textContent === '▼'; if (clickedExpandIcon && !isExpanded) { // Expand this node expandFolder(nodePath, node); } else if (!clickedExpandIcon) { // Select this folder selectFolderForCreation(nodePath, node.dataset.displayPath); } }); }); } /** * Expand folder to show children */ async function expandFolder(path, nodeElement) { const expandIcon = nodeElement.querySelector('.folder-expand'); if (!expandIcon) return; expandIcon.textContent = '⏳'; loadingNodes.add(path); try { const res = await fetch(`/api/filesystem/list?path=${encodeURIComponent(path)}`, { credentials: 'same-origin' }); const data = await res.json(); loadingNodes.delete(path); if (data.success && data.items && data.items.length > 0) { // Create children container let childrenContainer = nodeElement.nextElementSibling; if (!childrenContainer || !childrenContainer.classList.contains('folder-children')) { childrenContainer = document.createElement('div'); childrenContainer.className = 'folder-children'; nodeElement.after(childrenContainer); } // Render children const childrenHtml = data.items.map(item => `
${item.hasChildren ? '▶' : '•'} ${item.locked ? '🔒' : '📁'} ${escapeHtml(item.name)} ${item.hasChildren ? `${item.children} folders` : ''}
`).join(''); childrenContainer.innerHTML = childrenHtml; childrenContainer.style.display = 'block'; // Add click handlers to children childrenContainer.querySelectorAll('.folder-node').forEach(childNode => { childNode.addEventListener('click', (e) => { const childPath = childNode.dataset.path; // Check if clicked on expand icon (span or its parent) const clickedExpandIcon = e.target.classList.contains('folder-expand') || e.target.parentElement?.classList.contains('folder-expand'); const expandIcon = childNode.querySelector('.folder-expand'); const isExpanded = expandIcon && expandIcon.textContent === '▼'; if (clickedExpandIcon && !isExpanded) { expandFolder(childPath, childNode); } else if (!clickedExpandIcon) { selectFolderForCreation(childPath, childNode.dataset.displayPath); } }); }); expandIcon.textContent = '▼'; expandedNodes.add(path); } else { expandIcon.textContent = '•'; } } catch (error) { loadingNodes.delete(path); expandIcon.textContent = '▶'; console.error('Error expanding folder:', error); } } /** * Update breadcrumbs */ function updateBreadcrumbs(path) { const container = document.getElementById('breadcrumbs-bar'); if (!container) return; // path is already a display path (starts with ~) const displayPath = path; const parts = displayPath.split('/').filter(p => p); let html = ` 🏠 Home `; let buildPath = '~'; parts.forEach((part, index) => { buildPath += '/' + part; html += ` / ${escapeHtml(part)} `; }); container.innerHTML = html; // Add click handlers container.querySelectorAll('.breadcrumb-item').forEach(item => { item.addEventListener('click', () => { const itemPath = item.dataset.path; loadFolderTree(itemPath); }); }); } /** * Quick access click handler */ function quickAccessClick(path) { loadFolderTree(path); } /** * Select a folder (internal - highlights selection) */ function selectFolderForCreation(fullPath, displayPath) { selectedPath = fullPath; // Update UI document.querySelectorAll('.folder-node').forEach(node => { node.classList.remove('selected'); }); const selectedNode = document.querySelector(`.folder-node[data-path="${escapeHtml(fullPath)}"]`); if (selectedNode) { selectedNode.classList.add('selected'); } // Update footer document.getElementById('selected-path-text').textContent = displayPath || fullPath; document.getElementById('btn-select-folder').disabled = false; document.getElementById('btn-create-folder').disabled = false; } /** * Final folder selection - create session directly */ async function selectFolder() { if (!selectedPath) { showToast('Please select a folder first', 'warning'); return; } try { showLoadingOverlay('Creating session...'); // Create session directly with the selected folder as workingDir console.log('[FolderExplorer] Creating session in folder:', selectedPath); const createRes = await fetch('/claude/api/claude/sessions', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workingDir: selectedPath, metadata: { type: 'chat', source: 'folder-explorer', folderName: selectedPath.split('/').filter(Boolean).pop() } }) }); if (!createRes.ok) { let errorMsg = `Failed to create session (HTTP ${createRes.status})`; try { const errorData = await createRes.json(); errorMsg = errorData.error || errorMsg; } catch (e) { console.error('Failed to parse error response:', e); } throw new Error(errorMsg); } const createData = await createRes.json(); // Backend returns: { id, status, createdAt, workingDir, metadata } if (createData && createData.id) { closeFolderExplorer(); showToast('Session created!', 'success'); // Navigate directly to the new session await new Promise(resolve => setTimeout(resolve, 300)); window.location.href = `/claude/ide/session/${createData.id}`; } else { throw new Error(createData.error || 'Failed to create session'); } } catch (error) { console.error('Error selecting folder:', { message: error.message, stack: error.stack, name: error.name, selectedPath: selectedPath, currentPath: currentPath, timestamp: new Date().toISOString() }); hideLoadingOverlay(); showToast(error.message || 'Failed to create session', 'error'); } } /** * Show create folder dialog */ function showCreateFolder() { const basePath = selectedPath || currentPath; // Simple prompt for now (could be enhanced with inline input) const folderName = prompt(`Create new folder in:\n${basePath}\n\nFolder name:`); if (!folderName) return; const newPath = basePath + '/' + folderName; createFolder(newPath); } /** * Create new folder */ async function createFolder(path) { try { showLoadingOverlay('Creating folder...'); const res = await fetch('/api/filesystem/mkdir', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path }) }); if (!res.ok) { throw new Error('Failed to create folder'); } const data = await res.json(); if (data.success) { hideLoadingOverlay(); showToast('Folder created successfully', 'success'); // Refresh folder tree and select the new folder await loadFolderTree(currentPath); // Select the newly created folder if (data.path) { selectFolderForCreation(data.path, data.displayPath); } } else { throw new Error(data.error || 'Failed to create folder'); } } catch (error) { console.error('Error creating folder:', error); hideLoadingOverlay(); showToast(error.message || 'Failed to create folder', 'error'); } } /** * Render loading state */ function renderLoadingState() { const container = document.getElementById('folder-tree'); if (!container) return; container.innerHTML = `
Loading folders...
`; } /** * Close the modal */ function closeFolderExplorer() { const overlay = document.getElementById('folder-explorer-overlay'); if (!overlay) return; overlay.classList.remove('visible'); const modal = document.getElementById('folder-explorer-modal'); if (modal) modal.classList.remove('visible'); setTimeout(() => { if (currentModal && currentModal.escapeHandler) { document.removeEventListener('keydown', currentModal.escapeHandler); } overlay.remove(); document.body.style.overflow = ''; currentModal = null; selectedPath = null; }, 300); } /** * Escape HTML */ function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Show loading overlay */ function showLoadingOverlay(message = 'Loading...') { let overlay = document.getElementById('loading-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'loading-overlay'; overlay.className = 'loading-overlay'; overlay.innerHTML = `

${escapeHtml(message)}

`; document.body.appendChild(overlay); } else { const textElement = overlay.querySelector('.loading-text'); if (textElement) { textElement.textContent = message; } } overlay.classList.remove('hidden'); setTimeout(() => { overlay.classList.add('visible'); }, 10); } /** * Hide loading overlay */ function hideLoadingOverlay() { const overlay = document.getElementById('loading-overlay'); if (overlay) { overlay.classList.remove('visible'); setTimeout(() => { overlay.classList.add('hidden'); }, 300); } } /** * Show toast notification */ function showToast(message, type = 'info', duration = 3000) { const existingToasts = document.querySelectorAll('.toast-notification'); existingToasts.forEach(toast => toast.remove()); const toast = document.createElement('div'); toast.className = `toast-notification toast-${type}`; toast.innerHTML = ` ${getToastIcon(type)} ${escapeHtml(message)} `; document.body.appendChild(toast); setTimeout(() => { toast.classList.add('visible'); }, 10); setTimeout(() => { toast.classList.remove('visible'); setTimeout(() => { toast.remove(); }, 300); }, duration); } /** * Get toast icon based on type */ function getToastIcon(type) { const icons = { success: '✓', error: '✕', info: 'ℹ', warning: '⚠' }; return icons[type] || icons.info; } // Export to global scope window.FolderExplorer = { show: showFolderExplorer, close: closeFolderExplorer, quickAccessClick, selectFolder, showCreateFolder }; // Add CSS styles const style = document.createElement('style'); style.textContent = ` /* Modal Overlay (reuse existing) */ .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(4px); z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 20px; opacity: 0; transition: opacity 0.2s ease; } .modal-overlay.visible { opacity: 1; } /* Folder Explorer Modal */ .folder-explorer-modal { background: #1a1a1a; border: 1px solid #333; border-radius: 16px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); width: 100%; max-width: 700px; max-height: 80vh; overflow: hidden; display: flex; flex-direction: column; transform: scale(0.95); transition: transform 0.2s ease; } .folder-explorer-modal.visible { transform: scale(1); } /* Header */ .folder-explorer-header { padding: 20px 24px; border-bottom: 1px solid #333; display: flex; justify-content: space-between; align-items: center; } .folder-explorer-title { font-size: 20px; font-weight: 600; color: #e0e0e0; margin: 0; } .folder-explorer-close { background: none; border: none; color: #888; font-size: 28px; cursor: pointer; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 8px; transition: all 0.2s ease; } .folder-explorer-close:hover { background: #252525; color: #e0e0e0; } /* Content */ .folder-explorer-content { flex: 1; overflow-y: auto; padding: 16px 24px; display: flex; flex-direction: column; gap: 16px; } /* Quick Access Bar */ .quick-access-bar { display: flex; gap: 8px; flex-wrap: wrap; } .quick-access-item { padding: 12px 16px; background: #1a1a1a; border: 1px solid #333; border-radius: 10px; cursor: pointer; display: flex; align-items: center; gap: 10px; transition: all 0.2s ease; flex: 1; min-width: 120px; } .quick-access-item:hover { background: #252525; border-color: #4a9eff; } .quick-access-item.not-exists { opacity: 0.5; } .quick-access-icon { font-size: 20px; } .quick-access-label { font-size: 14px; font-weight: 500; color: #e0e0e0; flex: 1; } .quick-access-count { font-size: 12px; color: #888; background: #252525; padding: 2px 8px; border-radius: 6px; } /* Breadcrumbs */ .breadcrumbs-bar { display: flex; align-items: center; gap: 4px; padding: 8px 0; overflow-x: auto; } .breadcrumb-item { display: flex; align-items: center; gap: 4px; cursor: pointer; padding: 4px 8px; border-radius: 6px; transition: all 0.2s ease; } .breadcrumb-item:hover { background: #252525; } .breadcrumb-icon { font-size: 14px; } .breadcrumb-text { font-size: 14px; color: #4a9eff; } .breadcrumb-separator { color: #888; font-size: 14px; } /* Folder Tree Container */ .folder-tree-container { flex: 1; background: #0d0d0d; border: 1px solid #222; border-radius: 12px; overflow-y: auto; padding: 8px; min-height: 300px; } /* Folder Node */ .folder-node { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; user-select: none; } .folder-node:hover { background: #252525; } .folder-node.selected { background: rgba(74, 158, 255, 0.15); border: 1px solid #4a9eff; } .folder-node.locked { opacity: 0.6; } .folder-expand { font-size: 12px; color: #888; width: 16px; text-align: center; } .folder-expand.hidden { visibility: hidden; } .folder-icon { font-size: 16px; } .folder-name { font-size: 14px; color: #e0e0e0; flex: 1; } .folder-count { font-size: 11px; color: #888; background: #252525; padding: 2px 6px; border-radius: 4px; } /* Folder Children (nested) */ .folder-children { display: none; } /* Footer */ .folder-explorer-footer { padding: 16px 24px; border-top: 1px solid #333; display: flex; flex-direction: column; gap: 16px; } .selected-path-display { display: flex; align-items: center; gap: 8px; padding: 12px; background: #0d0d0d; border: 1px solid #222; border-radius: 8px; } .selected-path-icon { font-size: 16px; } .selected-path-text { font-size: 13px; color: #888; font-family: monospace; flex: 1; } .footer-actions { display: flex; align-items: center; gap: 12px; justify-content: flex-end; } /* Buttons */ .btn-primary { padding: 12px 24px; background: linear-gradient(135deg, #4a9eff 0%, #a78bfa 100%); border: none; border-radius: 10px; color: white; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s ease; } .btn-primary:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(74, 158, 255, 0.4); } .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } .btn-secondary { padding: 12px 24px; background: transparent; border: 1px solid #333; border-radius: 10px; color: #e0e0e0; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s ease; } .btn-secondary:hover { background: #252525; border-color: #4a9eff; } .btn-create-folder { padding: 10px 16px; background: #252525; border: 1px solid #333; border-radius: 8px; color: #e0e0e0; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 6px; transition: all 0.2s ease; margin-right: auto; } .btn-create-folder:hover:not(:disabled) { background: #1a1a1a; border-color: #4a9eff; } .btn-create-folder:disabled { opacity: 0.5; cursor: not-allowed; } .btn-icon { font-size: 16px; line-height: 1; } /* States */ .folder-tree-loading, .folder-tree-error, .folder-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: #888; font-size: 14px; gap: 12px; } .loading-spinner-small { width: 24px; height: 24px; border: 2px solid #333; border-top-color: #4a9eff; border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .empty-icon, .error-icon { font-size: 32px; } /* Responsive */ @media (max-width: 640px) { .folder-explorer-modal { max-height: 90vh; } .folder-explorer-header, .folder-explorer-content, .folder-explorer-footer { padding: 16px; } .quick-access-bar { flex-wrap: wrap; } .quick-access-item { flex: 1 1 calc(50% - 4px); } .footer-actions { flex-wrap: wrap; } .btn-create-folder { width: 100%; margin-right: 0; margin-bottom: 8px; } .btn-secondary, .btn-primary { flex: 1; } } /* Loading Overlay */ .loading-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.8); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 20000; opacity: 0; transition: opacity 0.3s ease; } .loading-overlay.visible { opacity: 1; } .loading-overlay.hidden { display: none; } .loading-spinner { width: 40px; height: 40px; border: 3px solid rgba(255, 255, 255, 0.1); border-top-color: #4a9eff; border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .loading-text { color: #fff; margin-top: 16px; font-size: 14px; } /* Toast Notifications */ .toast-notification { position: fixed; bottom: 20px; right: 20px; background: #2a2a2a; border: 1px solid #444; border-radius: 8px; padding: 12px 16px; display: flex; align-items: center; gap: 12px; z-index: 20001; opacity: 0; transform: translateY(20px); transition: all 0.3s ease; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); } .toast-notification.visible { opacity: 1; transform: translateY(0); } .toast-icon { font-size: 18px; font-weight: bold; } .toast-success .toast-icon { color: #4ade80; } .toast-error .toast-icon { color: #f87171; } .toast-warning .toast-icon { color: #fbbf24; } .toast-info .toast-icon { color: #60a5fa; } .toast-message { color: #fff; font-size: 14px; } `; document.head.appendChild(style); console.log('[FolderExplorer] Module loaded'); })();