diff --git a/server/index.js b/server/index.js index 7dd1e314..4ff5e598 100755 --- a/server/index.js +++ b/server/index.js @@ -20,7 +20,6 @@ import { getConnectableHost } from '../shared/networkHosts.js'; import { findAppRoot, getModuleDir } from './utils/runtime-paths.js'; import { deleteSessionById, - deleteProjectById, getProjectPathById, searchConversations, } from './projects.js'; @@ -343,21 +342,6 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) = }); // Delete project endpoint -// force=true to allow removal even when sessions exist -// deleteData=true to also delete session/memory files on disk (destructive) -// `projectId` is resolved to an absolute path through the DB before cleanup. -app.delete('/api/projects/:projectId', authenticateToken, async (req, res) => { - try { - const { projectId } = req.params; - const force = req.query.force === 'true'; - const deleteData = req.query.deleteData === 'true'; - await deleteProjectById(projectId, force, deleteData); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ error: error.message }); - } -}); - // Search conversations content (SSE streaming) app.get('/api/search/conversations', authenticateToken, async (req, res) => { const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''; diff --git a/server/modules/database/repositories/sessions.db.ts b/server/modules/database/repositories/sessions.db.ts index 2cf3a83c..41b821bf 100644 --- a/server/modules/database/repositories/sessions.db.ts +++ b/server/modules/database/repositories/sessions.db.ts @@ -161,6 +161,11 @@ export const sessionsDb = { .all(projectPath) as SessionRow[]; }, + deleteSessionsByProjectPath(projectPath: string): void { + const db = getConnection(); + db.prepare(`DELETE FROM sessions WHERE project_path = ?`).run(projectPath); + }, + getSessionName(sessionId: string, provider: string): string | null { const db = getConnection(); const row = db diff --git a/server/modules/projects/index.ts b/server/modules/projects/index.ts index a0a48a17..8a3188de 100644 --- a/server/modules/projects/index.ts +++ b/server/modules/projects/index.ts @@ -4,3 +4,5 @@ export { getProjectsWithSessions, writeSnapshot, } from './services/projects-with-sessions-fetch.service.js'; +export { updateProjectDisplayName } from './services/project-management.service.js'; +export { deleteOrArchiveProject, deleteSessionJsonlFilesForProjectPath } from './services/project-delete.service.js'; diff --git a/server/modules/projects/projects.routes.ts b/server/modules/projects/projects.routes.ts index b593f60c..1586811a 100644 --- a/server/modules/projects/projects.routes.ts +++ b/server/modules/projects/projects.routes.ts @@ -5,6 +5,8 @@ import { startCloneProject } from '@/modules/projects/services/project-clone.ser import { getProjectTaskMaster } from '@/modules/projects/services/projects-has-taskmaster.service.js'; import { AppError, asyncHandler } from '@/shared/utils.js'; import { getProjectsWithSessions } from '@/modules/projects/services/projects-with-sessions-fetch.service.js'; +import { deleteOrArchiveProject } from '@/modules/projects/services/project-delete.service.js'; +import { applyLegacyStarredProjectIds, toggleProjectStar } from '@/modules/projects/services/project-star.service.js'; const router = express.Router(); @@ -92,6 +94,20 @@ router.post( }), ); +/** + * One-time (or idempotent) migration: apply legacy `localStorage` starred projectIds to the DB, then clear client storage. + */ +router.post( + '/migrate-legacy-stars', + asyncHandler(async (req, res) => { + const projectIds = Array.isArray((req.body as { projectIds?: unknown })?.projectIds) + ? ((req.body as { projectIds: unknown[] }).projectIds as unknown[]).map((x) => String(x)) + : []; + const { updated } = applyLegacyStarredProjectIds(projectIds); + res.json({ success: true, updated }); + }), +); + router.get('/clone-progress', async (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); @@ -177,4 +193,27 @@ router.put('/:projectId/rename', (req, res) => { } }); +router.post( + '/:projectId/toggle-star', + asyncHandler(async (req, res) => { + const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : ''; + const { isStarred } = toggleProjectStar(projectId); + res.json({ success: true, isStarred }); + }), +); + +/** + * - `force` not set / false: archive project in DB only (`isArchived` = 1; hidden from active list). + * - `force=true`: remove DB row, delete session rows for that path, remove all `*.jsonl` under the Claude project dir. + */ +router.delete( + '/:projectId', + asyncHandler(async (req, res) => { + const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : ''; + const force = req.query.force === 'true'; + await deleteOrArchiveProject(projectId, force); + res.json({ success: true }); + }), +); + export default router; diff --git a/server/modules/projects/services/project-delete.service.ts b/server/modules/projects/services/project-delete.service.ts new file mode 100644 index 00000000..a743b4b6 --- /dev/null +++ b/server/modules/projects/services/project-delete.service.ts @@ -0,0 +1,75 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { projectsDb, sessionsDb } from '@/modules/database/index.js'; +import { AppError } from '@/shared/utils.js'; + +function uniqueJsonlPathsFromSessions( + sessions: Array<{ jsonl_path: string | null }>, +): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const row of sessions) { + const raw = row.jsonl_path?.trim(); + if (!raw) { + continue; + } + const absolute = path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(raw); + if (seen.has(absolute)) { + continue; + } + seen.add(absolute); + result.push(absolute); + } + + return result; +} + +async function unlinkJsonlIfExists(filePath: string): Promise { + try { + await fs.unlink(filePath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + return; + } + console.warn(`[project-delete] Failed to remove ${filePath}:`, (error as Error).message); + } +} + +/** + * Loads all session rows for the project path and removes each distinct `jsonl_path` file on disk. + */ +export async function deleteSessionJsonlFilesForProjectPath(projectPath: string): Promise { + const sessions = sessionsDb.getSessionsByProjectPath(projectPath); + const paths = uniqueJsonlPathsFromSessions(sessions); + + for (const filePath of paths) { + await unlinkJsonlIfExists(filePath); + } +} + +/** + * - **Soft delete** (`force` false): set `isArchived` on the `projects` row (hide from the active list; DB only). + * - **Force** (`force` true): for each session row for that `project_path`, delete the file at `jsonl_path` + * (when set), then remove session rows and the `projects` row. + */ +export async function deleteOrArchiveProject(projectId: string, force: boolean): Promise { + const row = projectsDb.getProjectById(projectId); + if (!row) { + throw new AppError(`Unknown projectId: ${projectId}`, { + code: 'PROJECT_NOT_FOUND', + statusCode: 404, + }); + } + + if (!force) { + projectsDb.updateProjectIsArchivedById(projectId, true); + return; + } + + await deleteSessionJsonlFilesForProjectPath(row.project_path); + sessionsDb.deleteSessionsByProjectPath(row.project_path); + projectsDb.deleteProjectById(projectId); +} diff --git a/server/projects.js b/server/projects.js index 332696b6..a09dfd43 100755 --- a/server/projects.js +++ b/server/projects.js @@ -18,8 +18,8 @@ * - Session message reads for each provider (Claude/Codex/Gemini) for * `GET /api/sessions/:sessionId/messages`. * - Conversation search (`searchConversations`) which scans JSONL history. - * - Destructive project cleanup (`deleteProjectById` -> `deleteProject`) - * which removes Claude/Cursor/Codex artifacts on disk. + * - (Project row removal / JSONL cleanup is handled in + * `modules/projects/services/project-delete.service.ts`.) * - Manual project registration (`addProjectManually`) which syncs to * ~/.claude/project-config.json for backwards compatibility. */ @@ -27,7 +27,6 @@ import fsSync, { promises as fs } from 'fs'; import path from 'path'; import readline from 'readline'; -import crypto from 'crypto'; import os from 'os'; import { generateDisplayName } from '@/modules/projects'; @@ -735,106 +734,6 @@ async function deleteSession(projectName, sessionId) { } } -// Check if a project is empty (has no sessions) -async function isProjectEmpty(projectName) { - try { - const sessionsResult = await getSessions(projectName, 1, 0); - return sessionsResult.total === 0; - } catch (error) { - console.error(`Error checking if project ${projectName} is empty:`, error); - return false; - } -} - -// Remove a project from the UI. -// When deleteData=true, also delete session/memory files on disk (destructive). -async function deleteProject(projectName, force = false, deleteData = false) { - const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); - - try { - const isEmpty = await isProjectEmpty(projectName); - if (!isEmpty && !force) { - throw new Error('Cannot delete project with existing sessions'); - } - - const config = await loadProjectConfig(); - - // Destructive path: delete underlying data when explicitly requested - if (deleteData) { - let projectPath = config[projectName]?.path || config[projectName]?.originalPath; - if (!projectPath) { - projectPath = await extractProjectDirectory(projectName); - } - - // Remove the Claude project directory (session logs, memory, subagent data) - await fs.rm(projectDir, { recursive: true, force: true }); - - // Delete 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 - } - } - } - - // Always remove from project config - delete config[projectName]; - await saveProjectConfig(config); - - return true; - } catch (error) { - console.error(`Error removing project ${projectName}:`, error); - throw error; - } -} - -/** - * ID-based wrapper around `deleteProject`. - * - * Resolves the project path via the DB, defers destructive filesystem cleanup - * to `deleteProject`, then removes the row from the `projects` table so the - * DB-driven GET /api/projects response no longer lists it. - */ -async function deleteProjectById(projectId, force = false, deleteData = false) { - const projectPath = await getProjectPathById(projectId); - if (!projectPath) { - throw new Error(`Unknown projectId: ${projectId}`); - } - - const claudeFolderName = claudeFolderNameFromPath(projectPath); - try { - await deleteProject(claudeFolderName, force, deleteData); - } catch (error) { - // If the legacy Claude folder doesn't exist anymore we still want to drop - // the DB row; rethrow otherwise so callers can surface the failure. - if (error.code !== 'ENOENT') { - throw error; - } - } - - // Drop the DB row so the DB-driven GET /api/projects stops listing it. - projectsDb.deleteProjectById(projectId); - return true; -} - // Add a project manually to the config (without creating folders) async function addProjectManually(projectPath, displayName = null) { const absolutePath = path.resolve(projectPath); @@ -1984,7 +1883,6 @@ async function getGeminiCliSessionMessages(sessionId) { export { getSessionMessages, deleteSessionById, - deleteProjectById, addProjectManually, getProjectPathById, claudeFolderNameFromPath, diff --git a/src/components/sidebar/hooks/useSidebarController.ts b/src/components/sidebar/hooks/useSidebarController.ts index bdf3fe02..16141622 100644 --- a/src/components/sidebar/hooks/useSidebarController.ts +++ b/src/components/sidebar/hooks/useSidebarController.ts @@ -448,8 +448,7 @@ export function useSidebarController({ return; } - const { project, sessionCount } = deleteConfirmation; - const isEmpty = sessionCount === 0; + const { project } = deleteConfirmation; setDeleteConfirmation(null); // Track in-flight deletes by projectId so the UI can disable actions @@ -457,13 +456,16 @@ export function useSidebarController({ setDeletingProjects((prev) => new Set([...prev, project.projectId])); try { - const response = await api.deleteProject(project.projectId, !isEmpty, deleteData); + const response = await api.deleteProject(project.projectId, deleteData); if (response.ok) { onProjectDelete?.(project.projectId); } else { - const error = (await response.json()) as { error?: string }; - alert(error.error || t('messages.deleteProjectFailed')); + const data = (await response.json()) as { error?: string | { message?: string } }; + const err = data.error; + const message = + typeof err === 'string' ? err : err && typeof err === 'object' && err.message ? err.message : t('messages.deleteProjectFailed'); + alert(message); } } catch (error) { console.error('Error deleting project:', error); diff --git a/src/utils/api.js b/src/utils/api.js index 2152c373..27571fd9 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -93,10 +93,10 @@ export const api = { authenticatedFetch(`/api/gemini/sessions/${sessionId}`, { method: 'DELETE', }), - deleteProject: (projectId, force = false, deleteData = false) => { + // `hardDelete` => server `?force=true` (remove DB row + Claude *.jsonl + sessions rows for path). + deleteProject: (projectId, hardDelete = false) => { const params = new URLSearchParams(); - if (force) params.set('force', 'true'); - if (deleteData) params.set('deleteData', 'true'); + if (hardDelete) params.set('force', 'true'); const qs = params.toString(); return authenticatedFetch(`/api/projects/${projectId}${qs ? `?${qs}` : ''}`, { method: 'DELETE',