mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-08 13:38:24 +00:00
286 lines
7.8 KiB
TypeScript
286 lines
7.8 KiB
TypeScript
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
|
import { sessionSynchronizerService } from '@/modules/providers/index.js';
|
|
import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js';
|
|
import type { RealtimeClientConnection } from '@/shared/types.js';
|
|
import { AppError } from '@/shared/utils.js';
|
|
|
|
type SessionSummary = {
|
|
id: string;
|
|
summary: string;
|
|
messageCount: number;
|
|
lastActivity: string;
|
|
};
|
|
|
|
type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini', SessionSummary[]>;
|
|
|
|
type SessionRepositoryRow = {
|
|
provider: string;
|
|
session_id: string;
|
|
custom_name?: string | null;
|
|
updated_at?: string | null;
|
|
created_at?: string | null;
|
|
};
|
|
|
|
export type ProjectListItem = {
|
|
projectId: string;
|
|
path: string;
|
|
displayName: string;
|
|
fullPath: string;
|
|
isStarred: boolean;
|
|
sessions: SessionSummary[];
|
|
cursorSessions: SessionSummary[];
|
|
codexSessions: SessionSummary[];
|
|
geminiSessions: SessionSummary[];
|
|
sessionMeta: {
|
|
hasMore: boolean;
|
|
total: number;
|
|
};
|
|
};
|
|
|
|
type ProgressUpdate = {
|
|
phase: 'loading' | 'complete';
|
|
current: number;
|
|
total: number;
|
|
currentProject?: string;
|
|
};
|
|
|
|
type GetProjectsWithSessionsOptions = {
|
|
skipSynchronization?: boolean;
|
|
sessionsLimit?: number;
|
|
sessionsOffset?: number;
|
|
};
|
|
|
|
type SessionPaginationOptions = {
|
|
limit?: number;
|
|
offset?: number;
|
|
};
|
|
|
|
type ProjectSessionsPageResult = {
|
|
sessionsByProvider: SessionsByProvider;
|
|
total: number;
|
|
hasMore: boolean;
|
|
};
|
|
|
|
export type ProjectSessionsPageApiView = {
|
|
projectId: string;
|
|
sessions: SessionSummary[];
|
|
cursorSessions: SessionSummary[];
|
|
codexSessions: SessionSummary[];
|
|
geminiSessions: SessionSummary[];
|
|
sessionMeta: {
|
|
hasMore: boolean;
|
|
total: number;
|
|
};
|
|
};
|
|
|
|
const DEFAULT_PROJECT_SESSIONS_PAGE_SIZE = 20;
|
|
const MAX_PROJECT_SESSIONS_PAGE_SIZE = 200;
|
|
|
|
/**
|
|
* Generate better display name from path.
|
|
*/
|
|
export async function generateDisplayName(projectName: string, actualProjectDir: string | null = null): Promise<string> {
|
|
// Use actual project directory if provided, otherwise decode from project name.
|
|
const projectPath = actualProjectDir || projectName.replace(/-/g, '/');
|
|
|
|
// Try to read package.json from the project path.
|
|
try {
|
|
const packageJsonPath = path.join(projectPath, 'package.json');
|
|
const packageData = await fs.readFile(packageJsonPath, 'utf8');
|
|
const packageJson = JSON.parse(packageData) as { name?: string };
|
|
|
|
// Return the name from package.json if it exists.
|
|
if (packageJson.name) {
|
|
return packageJson.name;
|
|
}
|
|
} catch {
|
|
// Fall back to path-based naming if package.json doesn't exist or can't be read.
|
|
}
|
|
|
|
// If it starts with /, it's an absolute path.
|
|
if (projectPath.startsWith('/')) {
|
|
const parts = projectPath.split('/').filter(Boolean);
|
|
// Return only the last folder name.
|
|
return parts[parts.length - 1] || projectPath;
|
|
}
|
|
|
|
return projectPath;
|
|
}
|
|
|
|
function normalizeSessionPagination(options: SessionPaginationOptions = {}): { limit: number; offset: number } {
|
|
const rawLimit = Number.isFinite(options.limit) ? Math.floor(Number(options.limit)) : DEFAULT_PROJECT_SESSIONS_PAGE_SIZE;
|
|
const rawOffset = Number.isFinite(options.offset) ? Math.floor(Number(options.offset)) : 0;
|
|
|
|
return {
|
|
limit: Math.min(Math.max(1, rawLimit), MAX_PROJECT_SESSIONS_PAGE_SIZE),
|
|
offset: Math.max(0, rawOffset),
|
|
};
|
|
}
|
|
|
|
function mapSessionRowToSummary(row: SessionRepositoryRow): SessionSummary {
|
|
return {
|
|
id: row.session_id,
|
|
summary: row.custom_name || '',
|
|
messageCount: 0,
|
|
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByProvider {
|
|
const byProvider: SessionsByProvider = {
|
|
claude: [],
|
|
cursor: [],
|
|
codex: [],
|
|
gemini: [],
|
|
};
|
|
|
|
for (const row of rows) {
|
|
const provider = row.provider as keyof SessionsByProvider;
|
|
const bucket = byProvider[provider];
|
|
if (!bucket) {
|
|
continue;
|
|
}
|
|
|
|
bucket.push(mapSessionRowToSummary(row));
|
|
}
|
|
|
|
return byProvider;
|
|
}
|
|
|
|
/**
|
|
* Reads one paginated project session slice from the DB and groups rows by provider.
|
|
*/
|
|
function readProjectSessionsPageByPath(
|
|
projectPath: string,
|
|
options: SessionPaginationOptions = {},
|
|
): ProjectSessionsPageResult {
|
|
const pagination = normalizeSessionPagination(options);
|
|
const rows = sessionsDb.getSessionsByProjectPathPage(
|
|
projectPath,
|
|
pagination.limit,
|
|
pagination.offset,
|
|
) as SessionRepositoryRow[];
|
|
const total = sessionsDb.countSessionsByProjectPath(projectPath);
|
|
|
|
return {
|
|
sessionsByProvider: bucketSessionRowsByProvider(rows),
|
|
total,
|
|
hasMore: pagination.offset + rows.length < total,
|
|
};
|
|
}
|
|
|
|
// Broadcast progress to all connected WebSocket clients
|
|
function broadcastProgress(progress: ProgressUpdate) {
|
|
const message = JSON.stringify({
|
|
type: 'loading_progress',
|
|
...progress,
|
|
});
|
|
|
|
connectedClients.forEach((client: RealtimeClientConnection) => {
|
|
if (client.readyState === WS_OPEN_STATE) {
|
|
client.send(message);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Reads all projects from DB and returns provider-bucketed session summaries.
|
|
*/
|
|
export async function getProjectsWithSessions(
|
|
options: GetProjectsWithSessionsOptions = {}
|
|
): Promise<ProjectListItem[]> {
|
|
if (!options.skipSynchronization) {
|
|
await sessionSynchronizerService.synchronizeSessions();
|
|
}
|
|
|
|
const projectRows = projectsDb.getProjectPaths() as Array<{
|
|
project_id: string;
|
|
project_path: string;
|
|
custom_project_name?: string | null;
|
|
isStarred?: number;
|
|
}>;
|
|
const totalProjects = projectRows.length;
|
|
const projects: ProjectListItem[] = [];
|
|
let processedProjects = 0;
|
|
|
|
for (const row of projectRows) {
|
|
processedProjects += 1;
|
|
|
|
const projectId = row.project_id;
|
|
const projectPath = row.project_path;
|
|
|
|
broadcastProgress({
|
|
phase: 'loading',
|
|
current: processedProjects,
|
|
total: totalProjects,
|
|
currentProject: projectPath,
|
|
});
|
|
|
|
const displayName =
|
|
row.custom_project_name && row.custom_project_name.trim().length > 0
|
|
? row.custom_project_name
|
|
: await generateDisplayName(path.basename(projectPath) || projectPath, projectPath);
|
|
|
|
const sessionsPage = readProjectSessionsPageByPath(projectPath, {
|
|
limit: options.sessionsLimit,
|
|
offset: options.sessionsOffset,
|
|
});
|
|
|
|
projects.push({
|
|
projectId,
|
|
path: projectPath,
|
|
displayName,
|
|
fullPath: projectPath,
|
|
isStarred: Boolean(row.isStarred),
|
|
sessions: sessionsPage.sessionsByProvider.claude,
|
|
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
|
codexSessions: sessionsPage.sessionsByProvider.codex,
|
|
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
|
sessionMeta: {
|
|
hasMore: sessionsPage.hasMore,
|
|
total: sessionsPage.total,
|
|
},
|
|
});
|
|
}
|
|
|
|
broadcastProgress({
|
|
phase: 'complete',
|
|
current: totalProjects,
|
|
total: totalProjects,
|
|
});
|
|
|
|
return projects;
|
|
}
|
|
|
|
/**
|
|
* Loads one paginated session slice for a specific project id.
|
|
*/
|
|
export async function getProjectSessionsPage(
|
|
projectId: string,
|
|
options: SessionPaginationOptions = {},
|
|
): Promise<ProjectSessionsPageApiView> {
|
|
const projectRow = projectsDb.getProjectById(projectId);
|
|
if (!projectRow) {
|
|
throw new AppError(`Project "${projectId}" was not found.`, {
|
|
code: 'PROJECT_NOT_FOUND',
|
|
statusCode: 404,
|
|
});
|
|
}
|
|
|
|
const sessionsPage = readProjectSessionsPageByPath(projectRow.project_path, options);
|
|
return {
|
|
projectId: projectRow.project_id,
|
|
sessions: sessionsPage.sessionsByProvider.claude,
|
|
cursorSessions: sessionsPage.sessionsByProvider.cursor,
|
|
codexSessions: sessionsPage.sessionsByProvider.codex,
|
|
geminiSessions: sessionsPage.sessionsByProvider.gemini,
|
|
sessionMeta: {
|
|
hasMore: sessionsPage.hasMore,
|
|
total: sessionsPage.total,
|
|
},
|
|
};
|
|
}
|