mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-01-23 18:07:34 +00:00
feat: allow deleting projects with sessions and add styled confirmation modal
- Add force delete option to delete projects with existing sessions - Add styled confirmation modal with session count warning - Add deletingProjects state to show loading indicator during deletion - Delete associated Codex sessions when deleting a project (with limit: 0) - Delete associated Cursor sessions directory when deleting a project - Add fallback to extractProjectDirectory when projectPath undefined - Use finally block for deletingProjects cleanup - Add fallback name in delete modal
This commit is contained in:
@@ -455,11 +455,12 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
|
||||
}
|
||||
});
|
||||
|
||||
// Delete project endpoint (only if empty)
|
||||
// Delete project endpoint (force=true to delete with sessions)
|
||||
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
await deleteProject(projectName);
|
||||
const force = req.query.force === 'true';
|
||||
await deleteProject(projectName, force);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
|
||||
@@ -1026,25 +1026,56 @@ async function isProjectEmpty(projectName) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete an empty project
|
||||
async function deleteProject(projectName) {
|
||||
// Delete a project (force=true to delete even with sessions)
|
||||
async function deleteProject(projectName, force = false) {
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
|
||||
|
||||
try {
|
||||
// First check if the project is empty
|
||||
const isEmpty = await isProjectEmpty(projectName);
|
||||
if (!isEmpty) {
|
||||
if (!isEmpty && !force) {
|
||||
throw new Error('Cannot delete project with existing sessions');
|
||||
}
|
||||
|
||||
// Remove the project directory
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
|
||||
// Remove from project config
|
||||
|
||||
const config = await loadProjectConfig();
|
||||
let projectPath = config[projectName]?.path || config[projectName]?.originalPath;
|
||||
|
||||
// Fallback to extractProjectDirectory if projectPath is not in config
|
||||
if (!projectPath) {
|
||||
projectPath = await extractProjectDirectory(projectName);
|
||||
}
|
||||
|
||||
// Remove the project directory (includes all Claude sessions)
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
|
||||
// Delete all Codex sessions associated with this project
|
||||
if (projectPath) {
|
||||
try {
|
||||
const codexSessions = await getCodexSessions(projectPath, { limit: 0 });
|
||||
for (const session of codexSessions) {
|
||||
try {
|
||||
await deleteCodexSession(session.id);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to delete Codex session ${session.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to delete Codex sessions:', err.message);
|
||||
}
|
||||
|
||||
// Delete Cursor sessions directory if it exists
|
||||
try {
|
||||
const hash = crypto.createHash('md5').update(projectPath).digest('hex');
|
||||
const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash);
|
||||
await fs.rm(cursorProjectDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
// Cursor dir may not exist, ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from project config
|
||||
delete config[projectName];
|
||||
await saveProjectConfig(config);
|
||||
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error deleting project ${projectName}:`, error);
|
||||
@@ -1055,17 +1086,17 @@ async function deleteProject(projectName) {
|
||||
// Add a project manually to the config (without creating folders)
|
||||
async function addProjectManually(projectPath, displayName = null) {
|
||||
const absolutePath = path.resolve(projectPath);
|
||||
|
||||
|
||||
try {
|
||||
// Check if the path exists
|
||||
await fs.access(absolutePath);
|
||||
} catch (error) {
|
||||
throw new Error(`Path does not exist: ${absolutePath}`);
|
||||
}
|
||||
|
||||
|
||||
// Generate project name (encode path for use as directory name)
|
||||
const projectName = absolutePath.replace(/\//g, '-');
|
||||
|
||||
|
||||
// Check if project already exists in config
|
||||
const config = await loadProjectConfig();
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
@@ -1076,13 +1107,13 @@ async function addProjectManually(projectPath, displayName = null) {
|
||||
|
||||
// Allow adding projects even if the directory exists - this enables tracking
|
||||
// existing Claude Code or Cursor projects in the UI
|
||||
|
||||
|
||||
// Add to config as manually added project
|
||||
config[projectName] = {
|
||||
manuallyAdded: true,
|
||||
originalPath: absolutePath
|
||||
};
|
||||
|
||||
|
||||
if (displayName) {
|
||||
config[projectName].displayName = displayName;
|
||||
}
|
||||
@@ -1214,7 +1245,8 @@ async function getCursorSessions(projectPath) {
|
||||
|
||||
|
||||
// Fetch Codex sessions for a given project path
|
||||
async function getCodexSessions(projectPath) {
|
||||
async function getCodexSessions(projectPath, options = {}) {
|
||||
const { limit = 5 } = options;
|
||||
try {
|
||||
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
||||
const sessions = [];
|
||||
@@ -1279,8 +1311,8 @@ async function getCodexSessions(projectPath) {
|
||||
// Sort sessions by last activity (newest first)
|
||||
sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
|
||||
|
||||
// Return only the first 5 sessions for performance
|
||||
return sessions.slice(0, 5);
|
||||
// Return limited sessions for performance (0 = unlimited for deletion)
|
||||
return limit > 0 ? sessions.slice(0, limit) : sessions;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching Codex sessions:', error);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Badge } from './ui/badge';
|
||||
import { Input } from './ui/input';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2, Star, Search } from 'lucide-react';
|
||||
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2, Star, Search, AlertTriangle } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import CursorLogo from './CursorLogo.jsx';
|
||||
@@ -80,6 +80,9 @@ function Sidebar({
|
||||
const [editingSessionName, setEditingSessionName] = useState('');
|
||||
const [generatingSummary, setGeneratingSummary] = useState({});
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const [deletingProjects, setDeletingProjects] = useState(new Set());
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState(null); // { project, sessionCount }
|
||||
const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState(null); // { projectName, sessionId, sessionTitle, provider }
|
||||
|
||||
// TaskMaster context
|
||||
const { setCurrentProject, mcpServerStatus } = useTaskMaster();
|
||||
@@ -306,10 +309,15 @@ function Sidebar({
|
||||
setEditingName('');
|
||||
};
|
||||
|
||||
const deleteSession = async (projectName, sessionId, provider = 'claude') => {
|
||||
if (!confirm(t('messages.deleteSessionConfirm'))) {
|
||||
return;
|
||||
}
|
||||
const showDeleteSessionConfirmation = (projectName, sessionId, sessionTitle, provider = 'claude') => {
|
||||
setSessionDeleteConfirmation({ projectName, sessionId, sessionTitle, provider });
|
||||
};
|
||||
|
||||
const confirmDeleteSession = async () => {
|
||||
if (!sessionDeleteConfirmation) return;
|
||||
|
||||
const { projectName, sessionId, provider } = sessionDeleteConfirmation;
|
||||
setSessionDeleteConfirmation(null);
|
||||
|
||||
try {
|
||||
console.log('[Sidebar] Deleting session:', { projectName, sessionId, provider });
|
||||
@@ -343,18 +351,26 @@ function Sidebar({
|
||||
}
|
||||
};
|
||||
|
||||
const deleteProject = async (projectName) => {
|
||||
if (!confirm(t('messages.deleteProjectConfirm'))) {
|
||||
return;
|
||||
}
|
||||
const deleteProject = (project) => {
|
||||
const sessionCount = getAllSessions(project).length;
|
||||
setDeleteConfirmation({ project, sessionCount });
|
||||
};
|
||||
|
||||
const confirmDeleteProject = async () => {
|
||||
if (!deleteConfirmation) return;
|
||||
|
||||
const { project, sessionCount } = deleteConfirmation;
|
||||
const isEmpty = sessionCount === 0;
|
||||
|
||||
setDeleteConfirmation(null);
|
||||
setDeletingProjects(prev => new Set([...prev, project.name]));
|
||||
|
||||
try {
|
||||
const response = await api.deleteProject(projectName);
|
||||
const response = await api.deleteProject(project.name, !isEmpty);
|
||||
|
||||
if (response.ok) {
|
||||
// Call parent callback if provided
|
||||
if (onProjectDelete) {
|
||||
onProjectDelete(projectName);
|
||||
onProjectDelete(project.name);
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
@@ -364,6 +380,12 @@ function Sidebar({
|
||||
} catch (error) {
|
||||
console.error('Error deleting project:', error);
|
||||
alert(t('messages.deleteProjectError'));
|
||||
} finally {
|
||||
setDeletingProjects(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(project.name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -488,6 +510,110 @@ function Sidebar({
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteConfirmation && ReactDOM.createPortal(
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Delete Project
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-1">
|
||||
Are you sure you want to delete{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{deleteConfirmation.project.displayName || deleteConfirmation.project.name}
|
||||
</span>?
|
||||
</p>
|
||||
{deleteConfirmation.sessionCount > 0 && (
|
||||
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p className="text-sm text-red-700 dark:text-red-300 font-medium">
|
||||
This project contains {deleteConfirmation.sessionCount} conversation{deleteConfirmation.sessionCount > 1 ? 's' : ''}.
|
||||
</p>
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
All conversations will be permanently deleted.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 p-4 bg-muted/30 border-t border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => setDeleteConfirmation(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1 bg-red-600 hover:bg-red-700 text-white"
|
||||
onClick={confirmDeleteProject}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Session Delete Confirmation Modal */}
|
||||
{sessionDeleteConfirmation && ReactDOM.createPortal(
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card border border-border rounded-xl shadow-2xl max-w-md w-full overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Delete Session
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-1">
|
||||
Are you sure you want to delete{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{sessionDeleteConfirmation.sessionTitle}
|
||||
</span>?
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 p-4 bg-muted/30 border-t border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => setSessionDeleteConfirmation(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1 bg-red-600 hover:bg-red-700 text-white"
|
||||
onClick={confirmDeleteSession}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<div
|
||||
className="h-full flex flex-col bg-card md:select-none"
|
||||
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
|
||||
@@ -718,9 +844,10 @@ function Sidebar({
|
||||
const isExpanded = expandedProjects.has(project.name);
|
||||
const isSelected = selectedProject?.name === project.name;
|
||||
const isStarred = isProjectStarred(project.name);
|
||||
|
||||
const isDeleting = deletingProjects.has(project.name);
|
||||
|
||||
return (
|
||||
<div key={project.name} className="md:space-y-1">
|
||||
<div key={project.name} className={cn("md:space-y-1", isDeleting && "opacity-50 pointer-events-none")}>
|
||||
{/* Project Header */}
|
||||
<div className="group md:group">
|
||||
{/* Mobile Project Item */}
|
||||
@@ -849,18 +976,16 @@ function Sidebar({
|
||||
: "text-gray-600 dark:text-gray-400"
|
||||
)} />
|
||||
</button>
|
||||
{getAllSessions(project).length === 0 && (
|
||||
<button
|
||||
<button
|
||||
className="w-8 h-8 rounded-lg bg-red-500/10 dark:bg-red-900/30 flex items-center justify-center active:scale-90 border border-red-200 dark:border-red-800"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteProject(project.name);
|
||||
deleteProject(project);
|
||||
}}
|
||||
onTouchEnd={handleTouchClick(() => deleteProject(project.name))}
|
||||
onTouchEnd={handleTouchClick(() => deleteProject(project))}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="w-8 h-8 rounded-lg bg-primary/10 dark:bg-primary/20 flex items-center justify-center active:scale-90 border border-primary/20 dark:border-primary/30"
|
||||
onClick={(e) => {
|
||||
@@ -1009,18 +1134,16 @@ function Sidebar({
|
||||
>
|
||||
<Edit3 className="w-3 h-3" />
|
||||
</div>
|
||||
{getAllSessions(project).length === 0 && (
|
||||
<div
|
||||
<div
|
||||
className="w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center justify-center rounded cursor-pointer touch:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteProject(project.name);
|
||||
deleteProject(project);
|
||||
}}
|
||||
title={t('tooltips.deleteProject')}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
) : (
|
||||
@@ -1152,9 +1275,9 @@ function Sidebar({
|
||||
className="w-5 h-5 rounded-md bg-red-50 dark:bg-red-900/20 flex items-center justify-center active:scale-95 transition-transform opacity-70 ml-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(project.name, session.id, session.__provider);
|
||||
showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider);
|
||||
}}
|
||||
onTouchEnd={handleTouchClick(() => deleteSession(project.name, session.id, session.__provider))}
|
||||
onTouchEnd={handleTouchClick(() => showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider))}
|
||||
>
|
||||
<Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
@@ -1271,7 +1394,7 @@ function Sidebar({
|
||||
className="w-6 h-6 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 rounded flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(project.name, session.id, session.__provider);
|
||||
showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider);
|
||||
}}
|
||||
title={t('tooltips.deleteSession')}
|
||||
>
|
||||
|
||||
@@ -79,8 +79,8 @@ export const api = {
|
||||
authenticatedFetch(`/api/codex/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
deleteProject: (projectName) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}`, {
|
||||
deleteProject: (projectName, force = false) =>
|
||||
authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
createProject: (path) =>
|
||||
|
||||
Reference in New Issue
Block a user