mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-24 01:27:42 +00:00
Add loading progress indicator
Adds real-time progress feedback when loading projects: - Server broadcasts progress updates via WebSocket - Sidebar shows current project being loaded with progress bar - Fixed progress counter to show correct current/total - Completion event now fires after all projects (including manual) are processed - Race condition fix for timeout cleanup using ref - Added cleanup function to prevent state update on unmounted component
This commit is contained in:
@@ -80,6 +80,20 @@ import { validateApiKey, authenticateToken, authenticateWebSocket } from './midd
|
|||||||
// File system watcher for projects folder
|
// File system watcher for projects folder
|
||||||
let projectsWatcher = null;
|
let projectsWatcher = null;
|
||||||
const connectedClients = new Set();
|
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
|
// Setup file system watcher for Claude projects folder using chokidar
|
||||||
async function setupProjectsWatcher() {
|
async function setupProjectsWatcher() {
|
||||||
@@ -117,13 +131,19 @@ async function setupProjectsWatcher() {
|
|||||||
const debouncedUpdate = async (eventType, filePath) => {
|
const debouncedUpdate = async (eventType, filePath) => {
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
debounceTimer = setTimeout(async () => {
|
debounceTimer = setTimeout(async () => {
|
||||||
|
// Prevent reentrant calls
|
||||||
|
if (isGetProjectsRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
isGetProjectsRunning = true;
|
||||||
|
|
||||||
// Clear project directory cache when files change
|
// Clear project directory cache when files change
|
||||||
clearProjectDirectoryCache();
|
clearProjectDirectoryCache();
|
||||||
|
|
||||||
// Get updated projects list
|
// Get updated projects list
|
||||||
const updatedProjects = await getProjects();
|
const updatedProjects = await getProjects(broadcastProgress);
|
||||||
|
|
||||||
// Notify all connected clients about the project changes
|
// Notify all connected clients about the project changes
|
||||||
const updateMessage = JSON.stringify({
|
const updateMessage = JSON.stringify({
|
||||||
@@ -142,6 +162,8 @@ async function setupProjectsWatcher() {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ERROR] Error handling project changes:', error);
|
console.error('[ERROR] Error handling project changes:', error);
|
||||||
|
} finally {
|
||||||
|
isGetProjectsRunning = false;
|
||||||
}
|
}
|
||||||
}, 300); // 300ms debounce (slightly faster than before)
|
}, 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) => {
|
app.get('/api/projects', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const projects = await getProjects();
|
const projects = await getProjects(broadcastProgress);
|
||||||
res.json(projects);
|
res.json(projects);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
|
|||||||
@@ -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 claudeDir = path.join(os.homedir(), '.claude', 'projects');
|
||||||
const config = await loadProjectConfig();
|
const config = await loadProjectConfig();
|
||||||
const projects = [];
|
const projects = [];
|
||||||
const existingProjects = new Set();
|
const existingProjects = new Set();
|
||||||
|
let totalProjects = 0;
|
||||||
|
let processedProjects = 0;
|
||||||
|
let directories = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if the .claude/projects directory exists
|
// Check if the .claude/projects directory exists
|
||||||
await fs.access(claudeDir);
|
await fs.access(claudeDir);
|
||||||
|
|
||||||
// First, get existing Claude projects from the file system
|
// First, get existing Claude projects from the file system
|
||||||
const entries = await fs.readdir(claudeDir, { withFileTypes: true });
|
const entries = await fs.readdir(claudeDir, { withFileTypes: true });
|
||||||
|
directories = entries.filter(e => e.isDirectory());
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isDirectory()) {
|
// Build set of existing project names for later
|
||||||
existingProjects.add(entry.name);
|
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);
|
const projectPath = path.join(claudeDir, entry.name);
|
||||||
|
|
||||||
// Extract actual project directory from JSONL sessions
|
// Extract actual project directory from JSONL sessions
|
||||||
@@ -460,20 +484,35 @@ async function getProjects() {
|
|||||||
status: 'error'
|
status: 'error'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
projects.push(project);
|
projects.push(project);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects
|
// If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects
|
||||||
if (error.code !== 'ENOENT') {
|
if (error.code !== 'ENOENT') {
|
||||||
console.error('Error reading projects directory:', error);
|
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
|
// Add manually configured projects that don't exist as folders yet
|
||||||
for (const [projectName, projectConfig] of Object.entries(config)) {
|
for (const [projectName, projectConfig] of Object.entries(config)) {
|
||||||
if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) {
|
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
|
// Use the original path if available, otherwise extract from potential sessions
|
||||||
let actualProjectDir = projectConfig.originalPath;
|
let actualProjectDir = projectConfig.originalPath;
|
||||||
|
|
||||||
@@ -541,7 +580,16 @@ async function getProjects() {
|
|||||||
projects.push(project);
|
projects.push(project);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit completion after all projects (including manual) are processed
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback({
|
||||||
|
phase: 'complete',
|
||||||
|
current: totalProjects,
|
||||||
|
total: totalProjects
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return projects;
|
return projects;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
35
src/App.jsx
35
src/App.jsx
@@ -18,7 +18,7 @@
|
|||||||
* Handles both existing sessions (with real IDs) and new sessions (with temporary IDs).
|
* 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 { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { Settings as SettingsIcon, Sparkles } from 'lucide-react';
|
import { Settings as SettingsIcon, Sparkles } from 'lucide-react';
|
||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
@@ -53,6 +53,7 @@ function AppContent() {
|
|||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
|
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
|
||||||
|
const [loadingProgress, setLoadingProgress] = useState(null); // { phase, current, total, currentProject }
|
||||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [settingsInitialTab, setSettingsInitialTab] = useState('agents');
|
const [settingsInitialTab, setSettingsInitialTab] = useState('agents');
|
||||||
@@ -78,7 +79,10 @@ function AppContent() {
|
|||||||
const [externalMessageUpdate, setExternalMessageUpdate] = useState(0);
|
const [externalMessageUpdate, setExternalMessageUpdate] = useState(0);
|
||||||
|
|
||||||
const { ws, sendMessage, messages } = useWebSocketContext();
|
const { ws, sendMessage, messages } = useWebSocketContext();
|
||||||
|
|
||||||
|
// Ref to track loading progress timeout for cleanup
|
||||||
|
const loadingProgressTimeoutRef = useRef(null);
|
||||||
|
|
||||||
// Detect if running as PWA
|
// Detect if running as PWA
|
||||||
const [isPWA, setIsPWA] = useState(false);
|
const [isPWA, setIsPWA] = useState(false);
|
||||||
|
|
||||||
@@ -170,7 +174,23 @@ function AppContent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length > 0) {
|
if (messages.length > 0) {
|
||||||
const latestMessage = messages[messages.length - 1];
|
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') {
|
if (latestMessage.type === 'projects_updated') {
|
||||||
|
|
||||||
// External Session Update Detection: Check if the changed file is the current session's JSONL
|
// External Session Update Detection: Check if the changed file is the current session's JSONL
|
||||||
@@ -247,6 +267,13 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (loadingProgressTimeoutRef.current) {
|
||||||
|
clearTimeout(loadingProgressTimeoutRef.current);
|
||||||
|
loadingProgressTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
}, [messages, selectedProject, selectedSession, activeSessions]);
|
}, [messages, selectedProject, selectedSession, activeSessions]);
|
||||||
|
|
||||||
const fetchProjects = async () => {
|
const fetchProjects = async () => {
|
||||||
@@ -765,6 +792,7 @@ function AppContent() {
|
|||||||
onSessionDelete={handleSessionDelete}
|
onSessionDelete={handleSessionDelete}
|
||||||
onProjectDelete={handleProjectDelete}
|
onProjectDelete={handleProjectDelete}
|
||||||
isLoading={isLoadingProjects}
|
isLoading={isLoadingProjects}
|
||||||
|
loadingProgress={loadingProgress}
|
||||||
onRefresh={handleSidebarRefresh}
|
onRefresh={handleSidebarRefresh}
|
||||||
onShowSettings={() => setShowSettings(true)}
|
onShowSettings={() => setShowSettings(true)}
|
||||||
updateAvailable={updateAvailable}
|
updateAvailable={updateAvailable}
|
||||||
@@ -859,6 +887,7 @@ function AppContent() {
|
|||||||
onSessionDelete={handleSessionDelete}
|
onSessionDelete={handleSessionDelete}
|
||||||
onProjectDelete={handleProjectDelete}
|
onProjectDelete={handleProjectDelete}
|
||||||
isLoading={isLoadingProjects}
|
isLoading={isLoadingProjects}
|
||||||
|
loadingProgress={loadingProgress}
|
||||||
onRefresh={handleSidebarRefresh}
|
onRefresh={handleSidebarRefresh}
|
||||||
onShowSettings={() => setShowSettings(true)}
|
onShowSettings={() => setShowSettings(true)}
|
||||||
updateAvailable={updateAvailable}
|
updateAvailable={updateAvailable}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ function Sidebar({
|
|||||||
onSessionDelete,
|
onSessionDelete,
|
||||||
onProjectDelete,
|
onProjectDelete,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
loadingProgress,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onShowSettings,
|
onShowSettings,
|
||||||
updateAvailable,
|
updateAvailable,
|
||||||
@@ -663,9 +664,28 @@ function Sidebar({
|
|||||||
<div className="w-6 h-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
<div className="w-6 h-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">Loading projects...</h3>
|
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">Loading projects...</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
{loadingProgress && loadingProgress.total > 0 ? (
|
||||||
Fetching your Claude projects and sessions
|
<div className="space-y-2">
|
||||||
</p>
|
<div className="w-full bg-muted rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-primary h-full transition-all duration-300 ease-out"
|
||||||
|
style={{ width: `${(loadingProgress.current / loadingProgress.total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{loadingProgress.current}/{loadingProgress.total} projects
|
||||||
|
</p>
|
||||||
|
{loadingProgress.currentProject && (
|
||||||
|
<p className="text-xs text-muted-foreground/70 truncate max-w-[200px] mx-auto" title={loadingProgress.currentProject}>
|
||||||
|
{loadingProgress.currentProject.split('-').slice(-2).join('/')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Fetching your Claude projects and sessions
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : projects.length === 0 ? (
|
) : projects.length === 0 ? (
|
||||||
<div className="text-center py-12 md:py-8 px-4">
|
<div className="text-center py-12 md:py-8 px-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user