diff --git a/server/index.js b/server/index.js index 7fa5fe218..bba7a3109 100755 --- a/server/index.js +++ b/server/index.js @@ -44,7 +44,7 @@ import pty from 'node-pty'; import fetch from 'node-fetch'; import mime from 'mime-types'; -import { getProjects, getSessions, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js'; +import { getProjects, getSessions, renameProject, removeProjectFromList, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js'; import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js'; import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js'; import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js'; @@ -578,6 +578,17 @@ app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => } }); +// Remove a manually added project from the sidebar list without deleting files or sessions. +app.delete('/api/projects/:projectName/remove', authenticateToken, async (req, res) => { + try { + const { projectName } = req.params; + await removeProjectFromList(projectName); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // Create project endpoint app.post('/api/projects/create', authenticateToken, async (req, res) => { try { diff --git a/server/projects.js b/server/projects.js index d8ccaeb7b..1a11e461b 100755 --- a/server/projects.js +++ b/server/projects.js @@ -1101,6 +1101,32 @@ async function renameProject(projectName, newDisplayName) { return true; } +// Remove a manually added project from the UI list without deleting project files or sessions. +async function removeProjectFromList(projectName) { + const config = await loadProjectConfig(); + const projectConfig = config[projectName]; + + if (!projectConfig?.manuallyAdded) { + throw new Error('Only manually added projects can be removed from the project list'); + } + + const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); + + try { + await fs.access(projectDir); + throw new Error('Projects with local Claude history cannot be removed from the project list'); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + + delete config[projectName]; + await saveProjectConfig(config); + clearProjectDirectoryCache(); + return true; +} + // Delete a session from a project async function deleteSession(projectName, sessionId) { const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); @@ -2544,6 +2570,7 @@ export { getSessionMessages, parseJsonlSessions, renameProject, + removeProjectFromList, deleteSession, isProjectEmpty, deleteProject, diff --git a/server/projects.remove-project.test.mjs b/server/projects.remove-project.test.mjs new file mode 100644 index 000000000..3d99245c1 --- /dev/null +++ b/server/projects.remove-project.test.mjs @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; + +const PROJECTS_MODULE_URL = new URL('./projects.js', import.meta.url); + +async function withTempHome(fn) { + const originalHome = process.env.HOME; + const tempHome = await mkdtemp(path.join(os.tmpdir(), 'claudecodeui-home-')); + + process.env.HOME = tempHome; + + try { + return await fn(tempHome); + } finally { + process.env.HOME = originalHome; + await rm(tempHome, { recursive: true, force: true }); + } +} + +async function writeProjectConfig(homeDir, config) { + const claudeDir = path.join(homeDir, '.claude'); + await mkdir(claudeDir, { recursive: true }); + await writeFile(path.join(claudeDir, 'project-config.json'), JSON.stringify(config, null, 2), 'utf8'); +} + +async function readProjectConfig(homeDir) { + const configPath = path.join(homeDir, '.claude', 'project-config.json'); + const content = await readFile(configPath, 'utf8'); + return JSON.parse(content); +} + +async function loadProjectsModule() { + return import(`${PROJECTS_MODULE_URL.href}?t=${Date.now()}-${Math.random()}`); +} + +test('removeProjectFromList only removes eligible manually added projects', async (t) => { + await t.test('removes a manually added project without deleting local files', async () => { + await withTempHome(async (homeDir) => { + const { removeProjectFromList } = await loadProjectsModule(); + const projectName = 'manual-project'; + + await writeProjectConfig(homeDir, { + [projectName]: { + manuallyAdded: true, + originalPath: '/tmp/manual-project', + }, + }); + + await removeProjectFromList(projectName); + + const config = await readProjectConfig(homeDir); + assert.deepEqual(config, {}); + }); + }); + + await t.test('rejects projects that were not manually added', async () => { + await withTempHome(async (homeDir) => { + const { removeProjectFromList } = await loadProjectsModule(); + const projectName = 'tracked-project'; + + await writeProjectConfig(homeDir, { + [projectName]: { + displayName: 'Tracked Project', + }, + }); + + await assert.rejects( + () => removeProjectFromList(projectName), + /Only manually added projects can be removed from the project list/, + ); + }); + }); + + await t.test('rejects manually added projects that already have local Claude history', async () => { + await withTempHome(async (homeDir) => { + const { removeProjectFromList } = await loadProjectsModule(); + const projectName = 'manual-with-history'; + + await writeProjectConfig(homeDir, { + [projectName]: { + manuallyAdded: true, + originalPath: '/tmp/manual-with-history', + }, + }); + + await mkdir(path.join(homeDir, '.claude', 'projects', projectName), { recursive: true }); + + await assert.rejects( + () => removeProjectFromList(projectName), + /Projects with local Claude history cannot be removed from the project list/, + ); + }); + }); +}); diff --git a/src/components/sidebar/hooks/useSidebarController.ts b/src/components/sidebar/hooks/useSidebarController.ts index 7141208ef..61ee7762e 100644 --- a/src/components/sidebar/hooks/useSidebarController.ts +++ b/src/components/sidebar/hooks/useSidebarController.ts @@ -445,6 +445,7 @@ export function useSidebarController({ const requestProjectDelete = useCallback( (project: Project) => { setDeleteConfirmation({ + action: project.isManuallyAdded ? 'remove' : 'delete', project, sessionCount: getProjectSessions(project).length, }); @@ -457,24 +458,29 @@ export function useSidebarController({ return; } - const { project, sessionCount } = deleteConfirmation; + const { action, project, sessionCount } = deleteConfirmation; const isEmpty = sessionCount === 0; setDeleteConfirmation(null); setDeletingProjects((prev) => new Set([...prev, project.name])); try { - const response = await api.deleteProject(project.name, !isEmpty); + const response = action === 'remove' + ? await api.removeProject(project.name) + : await api.deleteProject(project.name, !isEmpty); if (response.ok) { onProjectDelete?.(project.name); } else { const error = (await response.json()) as { error?: string }; - alert(error.error || t('messages.deleteProjectFailed')); + const fallbackMessage = action === 'remove' + ? t('messages.removeProjectFailed') + : t('messages.deleteProjectFailed'); + alert(error.error || fallbackMessage); } } catch (error) { - console.error('Error deleting project:', error); - alert(t('messages.deleteProjectError')); + console.error(`Error ${action === 'remove' ? 'removing' : 'deleting'} project:`, error); + alert(action === 'remove' ? t('messages.removeProjectError') : t('messages.deleteProjectError')); } finally { setDeletingProjects((prev) => { const next = new Set(prev); diff --git a/src/components/sidebar/types/types.ts b/src/components/sidebar/types/types.ts index bab1b665b..d1a38411f 100644 --- a/src/components/sidebar/types/types.ts +++ b/src/components/sidebar/types/types.ts @@ -10,6 +10,7 @@ export type AdditionalSessionsByProject = Record; export type LoadingSessionsByProject = Record; export type DeleteProjectConfirmation = { + action: 'delete' | 'remove'; project: Project; sessionCount: number; }; diff --git a/src/components/sidebar/view/subcomponents/SidebarModals.tsx b/src/components/sidebar/view/subcomponents/SidebarModals.tsx index 3f6cd4f8d..39932efbe 100644 --- a/src/components/sidebar/view/subcomponents/SidebarModals.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarModals.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import ReactDOM from 'react-dom'; -import { AlertTriangle, Trash2 } from 'lucide-react'; +import { AlertTriangle, Trash2, X } from 'lucide-react'; import type { TFunction } from 'i18next'; import { Button } from '../../../../shared/view/ui'; import Settings from '../../../settings/view/Settings'; @@ -75,6 +75,7 @@ export default function SidebarModals({ () => projects.map(normalizeProjectForSettings), [projects], ); + const isRemoveConfirmation = deleteConfirmation?.action === 'remove'; return ( <> @@ -105,20 +106,24 @@ export default function SidebarModals({
- + {isRemoveConfirmation ? ( + + ) : ( + + )}

- {t('deleteConfirmation.deleteProject')} + {isRemoveConfirmation ? t('removeConfirmation.removeProject') : t('deleteConfirmation.deleteProject')}

- {t('deleteConfirmation.confirmDelete')}{' '} + {isRemoveConfirmation ? t('removeConfirmation.confirmRemove') : t('deleteConfirmation.confirmDelete')}{' '} {deleteConfirmation.project.displayName || deleteConfirmation.project.name} ?

- {deleteConfirmation.sessionCount > 0 && ( + {!isRemoveConfirmation && deleteConfirmation.sessionCount > 0 && (

{t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })} @@ -128,9 +133,20 @@ export default function SidebarModals({

)} -

- {t('deleteConfirmation.cannotUndo')} -

+ {isRemoveConfirmation ? ( + <> +

+ {t('removeConfirmation.notDeleted')} +

+

+ {t('removeConfirmation.canReadd')} +

+ + ) : ( +

+ {t('deleteConfirmation.cannotUndo')} +

+ )}
@@ -139,12 +155,16 @@ export default function SidebarModals({ {t('actions.cancel')} diff --git a/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx b/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx index ae0bdf97c..732c8a272 100644 --- a/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx @@ -95,6 +95,7 @@ export default function SidebarProjectItem({ }: SidebarProjectItemProps) { const isSelected = selectedProject?.name === project.name; const isEditing = editingProject === project.name; + const isRemovable = project.isManuallyAdded === true; const hasMoreSessions = project.sessionMeta?.hasMore === true; const sessionCountDisplay = getSessionCountDisplay(sessions, hasMoreSessions); const sessionCountLabel = `${sessionCountDisplay} session${sessions.length === 1 ? '' : 's'}`; @@ -236,13 +237,23 @@ export default function SidebarProjectItem({