- Full IDE with terminal integration using xterm.js - Session management with local and web sessions - HTML preview functionality - Multi-terminal support with session picker Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
609 lines
17 KiB
JavaScript
609 lines
17 KiB
JavaScript
/**
|
||
* Sessions Landing Page JavaScript
|
||
*/
|
||
|
||
// Load sessions on page load
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
checkAuth();
|
||
initializeProjectInput();
|
||
loadSessions();
|
||
});
|
||
|
||
// Check authentication
|
||
async function checkAuth() {
|
||
try {
|
||
const res = await fetch('/claude/api/auth/status');
|
||
|
||
if (!res.ok) {
|
||
throw new Error('Request failed');
|
||
}
|
||
|
||
const data = await res.json();
|
||
|
||
if (!data.authenticated) {
|
||
// Redirect to login if not authenticated
|
||
window.location.href = '/claude/login.html';
|
||
}
|
||
} catch (error) {
|
||
console.error('Auth check failed:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Initialize project input field in hero section
|
||
*/
|
||
function initializeProjectInput() {
|
||
const input = document.getElementById('project-input');
|
||
const status = document.getElementById('input-status');
|
||
|
||
if (!input) return;
|
||
|
||
// Auto-focus on page load
|
||
input.focus();
|
||
|
||
input.addEventListener('input', () => {
|
||
const projectName = input.value.trim();
|
||
const hasInvalidChars = !validateProjectName(projectName);
|
||
|
||
if (hasInvalidChars && projectName.length > 0) {
|
||
status.textContent = 'Invalid characters';
|
||
status.classList.add('error');
|
||
} else {
|
||
status.textContent = '';
|
||
status.classList.remove('error');
|
||
}
|
||
});
|
||
|
||
input.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') {
|
||
const projectName = input.value.trim();
|
||
if (projectName && validateProjectName(projectName)) {
|
||
createProject(projectName);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Create a new project and navigate to IDE
|
||
*/
|
||
async function createProject(projectName) {
|
||
try {
|
||
showLoadingOverlay('Creating project...');
|
||
|
||
const res = await fetch('/claude/api/claude/sessions', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
metadata: {
|
||
type: 'chat',
|
||
source: 'web-ide',
|
||
project: projectName
|
||
}
|
||
})
|
||
});
|
||
|
||
if (!res.ok) throw new Error('Request failed');
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
// Minimum display time for smooth UX
|
||
await new Promise(resolve => setTimeout(resolve, 300));
|
||
window.location.href = `/claude/ide?session=${data.session.id}`;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error creating project:', error);
|
||
hideLoadingOverlay();
|
||
showToast('Failed to create project', 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load all sessions and render in table
|
||
*/
|
||
async function loadSessions() {
|
||
const tbody = document.getElementById('projects-tbody');
|
||
const emptyState = document.getElementById('projects-empty');
|
||
const table = document.getElementById('projects-table');
|
||
|
||
if (!tbody) return;
|
||
|
||
tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; padding: 40px;">Loading...</td></tr>';
|
||
|
||
try {
|
||
const res = await fetch('/claude/api/claude/sessions');
|
||
|
||
if (!res.ok) throw new Error('Request failed');
|
||
|
||
const data = await res.json();
|
||
|
||
const allSessions = [
|
||
...(data.active || []).map(s => ({...s, type: 'active'})),
|
||
...(data.historical || []).map(s => ({...s, type: 'historical'}))
|
||
];
|
||
|
||
renderProjectsTable(allSessions);
|
||
|
||
} catch (error) {
|
||
console.error('Error loading sessions:', error);
|
||
tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; color: #ff6b6b;">Failed to load</td></tr>';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Render projects table with session data
|
||
*/
|
||
function renderProjectsTable(sessions) {
|
||
const tbody = document.getElementById('projects-tbody');
|
||
const emptyState = document.getElementById('projects-empty');
|
||
const table = document.getElementById('projects-table');
|
||
|
||
if (!tbody) return;
|
||
|
||
// Sort by last activity (newest first)
|
||
sessions.sort((a, b) => {
|
||
const dateA = new Date(a.lastActivity || a.createdAt || a.created_at);
|
||
const dateB = new Date(b.lastActivity || b.createdAt || b.created_at);
|
||
return dateB - dateA;
|
||
});
|
||
|
||
tbody.innerHTML = '';
|
||
|
||
if (sessions.length === 0) {
|
||
if (table) table.style.display = 'none';
|
||
if (emptyState) emptyState.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
if (table) table.style.display = 'table';
|
||
if (emptyState) emptyState.style.display = 'none';
|
||
|
||
sessions.forEach(session => {
|
||
const row = createProjectRow(session);
|
||
tbody.appendChild(row);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Create a single project row for the table
|
||
*/
|
||
function createProjectRow(session) {
|
||
const tr = document.createElement('tr');
|
||
tr.dataset.sessionId = session.id;
|
||
|
||
const projectName = getProjectName(session);
|
||
const relativeTime = getRelativeTime(session);
|
||
const status = getStatus(session);
|
||
const statusClass = getStatusClass(session);
|
||
|
||
tr.innerHTML = `
|
||
<td data-label="Project">
|
||
<h3 class="project-name">${escapeHtml(projectName)}</h3>
|
||
</td>
|
||
<td data-label="Last Activity">
|
||
<span class="last-activity">${relativeTime}</span>
|
||
</td>
|
||
<td data-label="Status">
|
||
<span class="status-badge ${statusClass}">${status}</span>
|
||
</td>
|
||
<td data-label="Actions">
|
||
<button class="btn-continue">Continue</button>
|
||
<button class="btn-menu" aria-label="Menu">⋮</button>
|
||
</td>
|
||
`;
|
||
|
||
tr.addEventListener('click', (e) => {
|
||
if (!e.target.closest('.btn-menu')) {
|
||
continueToSession(session.id);
|
||
}
|
||
});
|
||
|
||
tr.querySelector('.btn-continue').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
continueToSession(session.id);
|
||
});
|
||
|
||
tr.querySelector('.btn-menu').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
showProjectMenu(e, session);
|
||
});
|
||
|
||
return tr;
|
||
}
|
||
|
||
/**
|
||
* Extract project name from session metadata
|
||
*/
|
||
function getProjectName(session) {
|
||
return session.metadata?.project ||
|
||
session.metadata?.projectName ||
|
||
session.workingDir?.split('/').pop() ||
|
||
'Session ' + session.id.substring(0, 8);
|
||
}
|
||
|
||
/**
|
||
* Get relative time string for session
|
||
*/
|
||
function getRelativeTime(session) {
|
||
const date = new Date(session.lastActivity || session.createdAt || session.created_at);
|
||
const now = new Date();
|
||
const diffMins = Math.floor((now - date) / 60000);
|
||
const diffHours = Math.floor((now - date) / 3600000);
|
||
const diffDays = Math.floor((now - date) / 86400000);
|
||
|
||
if (diffMins < 1) return 'Just now';
|
||
if (diffMins < 60) return `${diffMins}m ago`;
|
||
if (diffHours < 24) return `${diffHours}h ago`;
|
||
if (diffDays < 7) return `${diffDays}d ago`;
|
||
return date.toLocaleDateString();
|
||
}
|
||
|
||
/**
|
||
* Get status text for session
|
||
*/
|
||
function getStatus(session) {
|
||
if (session.status === 'running') return 'Active';
|
||
return 'Done';
|
||
}
|
||
|
||
/**
|
||
* Get status CSS class for session
|
||
*/
|
||
function getStatusClass(session) {
|
||
if (session.status === 'running') return 'active';
|
||
return 'done';
|
||
}
|
||
|
||
/**
|
||
* Navigate to IDE with session
|
||
*/
|
||
async function continueToSession(sessionId) {
|
||
showLoadingOverlay('Opening workspace...');
|
||
await new Promise(resolve => setTimeout(resolve, 300));
|
||
window.location.href = `/claude/ide?session=${sessionId}`;
|
||
}
|
||
|
||
/**
|
||
* Show project menu dropdown
|
||
*/
|
||
function showProjectMenu(event, session) {
|
||
closeProjectMenu();
|
||
|
||
const menu = document.createElement('div');
|
||
menu.className = 'dropdown-menu';
|
||
menu.id = 'project-menu';
|
||
|
||
menu.innerHTML = `
|
||
<button class="dropdown-menu-item" data-action="rename">✏️ Rename</button>
|
||
<button class="dropdown-menu-item" data-action="duplicate">📋 Duplicate</button>
|
||
<button class="dropdown-menu-item danger" data-action="delete">🗑️ Delete</button>
|
||
`;
|
||
|
||
const rect = event.target.getBoundingClientRect();
|
||
menu.style.position = 'fixed';
|
||
menu.style.top = `${rect.bottom + 8}px`;
|
||
menu.style.right = `${window.innerWidth - rect.right}px`;
|
||
|
||
document.body.appendChild(menu);
|
||
|
||
menu.querySelector('[data-action="rename"]').addEventListener('click', () => {
|
||
closeProjectMenu();
|
||
renameProject(session);
|
||
});
|
||
|
||
menu.querySelector('[data-action="duplicate"]').addEventListener('click', async () => {
|
||
closeProjectMenu();
|
||
await duplicateProject(session);
|
||
loadSessions();
|
||
});
|
||
|
||
menu.querySelector('[data-action="delete"]').addEventListener('click', async () => {
|
||
closeProjectMenu();
|
||
await deleteProject(session);
|
||
loadSessions();
|
||
});
|
||
|
||
setTimeout(() => {
|
||
document.addEventListener('click', closeProjectMenu, { once: true });
|
||
}, 10);
|
||
}
|
||
|
||
/**
|
||
* Close project menu dropdown
|
||
*/
|
||
function closeProjectMenu() {
|
||
document.getElementById('project_menu')?.remove();
|
||
}
|
||
|
||
/**
|
||
* Rename project (inline edit)
|
||
*/
|
||
function renameProject(session) {
|
||
const row = document.querySelector(`tr[data-session-id="${session.id}"]`);
|
||
if (!row) return;
|
||
|
||
const nameCell = row.querySelector('.project-name');
|
||
if (!nameCell) return;
|
||
|
||
const currentName = nameCell.textContent;
|
||
|
||
// Only block truly historical sessions (loaded from disk, not in memory)
|
||
// Active and terminated sessions in memory can be renamed
|
||
if (session.type === 'historical') {
|
||
showToast('Cannot rename historical sessions', 'warning');
|
||
return;
|
||
}
|
||
|
||
// Create input element
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.className = 'project-name-input';
|
||
input.value = currentName;
|
||
input.maxLength = 50;
|
||
|
||
// Replace text with input
|
||
nameCell.innerHTML = '';
|
||
nameCell.appendChild(input);
|
||
input.focus();
|
||
input.select();
|
||
|
||
// Handle save
|
||
const saveRename = async () => {
|
||
const newName = input.value.trim();
|
||
|
||
// Validation
|
||
if (!newName) {
|
||
nameCell.textContent = currentName;
|
||
showToast('Project name cannot be empty', 'error');
|
||
return;
|
||
}
|
||
|
||
if (!validateProjectName(newName)) {
|
||
nameCell.textContent = currentName;
|
||
showToast('Project name contains invalid characters', 'error');
|
||
return;
|
||
}
|
||
|
||
if (newName === currentName) {
|
||
nameCell.textContent = currentName;
|
||
return;
|
||
}
|
||
|
||
// Show saving state
|
||
input.disabled = true;
|
||
input.style.opacity = '0.6';
|
||
|
||
try {
|
||
const res = await fetch(`/claude/api/claude/sessions/${session.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
metadata: {
|
||
...session.metadata,
|
||
project: newName
|
||
}
|
||
})
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const data = await res.json();
|
||
throw new Error(data.error || 'Failed to rename');
|
||
}
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
nameCell.textContent = newName;
|
||
showToast('Project renamed successfully', 'success');
|
||
} else {
|
||
throw new Error(data.error || 'Failed to rename');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error renaming project:', error);
|
||
nameCell.textContent = currentName;
|
||
showToast(error.message || 'Failed to rename project', 'error');
|
||
}
|
||
};
|
||
|
||
// Handle cancel
|
||
const cancelRename = () => {
|
||
nameCell.textContent = currentName;
|
||
};
|
||
|
||
// Event listeners
|
||
input.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') {
|
||
input.blur(); // Trigger save on blur
|
||
} else if (e.key === 'Escape') {
|
||
cancelRename();
|
||
}
|
||
});
|
||
|
||
input.addEventListener('blur', () => {
|
||
// Small delay to allow Enter key to process first
|
||
setTimeout(() => {
|
||
if (document.activeElement !== input) {
|
||
saveRename();
|
||
}
|
||
}, 100);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Duplicate project/session
|
||
*/
|
||
async function duplicateProject(session) {
|
||
try {
|
||
showLoadingOverlay('Duplicating project...');
|
||
|
||
const res = await fetch(`/claude/api/claude/sessions/${session.id}/duplicate`, {
|
||
method: 'POST'
|
||
});
|
||
|
||
if (!res.ok) throw new Error('Request failed');
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
hideLoadingOverlay();
|
||
showToast('Project duplicated successfully', 'success');
|
||
} else {
|
||
throw new Error(data.error || 'Failed to duplicate');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error duplicating project:', error);
|
||
hideLoadingOverlay();
|
||
showToast('Failed to duplicate project', 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Delete project/session with confirmation
|
||
*/
|
||
async function deleteProject(session) {
|
||
const projectName = getProjectName(session);
|
||
|
||
if (!confirm(`Are you sure you want to delete "${projectName}"? This action cannot be undone.`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
showLoadingOverlay('Deleting project...');
|
||
|
||
const res = await fetch(`/claude/api/claude/sessions/${session.id}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (!res.ok) throw new Error('Request failed');
|
||
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
hideLoadingOverlay();
|
||
showToast('Project deleted successfully', 'success');
|
||
} else {
|
||
throw new Error(data.error || 'Failed to delete');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error deleting project:', error);
|
||
hideLoadingOverlay();
|
||
showToast('Failed to delete project', 'error');
|
||
}
|
||
}
|
||
|
||
// Refresh sessions
|
||
function refreshSessions() {
|
||
loadSessions();
|
||
}
|
||
|
||
/**
|
||
* Validate project name for invalid characters
|
||
* @param {string} name - The project name to validate
|
||
* @returns {boolean} - True if valid, false if contains invalid characters
|
||
*/
|
||
function validateProjectName(name) {
|
||
const invalidChars = /[\/\\<>:"|?*]/;
|
||
return !invalidChars.test(name);
|
||
}
|
||
|
||
/**
|
||
* Show loading overlay
|
||
* @param {string} message - Optional custom message (default: "Loading...")
|
||
*/
|
||
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 = `
|
||
<div class="loading-spinner"></div>
|
||
<p class="loading-text">${escapeHtml(message)}</p>
|
||
`;
|
||
document.body.appendChild(overlay);
|
||
} else {
|
||
// Update message if provided
|
||
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
|
||
* @param {string} message - The message to display
|
||
* @param {string} type - The type of toast: 'success', 'error', 'info'
|
||
* @param {number} duration - Duration in milliseconds (default: 3000)
|
||
*/
|
||
function showToast(message, type = 'info', duration = 3000) {
|
||
// Remove existing toasts
|
||
const existingToasts = document.querySelectorAll('.toast-notification');
|
||
existingToasts.forEach(toast => toast.remove());
|
||
|
||
// Create toast element
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast-notification toast-${type}`;
|
||
toast.innerHTML = `
|
||
<span class="toast-icon">${getToastIcon(type)}</span>
|
||
<span class="toast-message">${escapeHtml(message)}</span>
|
||
`;
|
||
|
||
document.body.appendChild(toast);
|
||
|
||
// Trigger animation
|
||
setTimeout(() => {
|
||
toast.classList.add('visible');
|
||
}, 10);
|
||
|
||
// Auto remove after duration
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Escape HTML to prevent XSS
|
||
*/
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|