Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions server/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -2544,6 +2570,7 @@ export {
getSessionMessages,
parseJsonlSessions,
renameProject,
removeProjectFromList,
deleteSession,
isProjectEmpty,
deleteProject,
Expand Down
97 changes: 97 additions & 0 deletions server/projects.remove-project.test.mjs
Original file line number Diff line number Diff line change
@@ -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/,
);
});
});
});
16 changes: 11 additions & 5 deletions src/components/sidebar/hooks/useSidebarController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ export function useSidebarController({
const requestProjectDelete = useCallback(
(project: Project) => {
setDeleteConfirmation({
action: project.isManuallyAdded ? 'remove' : 'delete',
project,
sessionCount: getProjectSessions(project).length,
});
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/components/sidebar/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type AdditionalSessionsByProject = Record<string, ProjectSession[]>;
export type LoadingSessionsByProject = Record<string, boolean>;

export type DeleteProjectConfirmation = {
action: 'delete' | 'remove';
project: Project;
sessionCount: number;
};
Expand Down
44 changes: 32 additions & 12 deletions src/components/sidebar/view/subcomponents/SidebarModals.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -75,6 +75,7 @@ export default function SidebarModals({
() => projects.map(normalizeProjectForSettings),
[projects],
);
const isRemoveConfirmation = deleteConfirmation?.action === 'remove';

return (
<>
Expand Down Expand Up @@ -105,20 +106,24 @@ export default function SidebarModals({
<div className="p-6">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
<AlertTriangle className="h-6 w-6 text-red-600 dark:text-red-400" />
{isRemoveConfirmation ? (
<X className="h-6 w-6 text-muted-foreground" />
) : (
<AlertTriangle className="h-6 w-6 text-red-600 dark:text-red-400" />
)}
</div>
<div className="min-w-0 flex-1">
<h3 className="mb-2 text-lg font-semibold text-foreground">
{t('deleteConfirmation.deleteProject')}
{isRemoveConfirmation ? t('removeConfirmation.removeProject') : t('deleteConfirmation.deleteProject')}
</h3>
<p className="mb-1 text-sm text-muted-foreground">
{t('deleteConfirmation.confirmDelete')}{' '}
{isRemoveConfirmation ? t('removeConfirmation.confirmRemove') : t('deleteConfirmation.confirmDelete')}{' '}
<span className="font-medium text-foreground">
{deleteConfirmation.project.displayName || deleteConfirmation.project.name}
</span>
?
</p>
{deleteConfirmation.sessionCount > 0 && (
{!isRemoveConfirmation && deleteConfirmation.sessionCount > 0 && (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm font-medium text-red-700 dark:text-red-300">
{t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })}
Expand All @@ -128,9 +133,20 @@ export default function SidebarModals({
</p>
</div>
)}
<p className="mt-3 text-xs text-muted-foreground">
{t('deleteConfirmation.cannotUndo')}
</p>
{isRemoveConfirmation ? (
<>
<p className="mt-3 text-xs text-muted-foreground">
{t('removeConfirmation.notDeleted')}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('removeConfirmation.canReadd')}
</p>
</>
) : (
<p className="mt-3 text-xs text-muted-foreground">
{t('deleteConfirmation.cannotUndo')}
</p>
)}
</div>
</div>
</div>
Expand All @@ -139,12 +155,16 @@ export default function SidebarModals({
{t('actions.cancel')}
</Button>
<Button
variant="destructive"
className="flex-1 bg-red-600 text-white hover:bg-red-700"
variant={isRemoveConfirmation ? 'outline' : 'destructive'}
className={isRemoveConfirmation ? 'flex-1' : 'flex-1 bg-red-600 text-white hover:bg-red-700'}
onClick={onConfirmDeleteProject}
>
<Trash2 className="mr-2 h-4 w-4" />
{t('actions.delete')}
{isRemoveConfirmation ? (
<X className="mr-2 h-4 w-4" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
{isRemoveConfirmation ? t('actions.remove') : t('actions.delete')}
</Button>
</div>
</div>
Expand Down
28 changes: 23 additions & 5 deletions src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'}`;
Expand Down Expand Up @@ -236,13 +237,23 @@ export default function SidebarProjectItem({
</button>

<button
className="flex h-8 w-8 items-center justify-center rounded-lg border border-red-200 bg-red-500/10 active:scale-90 dark:border-red-800 dark:bg-red-900/30"
className={cn(
'flex h-8 w-8 items-center justify-center rounded-lg active:scale-90',
isRemovable
? 'border border-border bg-muted/50'
: 'border border-red-200 bg-red-500/10 dark:border-red-800 dark:bg-red-900/30',
)}
onClick={(event) => {
event.stopPropagation();
onDeleteProject(project);
}}
title={isRemovable ? t('tooltips.removeProject') : t('tooltips.deleteProject')}
>
<Trash2 className="h-4 w-4 text-red-600 dark:text-red-400" />
{isRemovable ? (
<X className="h-4 w-4 text-muted-foreground" />
) : (
<Trash2 className="h-4 w-4 text-red-600 dark:text-red-400" />
)}
</button>

<button
Expand Down Expand Up @@ -383,14 +394,21 @@ export default function SidebarProjectItem({
<Edit3 className="h-3 w-3" />
</div>
<div
className="touch:opacity-100 flex h-6 w-6 cursor-pointer items-center justify-center rounded opacity-0 transition-all duration-200 hover:bg-red-50 group-hover:opacity-100 dark:hover:bg-red-900/20"
className={cn(
'touch:opacity-100 flex h-6 w-6 cursor-pointer items-center justify-center rounded opacity-0 transition-all duration-200 group-hover:opacity-100',
isRemovable ? 'hover:bg-accent' : 'hover:bg-red-50 dark:hover:bg-red-900/20',
)}
onClick={(event) => {
event.stopPropagation();
onDeleteProject(project);
}}
title={t('tooltips.deleteProject')}
title={isRemovable ? t('tooltips.removeProject') : t('tooltips.deleteProject')}
>
<Trash2 className="h-3 w-3 text-red-600 dark:text-red-400" />
{isRemovable ? (
<X className="h-3 w-3 text-muted-foreground" />
) : (
<Trash2 className="h-3 w-3 text-red-600 dark:text-red-400" />
)}
</div>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
Expand Down
Loading