/**
* 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 = `
`;
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 project
*/
async function selectFolder() {
if (!selectedPath) {
showToast('Please select a folder first', 'warning');
return;
}
try {
showLoadingOverlay('Validating folder...');
// Validate path
const res = await fetch(`/api/filesystem/validate?path=${encodeURIComponent(selectedPath)}`, {
credentials: 'same-origin'
});
if (!res.ok) {
throw new Error('Failed to validate folder');
}
const data = await res.json();
if (!data.valid || !data.exists || !data.isDirectory) {
throw new Error('Invalid folder selection');
}
hideLoadingOverlay();
// Create project
const projectName = selectedPath.split('/').filter(Boolean).pop();
console.log('[FolderExplorer] Creating project:', { name: projectName, path: selectedPath });
const createRes = await fetch('/api/projects', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: projectName,
path: selectedPath,
icon: '📁'
})
});
if (!createRes.ok) {
// Try to get error details from server
let errorMsg = `Failed to create project (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();
if (createData.success) {
closeFolderExplorer();
showToast(`Project "${projectName}" created!`, 'success');
// Reload projects list and open session picker
if (typeof refreshProjects === 'function') {
await refreshProjects();
}
// Open session picker for the new project
if (window.SessionPicker && createData.project) {
window.SessionPicker.show(createData.project);
}
} else {
throw new Error(createData.error || 'Failed to create project');
}
} catch (error) {
console.error('Error selecting folder:', error);
hideLoadingOverlay();
showToast(error.message || 'Failed to create project', '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 = `
`;
}
/**
* 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');
})();