mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-12 17:12:06 +08:00
refactor: move project deletion to module
This commit is contained in:
@@ -20,7 +20,6 @@ import { getConnectableHost } from '../shared/networkHosts.js';
|
|||||||
import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
|
import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
|
||||||
import {
|
import {
|
||||||
deleteSessionById,
|
deleteSessionById,
|
||||||
deleteProjectById,
|
|
||||||
getProjectPathById,
|
getProjectPathById,
|
||||||
searchConversations,
|
searchConversations,
|
||||||
} from './projects.js';
|
} from './projects.js';
|
||||||
@@ -343,21 +342,6 @@ app.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) =
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Delete project endpoint
|
// 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)
|
// Search conversations content (SSE streaming)
|
||||||
app.get('/api/search/conversations', authenticateToken, async (req, res) => {
|
app.get('/api/search/conversations', authenticateToken, async (req, res) => {
|
||||||
const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
|
const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
|
||||||
|
|||||||
@@ -161,6 +161,11 @@ export const sessionsDb = {
|
|||||||
.all(projectPath) as SessionRow[];
|
.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 {
|
getSessionName(sessionId: string, provider: string): string | null {
|
||||||
const db = getConnection();
|
const db = getConnection();
|
||||||
const row = db
|
const row = db
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ export {
|
|||||||
getProjectsWithSessions,
|
getProjectsWithSessions,
|
||||||
writeSnapshot,
|
writeSnapshot,
|
||||||
} from './services/projects-with-sessions-fetch.service.js';
|
} 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';
|
||||||
|
|||||||
@@ -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 { getProjectTaskMaster } from '@/modules/projects/services/projects-has-taskmaster.service.js';
|
||||||
import { AppError, asyncHandler } from '@/shared/utils.js';
|
import { AppError, asyncHandler } from '@/shared/utils.js';
|
||||||
import { getProjectsWithSessions } from '@/modules/projects/services/projects-with-sessions-fetch.service.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();
|
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) => {
|
router.get('/clone-progress', async (req, res) => {
|
||||||
res.setHeader('Content-Type', 'text/event-stream');
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
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;
|
export default router;
|
||||||
|
|||||||
75
server/modules/projects/services/project-delete.service.ts
Normal file
75
server/modules/projects/services/project-delete.service.ts
Normal file
@@ -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<string>();
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
* - Session message reads for each provider (Claude/Codex/Gemini) for
|
* - Session message reads for each provider (Claude/Codex/Gemini) for
|
||||||
* `GET /api/sessions/:sessionId/messages`.
|
* `GET /api/sessions/:sessionId/messages`.
|
||||||
* - Conversation search (`searchConversations`) which scans JSONL history.
|
* - Conversation search (`searchConversations`) which scans JSONL history.
|
||||||
* - Destructive project cleanup (`deleteProjectById` -> `deleteProject`)
|
* - (Project row removal / JSONL cleanup is handled in
|
||||||
* which removes Claude/Cursor/Codex artifacts on disk.
|
* `modules/projects/services/project-delete.service.ts`.)
|
||||||
* - Manual project registration (`addProjectManually`) which syncs to
|
* - Manual project registration (`addProjectManually`) which syncs to
|
||||||
* ~/.claude/project-config.json for backwards compatibility.
|
* ~/.claude/project-config.json for backwards compatibility.
|
||||||
*/
|
*/
|
||||||
@@ -27,7 +27,6 @@
|
|||||||
import fsSync, { promises as fs } from 'fs';
|
import fsSync, { promises as fs } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import readline from 'readline';
|
import readline from 'readline';
|
||||||
import crypto from 'crypto';
|
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
|
|
||||||
import { generateDisplayName } from '@/modules/projects';
|
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)
|
// 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);
|
||||||
@@ -1984,7 +1883,6 @@ async function getGeminiCliSessionMessages(sessionId) {
|
|||||||
export {
|
export {
|
||||||
getSessionMessages,
|
getSessionMessages,
|
||||||
deleteSessionById,
|
deleteSessionById,
|
||||||
deleteProjectById,
|
|
||||||
addProjectManually,
|
addProjectManually,
|
||||||
getProjectPathById,
|
getProjectPathById,
|
||||||
claudeFolderNameFromPath,
|
claudeFolderNameFromPath,
|
||||||
|
|||||||
@@ -448,8 +448,7 @@ export function useSidebarController({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { project, sessionCount } = deleteConfirmation;
|
const { project } = deleteConfirmation;
|
||||||
const isEmpty = sessionCount === 0;
|
|
||||||
|
|
||||||
setDeleteConfirmation(null);
|
setDeleteConfirmation(null);
|
||||||
// Track in-flight deletes by projectId so the UI can disable actions
|
// 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]));
|
setDeletingProjects((prev) => new Set([...prev, project.projectId]));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.deleteProject(project.projectId, !isEmpty, deleteData);
|
const response = await api.deleteProject(project.projectId, deleteData);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
onProjectDelete?.(project.projectId);
|
onProjectDelete?.(project.projectId);
|
||||||
} else {
|
} else {
|
||||||
const error = (await response.json()) as { error?: string };
|
const data = (await response.json()) as { error?: string | { message?: string } };
|
||||||
alert(error.error || t('messages.deleteProjectFailed'));
|
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) {
|
} catch (error) {
|
||||||
console.error('Error deleting project:', error);
|
console.error('Error deleting project:', error);
|
||||||
|
|||||||
@@ -93,10 +93,10 @@ export const api = {
|
|||||||
authenticatedFetch(`/api/gemini/sessions/${sessionId}`, {
|
authenticatedFetch(`/api/gemini/sessions/${sessionId}`, {
|
||||||
method: 'DELETE',
|
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();
|
const params = new URLSearchParams();
|
||||||
if (force) params.set('force', 'true');
|
if (hardDelete) params.set('force', 'true');
|
||||||
if (deleteData) params.set('deleteData', 'true');
|
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return authenticatedFetch(`/api/projects/${projectId}${qs ? `?${qs}` : ''}`, {
|
return authenticatedFetch(`/api/projects/${projectId}${qs ? `?${qs}` : ''}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|||||||
Reference in New Issue
Block a user