mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-10 06:28:18 +00:00
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.
265 lines
8.3 KiB
TypeScript
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;
|