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.
197 lines
7.3 KiB
TypeScript
197 lines
7.3 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import path from 'node:path';
|
|
|
|
import { getConnection } from '@/modules/database/connection.js';
|
|
import type { CreateProjectPathResult, ProjectRepositoryRow } from '@/shared/types.js';
|
|
import { normalizeProjectPath } from '@/shared/utils.js';
|
|
|
|
function normalizeProjectDisplayName(projectPath: string, customProjectName: string | null): string {
|
|
const trimmedCustomName = typeof customProjectName === 'string' ? customProjectName.trim() : '';
|
|
if (trimmedCustomName.length > 0) {
|
|
return trimmedCustomName;
|
|
}
|
|
|
|
const directoryName = path.basename(projectPath);
|
|
return directoryName || projectPath;
|
|
}
|
|
|
|
export const projectsDb = {
|
|
createProjectPath(projectPath: string, customProjectName: string | null = null): CreateProjectPathResult {
|
|
const db = getConnection();
|
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
|
const normalizedProjectName = normalizeProjectDisplayName(normalizedProjectPath, customProjectName);
|
|
const attemptedId = randomUUID();
|
|
const row = db.prepare(`
|
|
INSERT INTO projects (project_id, project_path, custom_project_name, isArchived)
|
|
VALUES (?, ?, ?, 0)
|
|
ON CONFLICT(project_path) DO UPDATE SET
|
|
isArchived = 0
|
|
WHERE projects.isArchived = 1
|
|
RETURNING project_id, project_path, custom_project_name, isStarred, isArchived
|
|
`).get(attemptedId, normalizedProjectPath, normalizedProjectName) as ProjectRepositoryRow | undefined;
|
|
|
|
if (row) {
|
|
return {
|
|
outcome: row.project_id === attemptedId ? 'created' : 'reactivated_archived',
|
|
project: row,
|
|
};
|
|
}
|
|
|
|
const existingProject = projectsDb.getProjectPath(normalizedProjectPath);
|
|
return {
|
|
outcome: 'active_conflict',
|
|
project: existingProject,
|
|
};
|
|
},
|
|
|
|
getProjectPath(projectPath: string): ProjectRepositoryRow | null {
|
|
const db = getConnection();
|
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
|
const row = db.prepare(`
|
|
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
|
|
FROM projects
|
|
WHERE project_path = ?
|
|
`).get(normalizedProjectPath) as ProjectRepositoryRow | undefined;
|
|
|
|
return row ?? null;
|
|
},
|
|
|
|
getProjectById(projectId: string): ProjectRepositoryRow | null {
|
|
const db = getConnection();
|
|
const row = db.prepare(`
|
|
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
|
|
FROM projects
|
|
WHERE project_id = ?
|
|
`).get(projectId) as ProjectRepositoryRow | undefined;
|
|
|
|
return row ?? null;
|
|
},
|
|
|
|
/**
|
|
* Resolve the absolute project directory from a database project_id.
|
|
*
|
|
* This is the canonical lookup used after the projectName → projectId migration:
|
|
* API routes receive the DB-assigned `projectId` and must resolve the real folder
|
|
* path through this helper before touching the filesystem. Returns `null` when the
|
|
* project row does not exist so callers can respond with a 404.
|
|
*/
|
|
getProjectPathById(projectId: string): string | null {
|
|
const db = getConnection();
|
|
const row = db.prepare(`
|
|
SELECT project_path
|
|
FROM projects
|
|
WHERE project_id = ?
|
|
`).get(projectId) as Pick<ProjectRepositoryRow, 'project_path'> | undefined;
|
|
|
|
return row?.project_path ?? null;
|
|
},
|
|
|
|
getProjectPaths(): ProjectRepositoryRow[] {
|
|
const db = getConnection();
|
|
return db.prepare(`
|
|
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
|
|
FROM projects
|
|
WHERE isArchived = 0
|
|
`).all() as ProjectRepositoryRow[];
|
|
},
|
|
|
|
/**
|
|
* Archived rows are queried separately so archive-focused UIs can present
|
|
* hidden workspaces without reintroducing them into the active sidebar list.
|
|
*/
|
|
getArchivedProjectPaths(): ProjectRepositoryRow[] {
|
|
const db = getConnection();
|
|
return db.prepare(`
|
|
SELECT project_id, project_path, custom_project_name, isStarred, isArchived
|
|
FROM projects
|
|
WHERE isArchived = 1
|
|
`).all() as ProjectRepositoryRow[];
|
|
},
|
|
|
|
getCustomProjectName(projectPath: string): string | null {
|
|
const db = getConnection();
|
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
|
const row = db.prepare(`
|
|
SELECT custom_project_name
|
|
FROM projects
|
|
WHERE project_path = ?
|
|
`).get(normalizedProjectPath) as Pick<ProjectRepositoryRow, 'custom_project_name'> | undefined;
|
|
|
|
return row?.custom_project_name ?? null;
|
|
},
|
|
|
|
updateCustomProjectName(projectPath: string, customProjectName: string | null): void {
|
|
const db = getConnection();
|
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
|
db.prepare(`
|
|
INSERT INTO projects (project_id, project_path, custom_project_name)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(project_path) DO UPDATE SET custom_project_name = excluded.custom_project_name
|
|
`).run(randomUUID(), normalizedProjectPath, customProjectName);
|
|
},
|
|
|
|
updateCustomProjectNameById(projectId: string, customProjectName: string | null): void {
|
|
const db = getConnection();
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET custom_project_name = ?
|
|
WHERE project_id = ?
|
|
`).run(customProjectName, projectId);
|
|
},
|
|
|
|
updateProjectIsStarred(projectPath: string, isStarred: boolean): void {
|
|
const db = getConnection();
|
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET isStarred = ?
|
|
WHERE project_path = ?
|
|
`).run(isStarred ? 1 : 0, normalizedProjectPath);
|
|
},
|
|
|
|
updateProjectIsStarredById(projectId: string, isStarred: boolean): void {
|
|
const db = getConnection();
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET isStarred = ?
|
|
WHERE project_id = ?
|
|
`).run(isStarred ? 1 : 0, projectId);
|
|
},
|
|
|
|
updateProjectIsArchived(projectPath: string, isArchived: boolean): void {
|
|
const db = getConnection();
|
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET isArchived = ?
|
|
WHERE project_path = ?
|
|
`).run(isArchived ? 1 : 0, normalizedProjectPath);
|
|
},
|
|
|
|
updateProjectIsArchivedById(projectId: string, isArchived: boolean): void {
|
|
const db = getConnection();
|
|
db.prepare(`
|
|
UPDATE projects
|
|
SET isArchived = ?
|
|
WHERE project_id = ?
|
|
`).run(isArchived ? 1 : 0, projectId);
|
|
},
|
|
|
|
deleteProjectPath(projectPath: string): void {
|
|
const db = getConnection();
|
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
|
db.prepare(`
|
|
DELETE FROM projects
|
|
WHERE project_path = ?
|
|
`).run(normalizedProjectPath);
|
|
},
|
|
|
|
deleteProjectById(projectId: string): void {
|
|
const db = getConnection();
|
|
db.prepare(`
|
|
DELETE FROM projects
|
|
WHERE project_id = ?
|
|
`).run(projectId);
|
|
},
|
|
};
|