diff --git a/server/index.js b/server/index.js index 44ebb95..b3c72b3 100755 --- a/server/index.js +++ b/server/index.js @@ -80,6 +80,20 @@ import { validateApiKey, authenticateToken, authenticateWebSocket } from './midd // File system watcher for projects folder let projectsWatcher = null; const connectedClients = new Set(); +let isGetProjectsRunning = false; // Flag to prevent reentrant calls + +// Broadcast progress to all connected WebSocket clients +function broadcastProgress(progress) { + const message = JSON.stringify({ + type: 'loading_progress', + ...progress + }); + connectedClients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + } + }); +} // Setup file system watcher for Claude projects folder using chokidar async function setupProjectsWatcher() { @@ -117,13 +131,19 @@ async function setupProjectsWatcher() { const debouncedUpdate = async (eventType, filePath) => { clearTimeout(debounceTimer); debounceTimer = setTimeout(async () => { + // Prevent reentrant calls + if (isGetProjectsRunning) { + return; + } + try { + isGetProjectsRunning = true; // Clear project directory cache when files change clearProjectDirectoryCache(); // Get updated projects list - const updatedProjects = await getProjects(); + const updatedProjects = await getProjects(broadcastProgress); // Notify all connected clients about the project changes const updateMessage = JSON.stringify({ @@ -142,6 +162,8 @@ async function setupProjectsWatcher() { } catch (error) { console.error('[ERROR] Error handling project changes:', error); + } finally { + isGetProjectsRunning = false; } }, 300); // 300ms debounce (slightly faster than before) }; @@ -366,7 +388,7 @@ app.post('/api/system/update', authenticateToken, async (req, res) => { app.get('/api/projects', authenticateToken, async (req, res) => { try { - const projects = await getProjects(); + const projects = await getProjects(broadcastProgress); res.json(projects); } catch (error) { res.status(500).json({ error: error.message }); diff --git a/server/projects.js b/server/projects.js index ff7a8d1..c6deeac 100755 --- a/server/projects.js +++ b/server/projects.js @@ -379,22 +379,46 @@ async function extractProjectDirectory(projectName) { } } -async function getProjects() { +async function getProjects(progressCallback = null) { const claudeDir = path.join(os.homedir(), '.claude', 'projects'); const config = await loadProjectConfig(); const projects = []; const existingProjects = new Set(); - + let totalProjects = 0; + let processedProjects = 0; + let directories = []; + try { // Check if the .claude/projects directory exists await fs.access(claudeDir); - + // First, get existing Claude projects from the file system const entries = await fs.readdir(claudeDir, { withFileTypes: true }); - - for (const entry of entries) { - if (entry.isDirectory()) { - existingProjects.add(entry.name); + directories = entries.filter(e => e.isDirectory()); + + // Build set of existing project names for later + directories.forEach(e => existingProjects.add(e.name)); + + // Count manual projects not already in directories + const manualProjectsCount = Object.entries(config) + .filter(([name, cfg]) => cfg.manuallyAdded && !existingProjects.has(name)) + .length; + + totalProjects = directories.length + manualProjectsCount; + + for (const entry of directories) { + processedProjects++; + + // Emit progress + if (progressCallback) { + progressCallback({ + phase: 'loading', + current: processedProjects, + total: totalProjects, + currentProject: entry.name + }); + } + const projectPath = path.join(claudeDir, entry.name); // Extract actual project directory from JSONL sessions @@ -460,20 +484,35 @@ async function getProjects() { status: 'error' }; } - - projects.push(project); - } + + projects.push(project); } } catch (error) { // If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects if (error.code !== 'ENOENT') { console.error('Error reading projects directory:', error); } + // Calculate total for manual projects only (no directories exist) + totalProjects = Object.entries(config) + .filter(([name, cfg]) => cfg.manuallyAdded) + .length; } // Add manually configured projects that don't exist as folders yet for (const [projectName, projectConfig] of Object.entries(config)) { if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) { + processedProjects++; + + // Emit progress for manual projects + if (progressCallback) { + progressCallback({ + phase: 'loading', + current: processedProjects, + total: totalProjects, + currentProject: projectName + }); + } + // Use the original path if available, otherwise extract from potential sessions let actualProjectDir = projectConfig.originalPath; @@ -541,7 +580,16 @@ async function getProjects() { projects.push(project); } } - + + // Emit completion after all projects (including manual) are processed + if (progressCallback) { + progressCallback({ + phase: 'complete', + current: totalProjects, + total: totalProjects + }); + } + return projects; } diff --git a/src/App.jsx b/src/App.jsx index 1860fff..8f10ef5 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -18,7 +18,7 @@ * Handles both existing sessions (with real IDs) and new sessions (with temporary IDs). */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom'; import { Settings as SettingsIcon, Sparkles } from 'lucide-react'; import Sidebar from './components/Sidebar'; @@ -55,6 +55,7 @@ function AppContent() { const [isMobile, setIsMobile] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); const [isLoadingProjects, setIsLoadingProjects] = useState(true); + const [loadingProgress, setLoadingProgress] = useState(null); // { phase, current, total, currentProject } const [isInputFocused, setIsInputFocused] = useState(false); const [showSettings, setShowSettings] = useState(false); const [settingsInitialTab, setSettingsInitialTab] = useState('agents'); @@ -80,7 +81,10 @@ function AppContent() { const [externalMessageUpdate, setExternalMessageUpdate] = useState(0); const { ws, sendMessage, messages } = useWebSocketContext(); - + + // Ref to track loading progress timeout for cleanup + const loadingProgressTimeoutRef = useRef(null); + // Detect if running as PWA const [isPWA, setIsPWA] = useState(false); @@ -172,7 +176,23 @@ function AppContent() { useEffect(() => { if (messages.length > 0) { const latestMessage = messages[messages.length - 1]; - + + // Handle loading progress updates + if (latestMessage.type === 'loading_progress') { + if (loadingProgressTimeoutRef.current) { + clearTimeout(loadingProgressTimeoutRef.current); + loadingProgressTimeoutRef.current = null; + } + setLoadingProgress(latestMessage); + if (latestMessage.phase === 'complete') { + loadingProgressTimeoutRef.current = setTimeout(() => { + setLoadingProgress(null); + loadingProgressTimeoutRef.current = null; + }, 500); + } + return; + } + if (latestMessage.type === 'projects_updated') { // External Session Update Detection: Check if the changed file is the current session's JSONL @@ -249,6 +269,13 @@ function AppContent() { } } } + + return () => { + if (loadingProgressTimeoutRef.current) { + clearTimeout(loadingProgressTimeoutRef.current); + loadingProgressTimeoutRef.current = null; + } + }; }, [messages, selectedProject, selectedSession, activeSessions]); const fetchProjects = async () => { @@ -767,6 +794,7 @@ function AppContent() { onSessionDelete={handleSessionDelete} onProjectDelete={handleProjectDelete} isLoading={isLoadingProjects} + loadingProgress={loadingProgress} onRefresh={handleSidebarRefresh} onShowSettings={() => setShowSettings(true)} updateAvailable={updateAvailable} @@ -861,6 +889,7 @@ function AppContent() { onSessionDelete={handleSessionDelete} onProjectDelete={handleProjectDelete} isLoading={isLoadingProjects} + loadingProgress={loadingProgress} onRefresh={handleSidebarRefresh} onShowSettings={() => setShowSettings(true)} updateAvailable={updateAvailable} diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 55f5ba5..35a56a2 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -53,6 +53,7 @@ function Sidebar({ onSessionDelete, onProjectDelete, isLoading, + loadingProgress, onRefresh, onShowSettings, updateAvailable, @@ -668,6 +669,29 @@ function Sidebar({
{t('projects.fetchingProjects')}
++ {loadingProgress.current}/{loadingProgress.total} projects +
+ {loadingProgress.currentProject && ( ++ {loadingProgress.currentProject.split('-').slice(-2).join('/')} +
+ )} ++ Fetching your Claude projects and sessions +
+ )} ) : projects.length === 0 ? (