From 74640a7f313065e24a86888c15dfce592b1452ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Blanquer=E2=80=8B?= Date: Tue, 20 Jan 2026 22:57:10 +0100 Subject: [PATCH 1/3] 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 --- server/index.js | 5 +- server/projects.js | 70 +++++++++++---- src/components/Sidebar.jsx | 175 +++++++++++++++++++++++++++++++------ src/utils/api.js | 4 +- 4 files changed, 205 insertions(+), 49 deletions(-) diff --git a/server/index.js b/server/index.js index b3c72b3..2f19dcd 100755 --- a/server/index.js +++ b/server/index.js @@ -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 }); diff --git a/server/projects.js b/server/projects.js index c6deeac..b4606f8 100755 --- a/server/projects.js +++ b/server/projects.js @@ -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); diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 35a56a2..d537da2 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -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( +
+
+
+
+
+ +
+
+

+ Delete Project +

+

+ Are you sure you want to delete{' '} + + {deleteConfirmation.project.displayName || deleteConfirmation.project.name} + ? +

+ {deleteConfirmation.sessionCount > 0 && ( +
+

+ This project contains {deleteConfirmation.sessionCount} conversation{deleteConfirmation.sessionCount > 1 ? 's' : ''}. +

+

+ All conversations will be permanently deleted. +

+
+ )} +

+ This action cannot be undone. +

+
+
+
+
+ + +
+
+
, + document.body + )} + + {/* Session Delete Confirmation Modal */} + {sessionDeleteConfirmation && ReactDOM.createPortal( +
+
+
+
+
+ +
+
+

+ Delete Session +

+

+ Are you sure you want to delete{' '} + + {sessionDeleteConfirmation.sessionTitle} + ? +

+

+ This action cannot be undone. +

+
+
+
+
+ + +
+
+
, + document.body + )} +
+
{/* Project Header */}
{/* Mobile Project Item */} @@ -849,18 +976,16 @@ function Sidebar({ : "text-gray-600 dark:text-gray-400" )} /> - {getAllSessions(project).length === 0 && ( - - )}
- {getAllSessions(project).length === 0 && ( -
{ e.stopPropagation(); - deleteProject(project.name); + deleteProject(project); }} title={t('tooltips.deleteProject')} >
- )} {isExpanded ? ( ) : ( @@ -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))} > @@ -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')} > diff --git a/src/utils/api.js b/src/utils/api.js index 894bce5..7f497cd 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -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) => From 8cb34a73b5755cf2693d99974029a1443424d938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Blanquer=E2=80=8B?= Date: Wed, 21 Jan 2026 22:38:29 +0100 Subject: [PATCH 2/3] fix: localize delete confirmation modal strings --- src/components/Sidebar.jsx | 24 ++++++++++++------------ src/i18n/locales/en/sidebar.json | 9 +++++++++ src/i18n/locales/zh-CN/sidebar.json | 9 +++++++++ 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index d537da2..512dbf6 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -521,10 +521,10 @@ function Sidebar({

- Delete Project + {t('deleteConfirmation.deleteProject')}

- Are you sure you want to delete{' '} + {t('deleteConfirmation.confirmDelete')}{' '} {deleteConfirmation.project.displayName || deleteConfirmation.project.name} ? @@ -532,15 +532,15 @@ function Sidebar({ {deleteConfirmation.sessionCount > 0 && (

- This project contains {deleteConfirmation.sessionCount} conversation{deleteConfirmation.sessionCount > 1 ? 's' : ''}. + {t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })}

- All conversations will be permanently deleted. + {t('deleteConfirmation.allConversationsDeleted')}

)}

- This action cannot be undone. + {t('deleteConfirmation.cannotUndo')}

@@ -551,7 +551,7 @@ function Sidebar({ className="flex-1" onClick={() => setDeleteConfirmation(null)} > - Cancel + {t('actions.cancel')} @@ -578,16 +578,16 @@ function Sidebar({

- Delete Session + {t('deleteConfirmation.deleteSession')}

- Are you sure you want to delete{' '} + {t('deleteConfirmation.confirmDelete')}{' '} {sessionDeleteConfirmation.sessionTitle} ?

- This action cannot be undone. + {t('deleteConfirmation.cannotUndo')}

@@ -598,7 +598,7 @@ function Sidebar({ className="flex-1" onClick={() => setSessionDeleteConfirmation(null)} > - Cancel + {t('actions.cancel')} diff --git a/src/i18n/locales/en/sidebar.json b/src/i18n/locales/en/sidebar.json index eb7a0d9..41689fa 100644 --- a/src/i18n/locales/en/sidebar.json +++ b/src/i18n/locales/en/sidebar.json @@ -98,5 +98,14 @@ }, "version": { "updateAvailable": "Update available" + }, + "deleteConfirmation": { + "deleteProject": "Delete Project", + "deleteSession": "Delete Session", + "confirmDelete": "Are you sure you want to delete", + "sessionCount": "This project contains {{count}} conversation.", + "sessionCount_plural": "This project contains {{count}} conversations.", + "allConversationsDeleted": "All conversations will be permanently deleted.", + "cannotUndo": "This action cannot be undone." } } diff --git a/src/i18n/locales/zh-CN/sidebar.json b/src/i18n/locales/zh-CN/sidebar.json index e5e3c65..a3c5808 100644 --- a/src/i18n/locales/zh-CN/sidebar.json +++ b/src/i18n/locales/zh-CN/sidebar.json @@ -98,5 +98,14 @@ }, "version": { "updateAvailable": "有可用更新" + }, + "deleteConfirmation": { + "deleteProject": "删除项目", + "deleteSession": "删除会话", + "confirmDelete": "您确定要删除", + "sessionCount": "此项目包含 {{count}} 个对话。", + "sessionCount_plural": "此项目包含 {{count}} 个对话。", + "allConversationsDeleted": "所有对话将被永久删除。", + "cannotUndo": "此操作无法撤销。" } } From 9f534ce15b837377a2bd2e416fd3965d299f5ee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Blanquer=E2=80=8B?= Date: Wed, 21 Jan 2026 23:14:41 +0100 Subject: [PATCH 3/3] fix: use i18next v4+ pluralization format and add sessionTitle fallback --- src/components/Sidebar.jsx | 2 +- src/i18n/locales/en/sidebar.json | 4 ++-- src/i18n/locales/zh-CN/sidebar.json | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 512dbf6..e558a9f 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -583,7 +583,7 @@ function Sidebar({

{t('deleteConfirmation.confirmDelete')}{' '} - {sessionDeleteConfirmation.sessionTitle} + {sessionDeleteConfirmation.sessionTitle || t('sessions.unnamed')} ?

diff --git a/src/i18n/locales/en/sidebar.json b/src/i18n/locales/en/sidebar.json index 41689fa..7789ddb 100644 --- a/src/i18n/locales/en/sidebar.json +++ b/src/i18n/locales/en/sidebar.json @@ -103,8 +103,8 @@ "deleteProject": "Delete Project", "deleteSession": "Delete Session", "confirmDelete": "Are you sure you want to delete", - "sessionCount": "This project contains {{count}} conversation.", - "sessionCount_plural": "This project contains {{count}} conversations.", + "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." } diff --git a/src/i18n/locales/zh-CN/sidebar.json b/src/i18n/locales/zh-CN/sidebar.json index a3c5808..34d52ed 100644 --- a/src/i18n/locales/zh-CN/sidebar.json +++ b/src/i18n/locales/zh-CN/sidebar.json @@ -103,8 +103,8 @@ "deleteProject": "删除项目", "deleteSession": "删除会话", "confirmDelete": "您确定要删除", - "sessionCount": "此项目包含 {{count}} 个对话。", - "sessionCount_plural": "此项目包含 {{count}} 个对话。", + "sessionCount_one": "此项目包含 {{count}} 个对话。", + "sessionCount_other": "此项目包含 {{count}} 个对话。", "allConversationsDeleted": "所有对话将被永久删除。", "cannotUndo": "此操作无法撤销。" }