import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store"; import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; export interface WorkspaceStructureHostPlacement { serverId: string; projectId: string; iconWorkingDir: string; worktreeSupport: "supported" | "unsupported" | "unknown"; customIconRevision?: string | null; iconRevision?: string; } export interface WorkspaceStructureProject { viewKey: string; projectKey: string | null; projectName: string; projectKind: WorkspaceDescriptor["projectKind"] | "unknown"; iconWorkingDir: string; hosts: WorkspaceStructureHostPlacement[]; workspaceKeys: string[]; } export interface WorkspaceStructure { projects: WorkspaceStructureProject[]; } interface WorkspaceStructureSession { serverId: string; projects: Iterable; workspaces: Iterable; } export type WorkspaceSort = "manual" | "created" | "lastPrompt"; interface WorkspaceSortItem { workspaceName: string; workspaceKey: string; sortTime: number; } interface ProjectDraft { viewKey: string; projectKey: string | null; projectName: string; hasCustomName: boolean; projectKind: WorkspaceDescriptor["projectKind"]; iconWorkingDir: string; hosts: Map; workspaces: WorkspaceSortItem[]; } /** The single app boundary that turns host-local projects into grouped display projects. */ export function buildWorkspaceStructureProjects(input: { sessions: WorkspaceStructureSession[]; workspaceSort?: WorkspaceSort; }): WorkspaceStructureProject[] { const byProject = new Map(); const projectEntries: Array<{ serverId: string; project: ProjectDescriptor }> = []; const keyCountsByServer = new Map>(); const viewKeyByServerProjectId = new Map>(); for (const session of input.sessions) { for (const project of session.projects) { projectEntries.push({ serverId: session.serverId, project }); const sharedKey = project.projectKey ?? null; if (sharedKey) { const counts = getOrCreate(keyCountsByServer, session.serverId, () => new Map()); counts.set(sharedKey, (counts.get(sharedKey) ?? 0) + 1); } } } const allocatedViewKeys = new Set( projectEntries.flatMap(({ project }) => (project.projectKey ? [project.projectKey] : [])), ); for (const { serverId, project } of projectEntries) { const viewKey = addProjectToView({ byProject, keyCountsByServer, allocatedViewKeys, serverId, project, }); getOrCreate(viewKeyByServerProjectId, serverId, () => new Map()).set( project.projectId, viewKey, ); } for (const session of input.sessions) { for (const workspace of session.workspaces) { const viewKey = viewKeyByServerProjectId.get(session.serverId)?.get(workspace.projectId); if (!viewKey) continue; byProject.get(viewKey)?.workspaces.push({ sortTime: workspaceSortTime(workspace, input.workspaceSort), workspaceName: workspace.name, workspaceKey: `${session.serverId}:${workspace.id}`, }); } } return Array.from(byProject.values()) .map((draft) => ({ viewKey: draft.viewKey, projectKey: draft.projectKey, projectName: draft.projectName, projectKind: draft.projectKind, iconWorkingDir: draft.iconWorkingDir, hosts: Array.from(draft.hosts.values()), workspaceKeys: draft.workspaces .sort(input.workspaceSort ? compareWorkspaceTimes : compareWorkspaceStructureItems) .map((workspace) => workspace.workspaceKey), })) .sort( (left, right) => left.projectName.localeCompare(right.projectName, undefined, { numeric: true, sensitivity: "base", }) || left.viewKey.localeCompare(right.viewKey), ); } export function createProjectViewKey( identity: | { kind: "equivalence"; projectKey: string } | { kind: "placement"; serverId: string; projectId: string }, ): string { return identity.kind === "equivalence" ? identity.projectKey : JSON.stringify([identity.serverId, identity.projectId]); } function allocatePlacementViewKey( allocatedViewKeys: Set, serverId: string, projectId: string, ): string { const legacyKey = createProjectViewKey({ kind: "placement", serverId, projectId }); if (!allocatedViewKeys.has(legacyKey)) { allocatedViewKeys.add(legacyKey); return legacyKey; } for (let suffix = 0; ; suffix += 1) { const collisionKey = JSON.stringify(["placement", serverId, projectId, suffix]); if (allocatedViewKeys.has(collisionKey)) continue; allocatedViewKeys.add(collisionKey); return collisionKey; } } function addProjectToView(input: { byProject: Map; keyCountsByServer: Map>; allocatedViewKeys: Set; serverId: string; project: ProjectDescriptor; }): string { const { byProject, keyCountsByServer, serverId, project } = input; const sharedKey = project.projectKey ?? null; const canUseSharedKey = sharedKey !== null && keyCountsByServer.get(serverId)?.get(sharedKey) === 1; const viewKey = canUseSharedKey ? createProjectViewKey({ kind: "equivalence", projectKey: sharedKey }) : allocatePlacementViewKey(input.allocatedViewKeys, serverId, project.projectId); const placement: WorkspaceStructureHostPlacement = { serverId, projectId: project.projectId, iconWorkingDir: project.projectRootPath, worktreeSupport: project.projectKind === "git" ? "supported" : "unsupported", customIconRevision: project.projectCustomIconRevision, iconRevision: project.projectIconRevision, }; const draft = byProject.get(viewKey); if (!draft) { byProject.set(viewKey, { viewKey, projectKey: sharedKey, projectName: project.projectCustomName ?? project.projectDisplayName ?? projectDisplayNameFromProjectId(project.projectId), hasCustomName: Boolean(project.projectCustomName), projectKind: project.projectKind, iconWorkingDir: project.projectRootPath, hosts: new Map([[serverId, placement]]), workspaces: [], }); } else { if (project.projectCustomName && !draft.hasCustomName) { draft.projectName = project.projectCustomName; draft.hasCustomName = true; } draft.hosts.set(serverId, placement); } return viewKey; } function getOrCreate(map: Map, key: K, create: () => V): V { const existing = map.get(key); if (existing !== undefined) return existing; const value = create(); map.set(key, value); return value; } function compareWorkspaceStructureItems(left: WorkspaceSortItem, right: WorkspaceSortItem): number { return ( left.workspaceName.localeCompare(right.workspaceName, undefined, { numeric: true, sensitivity: "base", }) || left.workspaceKey.localeCompare(right.workspaceKey) ); } function workspaceSortTime( workspace: WorkspaceDescriptor, sort: WorkspaceSort | undefined, ): number { if (!sort) return 0; const time = sort === "lastPrompt" ? (workspace.lastUserMessageAt ?? workspace.createdAt) : workspace.createdAt; return time?.getTime() ?? 0; } function compareWorkspaceTimes(left: WorkspaceSortItem, right: WorkspaceSortItem): number { return right.sortTime - left.sortTime || left.workspaceKey.localeCompare(right.workspaceKey); }