Merge pull request #314 from EricBlanquer/feature/delete-project-with-sessions

feat: allow deleting projects with sessions and add styled confirmation modal
This commit is contained in:
Haileyesus Dessie
2026-01-23 15:13:25 +03:00
committed by GitHub
6 changed files with 223 additions and 49 deletions

View File

@@ -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) => { app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
try { try {
const { projectName } = req.params; const { projectName } = req.params;
await deleteProject(projectName); const force = req.query.force === 'true';
await deleteProject(projectName, force);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });

View File

@@ -1026,25 +1026,56 @@ async function isProjectEmpty(projectName) {
} }
} }
// Delete an empty project // Delete a project (force=true to delete even with sessions)
async function deleteProject(projectName) { async function deleteProject(projectName, force = false) {
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
try { try {
// First check if the project is empty
const isEmpty = await isProjectEmpty(projectName); const isEmpty = await isProjectEmpty(projectName);
if (!isEmpty) { if (!isEmpty && !force) {
throw new Error('Cannot delete project with existing sessions'); 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(); 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]; delete config[projectName];
await saveProjectConfig(config); await saveProjectConfig(config);
return true; return true;
} catch (error) { } catch (error) {
console.error(`Error deleting project ${projectName}:`, 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) // Add a project manually to the config (without creating folders)
async function addProjectManually(projectPath, displayName = null) { async function addProjectManually(projectPath, displayName = null) {
const absolutePath = path.resolve(projectPath); const absolutePath = path.resolve(projectPath);
try { try {
// Check if the path exists // Check if the path exists
await fs.access(absolutePath); await fs.access(absolutePath);
} catch (error) { } catch (error) {
throw new Error(`Path does not exist: ${absolutePath}`); throw new Error(`Path does not exist: ${absolutePath}`);
} }
// Generate project name (encode path for use as directory name) // Generate project name (encode path for use as directory name)
const projectName = absolutePath.replace(/\//g, '-'); const projectName = absolutePath.replace(/\//g, '-');
// Check if project already exists in config // Check if project already exists in config
const config = await loadProjectConfig(); const config = await loadProjectConfig();
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); 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 // Allow adding projects even if the directory exists - this enables tracking
// existing Claude Code or Cursor projects in the UI // existing Claude Code or Cursor projects in the UI
// Add to config as manually added project // Add to config as manually added project
config[projectName] = { config[projectName] = {
manuallyAdded: true, manuallyAdded: true,
originalPath: absolutePath originalPath: absolutePath
}; };
if (displayName) { if (displayName) {
config[projectName].displayName = displayName; config[projectName].displayName = displayName;
} }
@@ -1214,7 +1245,8 @@ async function getCursorSessions(projectPath) {
// Fetch Codex sessions for a given project path // Fetch Codex sessions for a given project path
async function getCodexSessions(projectPath) { async function getCodexSessions(projectPath, options = {}) {
const { limit = 5 } = options;
try { try {
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
const sessions = []; const sessions = [];
@@ -1279,8 +1311,8 @@ async function getCodexSessions(projectPath) {
// Sort sessions by last activity (newest first) // Sort sessions by last activity (newest first)
sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
// Return only the first 5 sessions for performance // Return limited sessions for performance (0 = unlimited for deletion)
return sessions.slice(0, 5); return limit > 0 ? sessions.slice(0, limit) : sessions;
} catch (error) { } catch (error) {
console.error('Error fetching Codex sessions:', error); console.error('Error fetching Codex sessions:', error);

View File

@@ -6,7 +6,7 @@ import { Badge } from './ui/badge';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { useTranslation } from 'react-i18next'; 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 { cn } from '../lib/utils';
import ClaudeLogo from './ClaudeLogo'; import ClaudeLogo from './ClaudeLogo';
import CursorLogo from './CursorLogo.jsx'; import CursorLogo from './CursorLogo.jsx';
@@ -80,6 +80,9 @@ function Sidebar({
const [editingSessionName, setEditingSessionName] = useState(''); const [editingSessionName, setEditingSessionName] = useState('');
const [generatingSummary, setGeneratingSummary] = useState({}); const [generatingSummary, setGeneratingSummary] = useState({});
const [searchFilter, setSearchFilter] = 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 // TaskMaster context
const { setCurrentProject, mcpServerStatus } = useTaskMaster(); const { setCurrentProject, mcpServerStatus } = useTaskMaster();
@@ -306,10 +309,15 @@ function Sidebar({
setEditingName(''); setEditingName('');
}; };
const deleteSession = async (projectName, sessionId, provider = 'claude') => { const showDeleteSessionConfirmation = (projectName, sessionId, sessionTitle, provider = 'claude') => {
if (!confirm(t('messages.deleteSessionConfirm'))) { setSessionDeleteConfirmation({ projectName, sessionId, sessionTitle, provider });
return; };
}
const confirmDeleteSession = async () => {
if (!sessionDeleteConfirmation) return;
const { projectName, sessionId, provider } = sessionDeleteConfirmation;
setSessionDeleteConfirmation(null);
try { try {
console.log('[Sidebar] Deleting session:', { projectName, sessionId, provider }); console.log('[Sidebar] Deleting session:', { projectName, sessionId, provider });
@@ -343,18 +351,26 @@ function Sidebar({
} }
}; };
const deleteProject = async (projectName) => { const deleteProject = (project) => {
if (!confirm(t('messages.deleteProjectConfirm'))) { const sessionCount = getAllSessions(project).length;
return; 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 { try {
const response = await api.deleteProject(projectName); const response = await api.deleteProject(project.name, !isEmpty);
if (response.ok) { if (response.ok) {
// Call parent callback if provided
if (onProjectDelete) { if (onProjectDelete) {
onProjectDelete(projectName); onProjectDelete(project.name);
} }
} else { } else {
const error = await response.json(); const error = await response.json();
@@ -364,6 +380,12 @@ function Sidebar({
} catch (error) { } catch (error) {
console.error('Error deleting project:', error); console.error('Error deleting project:', error);
alert(t('messages.deleteProjectError')); 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 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">
{t('deleteConfirmation.deleteProject')}
</h3>
<p className="text-sm text-muted-foreground mb-1">
{t('deleteConfirmation.confirmDelete')}{' '}
<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">
{t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })}
</p>
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
{t('deleteConfirmation.allConversationsDeleted')}
</p>
</div>
)}
<p className="text-xs text-muted-foreground mt-3">
{t('deleteConfirmation.cannotUndo')}
</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)}
>
{t('actions.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" />
{t('actions.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">
{t('deleteConfirmation.deleteSession')}
</h3>
<p className="text-sm text-muted-foreground mb-1">
{t('deleteConfirmation.confirmDelete')}{' '}
<span className="font-medium text-foreground">
{sessionDeleteConfirmation.sessionTitle || t('sessions.unnamed')}
</span>?
</p>
<p className="text-xs text-muted-foreground mt-3">
{t('deleteConfirmation.cannotUndo')}
</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)}
>
{t('actions.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" />
{t('actions.delete')}
</Button>
</div>
</div>
</div>,
document.body
)}
<div <div
className="h-full flex flex-col bg-card md:select-none" className="h-full flex flex-col bg-card md:select-none"
style={isPWA && isMobile ? { paddingTop: '44px' } : {}} style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
@@ -718,9 +844,10 @@ function Sidebar({
const isExpanded = expandedProjects.has(project.name); const isExpanded = expandedProjects.has(project.name);
const isSelected = selectedProject?.name === project.name; const isSelected = selectedProject?.name === project.name;
const isStarred = isProjectStarred(project.name); const isStarred = isProjectStarred(project.name);
const isDeleting = deletingProjects.has(project.name);
return ( 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 */} {/* Project Header */}
<div className="group md:group"> <div className="group md:group">
{/* Mobile Project Item */} {/* Mobile Project Item */}
@@ -849,18 +976,16 @@ function Sidebar({
: "text-gray-600 dark:text-gray-400" : "text-gray-600 dark:text-gray-400"
)} /> )} />
</button> </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" 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) => { onClick={(e) => {
e.stopPropagation(); 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" /> <Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
</button> </button>
)}
<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" 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) => { onClick={(e) => {
@@ -1009,18 +1134,16 @@ function Sidebar({
> >
<Edit3 className="w-3 h-3" /> <Edit3 className="w-3 h-3" />
</div> </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" 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) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
deleteProject(project.name); deleteProject(project);
}} }}
title={t('tooltips.deleteProject')} title={t('tooltips.deleteProject')}
> >
<Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" /> <Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
</div> </div>
)}
{isExpanded ? ( {isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" /> <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" 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) => { onClick={(e) => {
e.stopPropagation(); 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" /> <Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
</button> </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" 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) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
deleteSession(project.name, session.id, session.__provider); showDeleteSessionConfirmation(project.name, session.id, sessionName, session.__provider);
}} }}
title={t('tooltips.deleteSession')} title={t('tooltips.deleteSession')}
> >

View File

@@ -98,5 +98,14 @@
}, },
"version": { "version": {
"updateAvailable": "Update available" "updateAvailable": "Update available"
},
"deleteConfirmation": {
"deleteProject": "Delete Project",
"deleteSession": "Delete Session",
"confirmDelete": "Are you sure you want to delete",
"sessionCount_one": "This project contains {{count}} conversation.",
"sessionCount_other": "This project contains {{count}} conversations.",
"allConversationsDeleted": "All conversations will be permanently deleted.",
"cannotUndo": "This action cannot be undone."
} }
} }

View File

@@ -98,5 +98,14 @@
}, },
"version": { "version": {
"updateAvailable": "有可用更新" "updateAvailable": "有可用更新"
},
"deleteConfirmation": {
"deleteProject": "删除项目",
"deleteSession": "删除会话",
"confirmDelete": "您确定要删除",
"sessionCount_one": "此项目包含 {{count}} 个对话。",
"sessionCount_other": "此项目包含 {{count}} 个对话。",
"allConversationsDeleted": "所有对话将被永久删除。",
"cannotUndo": "此操作无法撤销。"
} }
} }

View File

@@ -79,8 +79,8 @@ export const api = {
authenticatedFetch(`/api/codex/sessions/${sessionId}`, { authenticatedFetch(`/api/codex/sessions/${sessionId}`, {
method: 'DELETE', method: 'DELETE',
}), }),
deleteProject: (projectName) => deleteProject: (projectName, force = false) =>
authenticatedFetch(`/api/projects/${projectName}`, { authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, {
method: 'DELETE', method: 'DELETE',
}), }),
createProject: (path) => createProject: (path) =>