UNPKG

ccheckpoints

Version:

Checkpoint system for Claude Code CLI - Track and visualize your coding sessions with automatic checkpoint creation

1,357 lines (1,185 loc) 67.7 kB
class CCheckpointApp { constructor() { this.ws = null; this.reconnectInterval = null; this.selectedProject = null; this.selectedCheckpoint = null; this.projectCheckpoints = []; this.cachedStats = null; this.pendingAction = null; this.currentView = 'projects'; // projects, checkpoints, details this.init(); } async init() { console.log('🚀 Initializing CCheckpoint App...'); this.setupWebSocket(); await this.loadInitialData(); this.setupEventHandlers(); this.showView('projects'); console.log('✅ CCheckpoint App initialized'); } setupWebSocket() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}`; console.log(`🔌 Connecting to WebSocket: ${wsUrl}`); this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { console.log('📡 WebSocket connected'); this.updateConnectionStatus(true); if (this.reconnectInterval) { clearInterval(this.reconnectInterval); this.reconnectInterval = null; } }; this.ws.onmessage = (event) => { try { const message = JSON.parse(event.data); console.log('📨 WebSocket message received:', message); this.handleWebSocketMessage(message); } catch (error) { console.error('Error parsing WebSocket message:', error); } }; this.ws.onclose = () => { console.log('📡 WebSocket disconnected'); this.updateConnectionStatus(false); this.scheduleReconnect(); }; this.ws.onerror = (error) => { console.error('WebSocket error:', error); this.updateConnectionStatus(false); }; } scheduleReconnect() { if (this.reconnectInterval) return; console.log('🔄 Scheduling WebSocket reconnection...'); this.reconnectInterval = setInterval(() => { console.log('🔄 Attempting WebSocket reconnection...'); this.setupWebSocket(); }, 5000); } handleWebSocketMessage(message) { switch (message.type) { case 'session_start': if (message.data && message.data.projectName) { this.showToast(`New prompt received for ${message.data.projectName}`, 'info'); } // Only refresh projects view data, don't force navigation if (this.currentView === 'projects') { this.loadInitialData(); } break; case 'checkpoint_created': if (message.data && message.data.projectName) { this.showToast(`New checkpoint created for ${message.data.projectName}`, 'success'); } else { this.showToast('New checkpoint created', 'success'); } // Update projects stats only if in projects view if (this.currentView === 'projects') { this.loadInitialData(); } // Refresh checkpoints list if currently viewing checkpoints if (this.currentView === 'checkpoints' && this.selectedProject) { console.log('🔄 WebSocket: Refreshing checkpoints list for', this.selectedProject.path); this.refreshCheckpointsList(this.selectedProject.path); } else { console.log('🔄 WebSocket: Not refreshing checkpoints - currentView:', this.currentView, 'selectedProject:', this.selectedProject); } break; case 'session_stop': break; default: console.log('Unknown WebSocket message type:', message.type); } } updateConnectionStatus(connected) { const statusDot = document.getElementById('connection-status'); const statusText = document.getElementById('connection-text'); if (connected) { statusDot.classList.remove('disconnected'); statusText.textContent = 'Connected'; } else { statusDot.classList.add('disconnected'); statusText.textContent = 'Disconnected'; } } async loadInitialData() { // Show loading state in projects table if currently in projects view if (this.currentView === 'projects') { const tbody = document.getElementById('projects-tbody'); if (tbody) { tbody.innerHTML = ` <tr> <td colspan="5" class="empty-row"> <div class="empty-state"> <div class="empty-text">Loading projects...</div> </div> </td> </tr> `; } } try { console.log('📊 Loading initial data...'); const response = await fetch('/api/stats', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); const result = await response.json(); if (result.success) { this.cachedStats = result.data; if (this.currentView === 'projects') { this.renderProjectsView(); } console.log('✅ Initial data loaded'); } else { throw new Error(result.error || 'Failed to load stats'); } } catch (error) { console.error('Error loading initial data:', error); this.showToast('Failed to load data', 'error'); // Show error state in projects table if currently in projects view if (this.currentView === 'projects') { const tbody = document.getElementById('projects-tbody'); if (tbody) { tbody.innerHTML = ` <tr> <td colspan="5" class="empty-row"> <div class="empty-state"> <div class="empty-text">Failed to load projects</div> <div class="empty-subtext">Please refresh the page to try again</div> </div> </td> </tr> `; } } } } // View Management showView(view) { this.currentView = view; // Hide all views document.getElementById('projects-view').style.display = 'none'; document.getElementById('checkpoints-view').style.display = 'none'; document.getElementById('details-view').style.display = 'none'; // Show the requested view document.getElementById(`${view}-view`).style.display = 'block'; // Update header title and breadcrumb this.updateHeaderTitle(); this.updateBreadcrumb(); this.updateBackButton(); } updateHeaderTitle() { const titleEl = document.getElementById('page-title'); const subtitleEl = document.getElementById('page-subtitle'); switch (this.currentView) { case 'projects': titleEl.textContent = 'Projects'; subtitleEl.textContent = 'Select a project to view its checkpoints'; break; case 'checkpoints': titleEl.textContent = this.selectedProject?.name || 'Checkpoints'; subtitleEl.textContent = `${this.projectCheckpoints.length} checkpoints found`; break; case 'details': titleEl.textContent = 'Checkpoint Details'; subtitleEl.textContent = this.selectedCheckpoint?.message || ''; break; } } updateBreadcrumb() { const breadcrumbEl = document.getElementById('breadcrumb'); const projectItem = document.getElementById('breadcrumb-project'); const projectLink = document.getElementById('breadcrumb-project-link'); const checkpointItem = document.getElementById('breadcrumb-checkpoint'); const checkpointText = document.getElementById('breadcrumb-checkpoint-text'); const separator2 = document.getElementById('breadcrumb-separator-2'); // Check if all elements exist before proceeding if (!breadcrumbEl || !projectItem || !projectLink || !checkpointItem || !checkpointText || !separator2) { console.warn('Breadcrumb elements not found, skipping update'); return; } // Show/hide breadcrumb based on current view if (this.currentView === 'projects') { breadcrumbEl.style.display = 'none'; } else { breadcrumbEl.style.display = 'flex'; } // Update project breadcrumb if (this.selectedProject && (this.currentView === 'checkpoints' || this.currentView === 'details')) { projectItem.style.display = 'inline'; projectLink.textContent = `📋 ${this.selectedProject.name}`; projectLink.onclick = () => this.showCheckpoints(); } else { projectItem.style.display = 'none'; } // Update checkpoint breadcrumb if (this.selectedCheckpoint && this.currentView === 'details') { separator2.style.display = 'inline'; checkpointItem.style.display = 'inline'; const truncatedMessage = this.truncateMessage(this.selectedCheckpoint.message, 30); checkpointText.textContent = `📄 ${truncatedMessage}`; } else { separator2.style.display = 'none'; checkpointItem.style.display = 'none'; } } updateBackButton() { const backBtn = document.getElementById('back-btn'); if (!backBtn) { console.warn('Back button element not found, skipping update'); return; } if (this.currentView === 'projects') { backBtn.classList.remove('show'); } else { backBtn.classList.add('show'); } } // Projects View renderProjectsView() { const projects = this.cachedStats?.projects || []; document.getElementById('projects-count').textContent = projects.length; const tbody = document.getElementById('projects-tbody'); if (projects.length === 0) { tbody.innerHTML = ` <tr> <td colspan="5" class="empty-row"> <div class="empty-state"> <div class="empty-text">No projects with checkpoints found</div> <div class="empty-subtext">Start using Claude Code to create checkpoints</div> </div> </td> </tr> `; return; } tbody.innerHTML = projects.map((project, index) => ` <tr class="table-row" data-project-path="${project.projectPath}" data-project-name="${project.projectName}"> <td class="row-number">${index + 1}</td> <td class="project-info"> <div class="project-name">${project.projectName}</div> <div class="project-path">${project.projectPath}</div> </td> <td class="project-checkpoints"> ${project.totalCheckpoints} </td> <td class="project-last-checkpoint"> ${this.formatTimeAgo(project.lastSessionTime)} </td> <td class="project-actions-cell"> <div class="table-actions"> <button class="delete-btn" data-project-path="${project.projectPath}" data-project-name="${project.projectName}" title="Delete all checkpoints"> Delete </button> </div> </td> </tr> `).join(''); // Add event listeners for project selection and deletion tbody.querySelectorAll('.table-row').forEach(row => { row.addEventListener('click', (e) => { if (!e.target.closest('.delete-btn')) { const projectPath = row.dataset.projectPath; const projectName = row.dataset.projectName; this.selectProject(projectPath, projectName); } }); }); tbody.querySelectorAll('.delete-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const projectPath = btn.dataset.projectPath; const projectName = btn.dataset.projectName; this.confirmDeleteProject(projectPath, projectName, btn); }); }); } selectProject(projectPath, projectName) { console.log(`📁 Selecting project: ${projectName}`); this.selectedProject = { path: projectPath, name: projectName }; this.selectedCheckpoint = null; this.loadProjectCheckpoints(projectPath); } async loadProjectCheckpoints(projectPath) { // Switch to checkpoints view and show loading state this.showView('checkpoints'); const tbody = document.getElementById('checkpoints-tbody'); if (tbody) { tbody.innerHTML = ` <tr> <td colspan="4" class="empty-row"> <div class="empty-state"> <div class="empty-icon">📋</div> <div class="empty-text">Loading checkpoints...</div> </div> </td> </tr> `; } try { console.log(`📋 Loading checkpoints for: ${projectPath}`); const response = await fetch('/api/checkpoints', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectPath }) }); const result = await response.json(); if (result.success) { this.projectCheckpoints = result.data || []; this.renderCheckpointsView(); console.log(`✅ Loaded ${this.projectCheckpoints.length} checkpoints`); } else { throw new Error(result.error || 'Failed to load checkpoints'); } } catch (error) { console.error('Error loading project checkpoints:', error); this.showToast('Failed to load checkpoints', 'error'); // Show error state in checkpoints table if (tbody) { tbody.innerHTML = ` <tr> <td colspan="4" class="empty-row"> <div class="empty-state"> <div class="empty-icon">⚠️</div> <div class="empty-text">Failed to load checkpoints</div> <div class="empty-subtext">Please try again or go back to projects</div> </div> </td> </tr> `; } } } async refreshCheckpointsList(projectPath) { try { console.log(`🔄 Refreshing checkpoints for: ${projectPath}`); console.log(`🔍 Current projectCheckpoints.length before refresh: ${this.projectCheckpoints.length}`); // Store current selection info BEFORE making API call const selectedCheckpointId = this.selectedCheckpoint?.id; const wasSelected = !!selectedCheckpointId; const response = await fetch('/api/checkpoints', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectPath }) }); const result = await response.json(); console.log(`📊 API response:`, result); if (result.success) { this.projectCheckpoints = result.data || []; console.log(`📋 Set projectCheckpoints.length to: ${this.projectCheckpoints.length}`); // Re-render the checkpoints view, skipping automatic selection restore this.renderCheckpointsView(true); // If there was a selection and it still exists, re-fetch its details to ensure fresh data if (wasSelected && selectedCheckpointId) { const stillExists = this.projectCheckpoints.find(cp => cp.id === selectedCheckpointId); if (stillExists) { console.log(`🔄 Re-selecting checkpoint ${selectedCheckpointId} to refresh details`); // Small delay to ensure DOM is ready setTimeout(() => { this.selectCheckpoint(selectedCheckpointId); }, 50); } } console.log(`✅ Refreshed ${this.projectCheckpoints.length} checkpoints`); } else { console.error('❌ API response not successful:', result); throw new Error(result.error || 'Failed to refresh checkpoints'); } } catch (error) { console.error('Error refreshing checkpoints:', error); this.showToast('Failed to refresh checkpoints', 'error'); } } // Checkpoints View async renderCheckpointsView(skipSelectionRestore = false) { const tbody = document.getElementById('checkpoints-tbody'); // Debug logging console.log('renderCheckpointsView called, skipSelectionRestore:', skipSelectionRestore); console.log('projectCheckpoints:', this.projectCheckpoints); console.log('tbody element:', tbody); // Remember the currently selected checkpoint ID const selectedCheckpointId = this.selectedCheckpoint?.id; if (!this.projectCheckpoints || this.projectCheckpoints.length === 0) { tbody.innerHTML = ` <tr> <td colspan="4" class="empty-row"> <div class="empty-state"> <div class="empty-icon">📋</div> <div class="empty-text">No checkpoints found</div> <div class="empty-subtext">This project doesn't have any checkpoints yet</div> </div> </td> </tr> `; // Clear selection since there are no checkpoints this.clearCheckpointSelection(); return; } // Render checkpoints with change detection const checkpointRows = await Promise.all(this.projectCheckpoints.map(async (checkpoint, index) => { const hasChanges = await this.checkpointHasChanges(checkpoint, index); const isDisabled = !hasChanges; return ` <tr class="table-row ${isDisabled ? 'disabled-checkpoint' : ''}" ${isDisabled ? '' : `onclick="window.app.selectCheckpoint('${checkpoint.id}')"`} style="${isDisabled ? 'cursor: not-allowed; opacity: 0.6; color: #ff6b6b;' : ''}"> <td class="row-number">${index + 1}</td> <td class="checkpoint-message">${this.truncateMessage(checkpoint.message, 60)}</td> <td class="checkpoint-stats"> <div class="stat-item">📄 ${checkpoint.fileCount}</div> <div class="stat-item">📦 ${this.formatFileSize(checkpoint.totalSize)}</div> </td> <td class="checkpoint-time">${this.formatTimestamp(checkpoint.timestamp)}</td> </tr> `; })); tbody.innerHTML = checkpointRows.join(''); // If there was a selected checkpoint, try to restore the selection (unless skipping) if (selectedCheckpointId && !skipSelectionRestore) { const stillExists = this.projectCheckpoints.find(cp => cp.id === selectedCheckpointId); if (stillExists) { // Restore the selection without making an API call this.selectedCheckpoint = stillExists; this.highlightSelectedCheckpoint(selectedCheckpointId); await this.showCheckpointDetails(stillExists); } else { // Selected checkpoint no longer exists, clear selection this.clearCheckpointSelection(); } } else if (skipSelectionRestore) { // Just clear any highlighting, selection will be handled manually document.querySelectorAll('.table-row').forEach(row => { row.classList.remove('selected'); }); } else { // No previous selection, clear any stray highlighting this.clearCheckpointSelection(); } } highlightSelectedCheckpoint(checkpointId) { // Remove previous highlighting document.querySelectorAll('.table-row').forEach(row => { row.classList.remove('selected'); }); // Highlight the selected row const selectedRow = document.querySelector(`[onclick*="${checkpointId}"]`); if (selectedRow) { selectedRow.classList.add('selected'); } } async showCheckpointDetails(checkpoint) { // Show details in right panel await this.renderCheckpointDetails(); } async selectCheckpoint(checkpointId) { try { console.log(`📋 Selecting checkpoint: ${checkpointId}`); const response = await fetch('/api/checkpoint', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ checkpointId }) }); const result = await response.json(); if (result.success) { this.selectedCheckpoint = result.data; // Highlight selected row and show details this.highlightSelectedCheckpoint(checkpointId); await this.showCheckpointDetails(result.data); console.log('✅ Checkpoint selected'); } else { throw new Error(result.error || 'Failed to load checkpoint details'); } } catch (error) { console.error('Error selecting checkpoint:', error); this.showToast('Failed to load checkpoint details', 'error'); } } // Checkpoint Details for Split Panel async renderCheckpointDetails() { const checkpoint = this.selectedCheckpoint; // Hide empty state and show details content const emptyDetails = document.querySelector('.empty-details'); const detailsContent = document.getElementById('checkpoint-details-content'); if (emptyDetails) emptyDetails.style.display = 'none'; if (detailsContent) detailsContent.style.display = 'flex'; // Update action buttons in split panel const diffBtn = detailsContent?.querySelector('#diff-btn'); const restoreBtn = detailsContent?.querySelector('#restore-btn'); const deleteBtn = detailsContent?.querySelector('#delete-checkpoint-btn'); if (diffBtn) { await this.updateDiffButtonState(checkpoint); // Remove any existing event listeners and add new one diffBtn.onclick = null; diffBtn.onclick = (e) => { e.preventDefault(); e.stopPropagation(); this.showCheckpointDiff(); }; } if (restoreBtn) { restoreBtn.onclick = null; restoreBtn.onclick = (e) => { e.preventDefault(); e.stopPropagation(); this.restoreCheckpoint(); }; } if (deleteBtn) { deleteBtn.onclick = null; deleteBtn.onclick = (e) => { e.preventDefault(); e.stopPropagation(); this.confirmDeleteCheckpoint(); }; } // Render checkpoint info in split panel const infoContainer = detailsContent?.querySelector('#checkpoint-info'); if (infoContainer) { infoContainer.innerHTML = ` <div class="info-grid"> <div class="info-item"> <div class="info-label">Project</div> <div class="info-value">${checkpoint.projectName}</div> </div> <div class="info-item"> <div class="info-label">Created</div> <div class="info-value">${this.formatTimestamp(checkpoint.timestamp)}</div> </div> <div class="info-item"> <div class="info-label">Files</div> <div class="info-value">${checkpoint.fileCount}</div> </div> <div class="info-item"> <div class="info-label">Total Size</div> <div class="info-value">${this.formatFileSize(checkpoint.totalSize)}</div> </div> ${checkpoint.userPrompt && checkpoint.userPrompt !== 'User prompt submitted' ? ` <div class="info-item" style="grid-column: 1 / -1;"> <div class="info-label">User Prompt</div> <div class="info-value user-prompt">${checkpoint.userPrompt}</div> </div> ` : ''} </div> `; } // Render changed files table in split panel const filesTbody = detailsContent?.querySelector('#files-tbody'); if (filesTbody) { await this.renderChangedFilesInPanel(checkpoint, filesTbody); } } async renderChangedFilesInPanel(checkpoint, tbody) { try { // Get diff data to show only changed files const currentIndex = this.projectCheckpoints.findIndex(cp => cp.id === checkpoint.id); if (currentIndex === -1 || currentIndex === this.projectCheckpoints.length - 1) { // No previous checkpoint to compare with, show message tbody.innerHTML = ` <tr> <td colspan="4" class="empty-row"> <div class="empty-state"> <div class="empty-icon">📝</div> <div class="empty-text">No changes to display</div> <div class="empty-subtext">This is the first checkpoint or no previous checkpoint available</div> </div> </td> </tr> `; return; } const previousCheckpoint = this.projectCheckpoints[currentIndex + 1]; // Get diff data const response = await fetch('/api/checkpoint/diff', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentId: checkpoint.id, previousId: previousCheckpoint.id }) }); const result = await response.json(); if (!result.success) { throw new Error(result.error || 'Failed to get diff data'); } const changes = result.data.changes || []; if (changes.length === 0) { tbody.innerHTML = ` <tr> <td colspan="4" class="empty-row"> <div class="empty-state"> <div class="empty-icon">📝</div> <div class="empty-text">No file changes detected</div> <div class="empty-subtext">All files remained unchanged in this checkpoint</div> </div> </td> </tr> `; return; } // Render changed files with color coding tbody.innerHTML = changes.map((change, index) => { const changeTypeColor = change.type === 'added' ? '#2ea043' : change.type === 'deleted' ? '#f85149' : '#1f6feb'; const changeTypeIcon = change.type === 'added' ? '📄' : change.type === 'deleted' ? '🗑️' : '✏️'; const changeTypeText = change.type === 'added' ? 'Added' : change.type === 'deleted' ? 'Deleted' : 'Modified'; return ` <tr class="table-row" style="cursor: pointer;" onclick="app.openDiffForFile('${change.file}')"> <td class="row-number">${index + 1}</td> <td class="file-path">${change.file}</td> <td> <span class="${change.type}-badge" style="color: ${changeTypeColor};"> ${changeTypeIcon} ${changeTypeText} </span> </td> <td class="file-info"> <div class="file-size" style="color: var(--text-muted);"> ${change.type === 'deleted' ? 'File removed' : change.type === 'added' ? 'New file' : 'Modified'} </div> </td> </tr> `; }).join(''); } catch (error) { console.error('Error loading changed files:', error); tbody.innerHTML = ` <tr> <td colspan="4" class="empty-row"> <div class="empty-state"> <div class="empty-icon">⚠️</div> <div class="empty-text">Error loading changed files</div> <div class="empty-subtext">${error.message}</div> </div> </td> </tr> `; } } renderFilesTableInPanel(files, tbody) { if (files.length === 0) { tbody.innerHTML = ` <tr> <td colspan="4" class="empty-row"> <div class="empty-state"> <div class="empty-icon">📄</div> <div class="empty-text">No file details available</div> </div> </td> </tr> `; return; } tbody.innerHTML = files.map((file, index) => ` <tr class="table-row"> <td class="row-number">${index + 1}</td> <td class="file-path">${file.relativePath}</td> <td class="file-info"> <div class="file-size">${this.formatFileSize(file.size)}</div> <div class="file-modified">Modified: ${this.formatTimestamp(file.modifiedTime)}</div> </td> </tr> `).join(''); } clearCheckpointSelection() { // Clear selected checkpoint this.selectedCheckpoint = null; // Remove selection highlighting document.querySelectorAll('.table-row').forEach(row => { row.classList.remove('selected'); }); // Show empty state and hide details content const emptyDetails = document.querySelector('.empty-details'); const detailsContent = document.getElementById('checkpoint-details-content'); if (emptyDetails) emptyDetails.style.display = 'flex'; if (detailsContent) detailsContent.style.display = 'none'; } // Details View async renderDetailsView() { const checkpoint = this.selectedCheckpoint; // Update action buttons await this.updateDiffButtonState(checkpoint); // Render checkpoint info const infoContainer = document.getElementById('checkpoint-info'); infoContainer.innerHTML = ` <div class="info-grid"> <div class="info-item"> <div class="info-label">Project</div> <div class="info-value">${checkpoint.projectName}</div> </div> <div class="info-item"> <div class="info-label">Created</div> <div class="info-value">${this.formatTimestamp(checkpoint.timestamp)}</div> </div> <div class="info-item"> <div class="info-label">Files</div> <div class="info-value">${checkpoint.fileCount}</div> </div> <div class="info-item"> <div class="info-label">Total Size</div> <div class="info-value">${this.formatFileSize(checkpoint.totalSize)}</div> </div> ${checkpoint.userPrompt && checkpoint.userPrompt !== 'User prompt submitted' ? ` <div class="info-item" style="grid-column: 1 / -1;"> <div class="info-label">User Prompt</div> <div class="info-value user-prompt">${checkpoint.userPrompt}</div> </div> ` : ''} </div> `; // Render files table this.renderFilesTable(checkpoint.files || []); } renderFilesTable(files) { const tbody = document.getElementById('files-tbody'); if (files.length === 0) { tbody.innerHTML = ` <tr> <td colspan="4" class="empty-row"> <div class="empty-state"> <div class="empty-icon">📄</div> <div class="empty-text">No file details available</div> </div> </td> </tr> `; return; } tbody.innerHTML = files.map((file, index) => ` <tr class="table-row"> <td class="row-number">${index + 1}</td> <td class="file-path">${file.relativePath}</td> <td class="file-info"> <div class="file-size">${this.formatFileSize(file.size)}</div> <div class="file-modified">Modified: ${this.formatTimestamp(file.modifiedTime)}</div> </td> </tr> `).join(''); } // Navigation goBack() { switch (this.currentView) { case 'checkpoints': this.showProjects(); break; case 'details': this.showCheckpoints(); break; } } async showProjects() { this.selectedProject = null; this.selectedCheckpoint = null; this.clearCheckpointSelection(); this.showView('projects'); // Refresh stats when navigating back to projects view this.cachedStats = null; await this.loadInitialData(); } showCheckpoints() { if (!this.selectedProject) return; this.selectedCheckpoint = null; this.clearCheckpointSelection(); this.showView('checkpoints'); this.renderCheckpointsView(); } // Actions canShowDiff(checkpoint) { if (!this.projectCheckpoints) return false; const checkpointIndex = this.projectCheckpoints.findIndex(c => c.id === checkpoint.id); return checkpointIndex < this.projectCheckpoints.length - 1; } confirmDeleteProject(projectPath, projectName, buttonElement = null) { this.showModal( 'Delete Project Checkpoints', `Are you sure you want to delete all checkpoints for "${projectName}"? This action cannot be undone.`, () => this.deleteProject(projectPath, projectName, buttonElement) ); } confirmClearAllCheckpoints() { this.showModal( 'Clear All Checkpoints', 'Are you sure you want to delete ALL checkpoints from ALL projects? This will not delete the projects themselves, only their checkpoint history. This action cannot be undone.', () => this.clearAllCheckpoints() ); } async deleteProject(projectPath, projectName, buttonElement = null) { // Set loading state for specific delete button if provided if (buttonElement) { this.setButtonLoading(buttonElement, true, 'Deleting...'); } try { console.log(`🗑️ Deleting project: ${projectName}`); const response = await fetch('/api/project/checkpoints', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectPath }) }); const result = await response.json(); if (result.success) { this.showToast(`Deleted all checkpoints for ${projectName}`, 'success'); // If we're viewing this project, go back to projects list if (this.selectedProject?.path === projectPath) { this.showProjects(); } this.loadInitialData(); console.log('✅ Project deleted successfully'); } else { throw new Error(result.error || 'Failed to delete project'); } } catch (error) { console.error('Error deleting project:', error); this.showToast('Failed to delete project checkpoints', 'error'); } finally { // Remove loading state regardless of success/failure if (buttonElement) { this.setButtonLoading(buttonElement, false); } } } async clearAllCheckpoints() { // Set loading state for Clear All button this.setButtonLoading('#clear-all-btn', true, '🗑️ Clearing...'); try { console.log('🗑️ Clearing all checkpoints from all projects...'); const response = await fetch('/api/clear-all-checkpoints', { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }); const result = await response.json(); if (result.success) { this.showToast(`Cleared ${result.data.deletedCheckpoints} checkpoints from ${result.data.projectCount} projects`, 'success'); // Refresh the projects view this.showProjects(); this.loadInitialData(); console.log('✅ All checkpoints cleared successfully'); } else { throw new Error(result.error || 'Failed to clear all checkpoints'); } } catch (error) { console.error('Error clearing all checkpoints:', error); this.showToast('Failed to clear all checkpoints', 'error'); } finally { // Remove loading state regardless of success/failure this.setButtonLoading('#clear-all-btn', false); } } confirmDeleteCheckpoint() { if (!this.selectedCheckpoint) return; this.showModal( 'Delete Checkpoint', `Are you sure you want to delete the checkpoint "${this.selectedCheckpoint.message}"? This action cannot be undone.`, () => this.deleteCheckpoint(this.selectedCheckpoint.id) ); } async deleteCheckpoint(checkpointId) { // Set loading state for all delete checkpoint buttons this.setButtonLoading('#delete-checkpoint-btn', true, '🗑️ Deleting...'); try { console.log(`🗑️ Deleting checkpoint: ${checkpointId}`); const response = await fetch(`/api/checkpoint/${checkpointId}`, { method: 'DELETE' }); const result = await response.json(); if (result.success) { this.showToast('Checkpoint deleted successfully', 'success'); // Go back to checkpoints view this.showCheckpoints(); this.loadProjectCheckpoints(this.selectedProject.path); console.log('✅ Checkpoint deleted successfully'); } else { throw new Error(result.error || 'Failed to delete checkpoint'); } } catch (error) { console.error('Error deleting checkpoint:', error); this.showToast('Failed to delete checkpoint', 'error'); } finally { // Remove loading state regardless of success/failure this.setButtonLoading('#delete-checkpoint-btn', false); } } async restoreCheckpoint() { if (!this.selectedCheckpoint) return; // Set loading state for all restore buttons this.setButtonLoading('#restore-btn', true, '🔄 Restoring...'); try { console.log(`🔄 Restoring checkpoint: ${this.selectedCheckpoint.id}`); const response = await fetch(`/api/checkpoint/${this.selectedCheckpoint.id}/restore`, { method: 'POST' }); const result = await response.json(); if (result.success) { this.showToast('Restored files successfully!', 'success'); console.log('✅ Checkpoint restored successfully'); } else { throw new Error(result.error || 'Failed to restore checkpoint'); } } catch (error) { console.error('Error restoring checkpoint:', error); this.showToast('Failed to restore checkpoint', 'error'); } finally { // Remove loading state regardless of success/failure this.setButtonLoading('#restore-btn', false); } } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } formatDiffLine(line) { const trimmedLine = line.trim(); if (line.startsWith('+')) { // Added line - green background with proper padding and spacing return `<div style="background-color: rgba(46, 160, 67, 0.15); color: #2ea043; padding: 2px 8px; margin: 1px 0; border-left: 3px solid #2ea043; font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace; font-size: 13px; line-height: 1.4;">${this.escapeHtml(line)}</div>`; } else if (line.startsWith('-')) { // Removed line - red background with proper padding and spacing return `<div style="background-color: rgba(248, 81, 73, 0.15); color: #f85149; padding: 2px 8px; margin: 1px 0; border-left: 3px solid #f85149; font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace; font-size: 13px; line-height: 1.4;">${this.escapeHtml(line)}</div>`; } else { // Context line - neutral color with subtle styling return `<div style="color: #7d8590; padding: 2px 8px; margin: 1px 0; border-left: 3px solid transparent; font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace; font-size: 13px; line-height: 1.4;">${this.escapeHtml(line)}</div>`; } } formatDiffContent(diffText, changeType) { if (changeType === 'added') { return `<div style="background-color: rgba(46, 160, 67, 0.1); border: 1px solid rgba(46, 160, 67, 0.3); border-radius: 6px; padding: 12px; margin: 8px 0;"> <div style="color: #2ea043; font-weight: 600; font-size: 14px; margin-bottom: 6px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;">✅ File added</div> <div style="color: #656d76; font-size: 13px;">New file was created in this checkpoint</div> </div>`; } else if (changeType === 'deleted') { return `<div style="background-color: rgba(248, 81, 73, 0.1); border: 1px solid rgba(248, 81, 73, 0.3); border-radius: 6px; padding: 12px; margin: 8px 0;"> <div style="color: #f85149; font-weight: 600; font-size: 14px; margin-bottom: 6px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;">🗑️ File deleted</div> <div style="color: #656d76; font-size: 13px;">File was removed in this checkpoint</div> </div>`; } else { // For modified files, clean up the diff and format nicely const lines = diffText.split('\n').filter(line => line.trim() !== ''); // Remove any leading numbers or artifacts const cleanLines = lines.filter(line => { const trimmed = line.trim(); return trimmed !== '' && !(/^\d+$/.test(trimmed)); }); return `<div style="background-color: #0d1117; border: 1px solid #30363d; border-radius: 6px; overflow: hidden; margin: 8px 0;"> <div style="background-color: #21262d; padding: 8px 12px; border-bottom: 1px solid #30363d; font-size: 12px; font-weight: 600; color: #f0f6fc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;"> 📝 Changes </div> <div style="padding: 0;"> ${cleanLines.map(line => this.formatDiffLine(line)).join('')} </div> </div>`; } } showDiffModal(diffData, preSelectedFile = null) { try { const modal = document.createElement('div'); modal.className = 'modal-overlay active'; // Filter out files with no actual changes const filteredChanges = diffData.changes.filter(change => { if (change.type === 'added' || change.type === 'deleted') { return true; // Always show added/deleted files } // For modified files, check if there's actual content difference return change.diff && change.diff.trim() && !change.diff.includes('No content changes detected'); }); // If no files have changes, show message if (filteredChanges.length === 0) { modal.innerHTML = ` <div class="modal"> <div class="modal-header"> <div class="modal-title">No Changes Found</div> </div> <div class="modal-content" style="text-align: center;"> <div style="color: var(--text-muted); font-size: 16px;"> This checkpoint contains no file changes to display. </div> </div> <div class="modal-actions"> <button class="btn btn-secondary" onclick="this.closest('.modal-overlay').remove()">Close</button> </div> </div> `; document.body.appendChild(modal); return; } // Update diffData to use filtered changes diffData.changes = filteredChanges; // Create file list for sidebar with color coding let initialActiveIndex = 0; if (preSelectedFile) { const preSelectedIndex = diffData.changes.findIndex(change => change.file === preSelectedFile); if (preSelectedIndex !== -1) { initialActiveIndex = preSelectedIndex; } } else { const firstSelectableIndex = diffData.changes.findIndex(change => change.type !== 'deleted'); initialActiveIndex = firstSelectableIndex !== -1 ? firstSelectableIndex : 0; } const fileListHtml = diffData.changes.map((change, index) => ` <div class="diff-file-item ${index === initialActiveIndex ? 'active' : ''} ${change.type} ${change.type === 'deleted' ? 'disabled' : ''}" data-index="${index}"> <div class="diff-file-icon"> ${change.type === 'added' ? '📄' : change.type === 'deleted' ? '🗑️' : '✏️'} </div> <div class="diff-file-name">${change.file}</div> <div class="diff-file-changes ${change.type}-badge"> ${change.type === 'added' ? 'added' : change.type === 'deleted' ? 'deleted' : 'modified'} </div> </div> `).join(''); modal.innerHTML = ` <div class="modal diff-modal"> <div class="modal-header"> <div class="modal-title"> Checkpoint Comparison <span class="diff-summary"> ${diffData.summary.added} added, ${diffData.summary.modified} modified, ${diffData.summary.deleted} deleted </span> </div> </div> <div class="modal-content" style="padding: 0;"> <div class="diff-container"> <div class="diff-sidebar"> <div class="diff-files-header">Files changed (${diffData.changes.length})</div> <div class="diff-files-list"> ${fileListHtml} </div> </div> <div class="diff-content"> <div id="diff-file-content"> ${this.renderSingleFileDiff(diffData.changes[initialActiveIndex] || null)} </div> </div> </div> </div> <div class="modal-actions"> <button class="btn btn-secondary" onclick="this.closest('.modal-overlay').remove()">Close</button> </div> </div> `; // Add event listeners for file selection modal.querySelectorAll('.diff-file-item').forEach(item => { item.addEventListener('click', () => { // Don't allow clicking on deleted files if (item.classList.contains('disabled')) { return; } const index = parseInt(item.dataset.index); const fileData = diffData.changes[index]; // Update active state modal.querySelectorAll('.diff-file-item').forEach(i => i.classList.remove('active')); item.classList.add('active'); // Update content modal.querySelector('#diff-file-content').innerHTML = this.renderSingleFileDiff(fileData); }); }); document.body.appendChild(modal); } catch (error) { console.error('Error in showDiffModal:', error); this.showToast('Error displaying diff: ' + error.message, 'error'); } } async checkpointHasChanges(checkpoint, index) { try { // First checkpoint or last checkpoint always considered as having changes if (index === this.projectCheckpoints.length - 1) { return true; // First checkpoint in the project } const previousCheckpoint = this.projectCheckpoints[index + 1]; // Get diff data to check for changes const response = await fetch('/api/checkpoint/diff', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentId: checkpoint.id, previousId: previousCheckpoint.id }) }); const result = await response.json(); if (!result.success) { // If diff fails, assume it has changes to be safe return true; } const changes = result.data.changes || []; // Filter out files with no actual changes (same logic as showDiffModal) const actualChanges = changes.filter(change => { if (change.type === 'added' || change.type === 'deleted') { return true; // Always show added/deleted files } // For modified files, check if there's actual content difference return change.diff && change.diff.trim() && !change.diff.includes('No content changes detected'); }); return actualChanges.length > 0; } catch (error) { console.error('Error checking checkpoint changes:', error); // If error, assume it has changes to be safe return true; } } async openDiffForFile(fileName) { try { // Get the current checkpoint's diff data if (!this.selectedCheckpoint) { this.showToast('No checkpoint selected', 'error'); return; } const currentIndex = this.projectCheckpoints.findIndex(cp => cp.id === this.selectedCheckpoint.id); if (currentIndex === -1 || currentIndex === this.projectCheckpoints.length - 1) { this.showToast('No previous checkpoint to compare with', 'error'); return; } const previousCheckpoint = this.projectCheckpoints[currentIndex + 1]; // Get diff data const response = await fetch('/api/checkpoint/diff', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentId: this.selectedCheckpoint.id, previousId: previousCheckpoint.id }) }); const result = await response.json(); if (!result.success) { throw new Error(result.error || 'Failed to get diff data'); } // Open diff modal with the specific file pre-selected this.showDiffModal(result.data, fileName); } catch (error) { console.error('Error opening diff for file:', error); this.showToast('Error opening diff: ' + error.message, 'error'); } } renderSingleFileDiff(fileData) { if (!fileData) { return '<div class="no-file-selected">Select a file to view changes</div>'; } return ` <div class="single-file-diff"> <div class="file-header"> <div class="file-path">${fileData.file}</div> <div class="file-status ${fileData.type}"> ${fileData.type === 'added' ? '📄 Added' : fileData.type === 'deleted' ? '🗑️ Deleted' : '✏️ Modified'} </div> </div> <div class="file-d