Files
claudecodeui/server/modules/projects/projects.routes.ts
Haileyesus b2e3a61030 feat: add archiving flows for sessions and workspaces
Users previously had an all-or-nothing choice for completed sessions: either keep them in the active
sidebar or permanently delete them. That made long-lived usage brittle because valuable history
stayed in the way unless users destroyed it. This change introduces archiving as a first-class
lifecycle so completed work can be hidden without losing transcript history, workspace context,
or restoreability.

The backend now persists session archive state and excludes archived rows from active session
queries by default. Dedicated archive queries and routes make archived sessions and archived
workspaces addressable on their own, which is necessary once hidden data can no longer be rebuilt
from the active project list. Hard-delete behavior still cleans up transcript files so destructive
deletes remain truly destructive.

The frontend now mirrors that lifecycle in the sidebar. Delete flows distinguish between archive
and permanent delete, archived sessions can be restored, archived workspaces appear beside
standalone archived sessions, and archived project sessions open with the correct workspace context
instead of routing to a session URL that leaves the main view empty. Follow-up archive UI polish
keeps the status affordances explicit without competing with workspace names.
2026-05-08 20:14:33 +03:00

265 lines
8.3 KiB
TypeScript

import express from 'express';
import { createProject, updateProjectDisplayName } from '@/modules/projects/services/project-management.service.js';
import { startCloneProject } from '@/modules/projects/services/project-clone.service.js';
import { getProjectTaskMaster } from '@/modules/projects/services/projects-has-taskmaster.service.js';
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
import { getArchivedProjectsWithSessions, getProjectSessionsPage, getProjectsWithSessions } from '@/modules/projects/services/projects-with-sessions-fetch.service.js';
import { deleteOrArchiveProject, restoreArchivedProject } from '@/modules/projects/services/project-delete.service.js';
import { applyLegacyStarredProjectIds, toggleProjectStar } from '@/modules/projects/services/project-star.service.js';
const router = express.Router();
type AuthenticatedUser = {
id?: number | string;
};
function readQueryStringValue(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value) && typeof value[0] === 'string') {
return value[0];
}
return '';
}
function readOptionalNumericQueryValue(value: unknown): number | null {
const rawValue = readQueryStringValue(value).trim();
if (!rawValue) {
return null;
}
const parsedValue = Number.parseInt(rawValue, 10);
return Number.isNaN(parsedValue) ? null : parsedValue;
}
function parseNonNegativeIntQuery(value: unknown, name: string, fallback: number): number {
const rawValue = readQueryStringValue(value).trim();
if (!rawValue) {
return fallback;
}
const parsedValue = Number.parseInt(rawValue, 10);
if (Number.isNaN(parsedValue) || parsedValue < 0) {
throw new AppError(`${name} must be a non-negative integer`, {
code: 'INVALID_QUERY_PARAMETER',
statusCode: 400,
});
}
return parsedValue;
}
function resolveRouteErrorMessage(error: unknown): string {
if (error instanceof AppError) {
return error.message;
}
if (error instanceof Error && error.message) {
return error.message;
}
return 'Failed to clone repository';
}
router.get(
'/',
asyncHandler(async (_req, res) => {
const projects = await getProjectsWithSessions();
res.json(projects);
}),
);
router.get(
'/archived',
asyncHandler(async (_req, res) => {
const projects = await getArchivedProjectsWithSessions();
res.json(createApiSuccessResponse({ projects }));
}),
);
router.get(
'/:projectId/sessions',
asyncHandler(async (req, res) => {
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
const limit = parseNonNegativeIntQuery(req.query.limit, 'limit', 20);
const offset = parseNonNegativeIntQuery(req.query.offset, 'offset', 0);
const sessionsPage = await getProjectSessionsPage(projectId, { limit, offset });
res.json(sessionsPage);
}),
);
router.post(
'/create-project',
asyncHandler(async (req, res) => {
const requestBody = req.body as Record<string, unknown>;
const projectPath = typeof requestBody.path === 'string' ? requestBody.path : '';
const customName = typeof requestBody.customName === 'string' ? requestBody.customName : null;
if (requestBody.workspaceType !== undefined) {
throw new AppError('workspaceType is no longer supported. Use the single create-project flow.', {
code: 'LEGACY_WORKSPACE_TYPE_UNSUPPORTED',
statusCode: 400,
});
}
if (requestBody.githubUrl || requestBody.githubTokenId || requestBody.newGithubToken) {
throw new AppError('Repository cloning is not supported on create-project', {
code: 'CLONE_NOT_SUPPORTED_ON_CREATE_PROJECT',
statusCode: 400,
details: 'Use /api/projects/clone-progress for cloning workflows',
});
}
const projectCreationResult = await createProject({
projectPath,
customName,
});
res.json({
success: true,
project: projectCreationResult.project,
message:
projectCreationResult.outcome === 'reactivated_archived'
? 'Archived project path reused successfully'
: 'Project created successfully',
});
}),
);
/**
* 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');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const sendEvent = (type: string, data: Record<string, unknown>) => {
if (res.writableEnded) {
return;
}
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
};
let cloneOperation: Awaited<ReturnType<typeof startCloneProject>> | null = null;
const closeListener = () => {
cloneOperation?.cancel();
};
req.on('close', closeListener);
try {
const queryParams = req.query as Record<string, unknown>;
const workspacePath = readQueryStringValue(queryParams.path);
const githubUrl = readQueryStringValue(queryParams.githubUrl);
const githubTokenId = readOptionalNumericQueryValue(queryParams.githubTokenId);
const newGithubToken = readQueryStringValue(queryParams.newGithubToken) || null;
const authenticatedUser = (req as typeof req & { user?: AuthenticatedUser }).user;
const userId = authenticatedUser?.id;
if (userId === undefined || userId === null) {
throw new AppError('Authenticated user is required', {
code: 'AUTHENTICATION_REQUIRED',
statusCode: 401,
});
}
cloneOperation = await startCloneProject(
{
workspacePath,
githubUrl,
githubTokenId,
newGithubToken,
userId,
},
{
onProgress: (message) => {
sendEvent('progress', { message });
},
onComplete: ({ project, message }) => {
sendEvent('complete', { project, message });
},
},
);
await cloneOperation.waitForCompletion;
} catch (error) {
sendEvent('error', { message: resolveRouteErrorMessage(error) });
} finally {
req.off('close', closeListener);
if (!res.writableEnded) {
res.end();
}
}
});
router.get(
'/:projectId/taskmaster',
asyncHandler(async (req, res) => {
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
const taskMasterDetails = await getProjectTaskMaster(projectId);
res.json(taskMasterDetails);
}),
);
router.put('/:projectId/rename', (req, res) => {
try {
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
const { displayName } = req.body as { displayName?: unknown };
updateProjectDisplayName(projectId, displayName);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to rename project' });
}
});
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 });
}),
);
router.post(
'/:projectId/restore',
asyncHandler(async (req, res) => {
const projectId = typeof req.params.projectId === 'string' ? req.params.projectId : '';
restoreArchivedProject(projectId);
res.json(createApiSuccessResponse({ projectId, isArchived: false }));
}),
);
/**
* - `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;