import type { PrHint } from "@/git/pr-hint"; import { selectPrHintFromStatus } from "@/git/pr-hint"; import { type HostProjectListItem } from "@/projects/host-project-model"; import type { PendingCreateAttempt } from "@/stores/create-flow-store"; import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceStructureHostPlacement, WorkspaceStructureProject, } from "@/projects/workspace-structure"; import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; const EMPTY_PROJECTS: SidebarProjectEntry[] = []; export type SidebarStateBucket = WorkspaceDescriptor["status"]; export interface SidebarWorkspacePlacement { workspaceKey: string; serverId: string; workspaceId: string; projectKey: string; projectName: string; projectRootPath?: string; workspaceDirectory?: string; projectKind: WorkspaceDescriptor["projectKind"]; workspaceKind: WorkspaceDescriptor["workspaceKind"]; name: string; } export interface SidebarStatusWorkspacePlacement extends SidebarWorkspacePlacement { statusBucket: SidebarStateBucket; statusEnteredAt: Date | null; } export interface SidebarWorkspaceEntry extends SidebarStatusWorkspacePlacement { // Raw user-set title (null when the name is derived from branch/directory). // Prefills the rename input and signals whether a reset is available. title: string | null; pinnedAt?: string | null; // Checkout branch (null when not a git checkout or detached HEAD). currentBranch: string | null; archivingAt: string | null; diffStat: { additions: number; deletions: number } | null; prHint: PrHint | null; archiveHasUncommittedChanges: boolean | null; archiveUnpushedCommitCount: number | null; scripts: WorkspaceDescriptor["scripts"]; hasRunningScripts: boolean; } export interface SidebarProjectEntry { projectKey: string; projectName: string; projectKind: WorkspaceDescriptor["projectKind"]; iconWorkingDir: string; hosts: WorkspaceStructureHostPlacement[]; workspaces: SidebarWorkspacePlacement[]; } export interface SidebarWorkspacePlacementModel { workspaces: SidebarWorkspacePlacement[]; projects: SidebarProjectEntry[]; projectNamesByKey: Map; } export interface SidebarWorkspaceSession { serverId: string; workspaces: Map; workspaceAgentActivity: Map; } interface SidebarWorkspaceSessionSource { workspaces: Map; workspaceAgentActivity: Map; } export function selectSidebarWorkspaceSessions( sessions: Record, serverIds: readonly string[], ): SidebarWorkspaceSession[] { const selected: SidebarWorkspaceSession[] = []; for (const serverId of serverIds) { const session = sessions[serverId]; if (!session) { continue; } selected.push({ serverId, workspaces: session.workspaces, workspaceAgentActivity: session.workspaceAgentActivity, }); } return selected; } export function areSidebarWorkspaceSessionsEqual( left: readonly SidebarWorkspaceSession[], right: readonly SidebarWorkspaceSession[], ): boolean { if (left.length !== right.length) { return false; } for (let index = 0; index < left.length; index += 1) { const leftSession = left[index]; const rightSession = right[index]; if ( !leftSession || !rightSession || leftSession.serverId !== rightSession.serverId || leftSession.workspaces !== rightSession.workspaces || leftSession.workspaceAgentActivity !== rightSession.workspaceAgentActivity ) { return false; } } return true; } interface EffectiveWorkspaceStatus { status: WorkspaceDescriptor["status"]; enteredAt: Date | null; } function projectNameForWorkspace(workspace: WorkspaceDescriptor, projectKey: string): string { return ( workspace.projectCustomName ?? workspace.projectDisplayName ?? projectDisplayNameFromProjectId(projectKey) ); } function normalizeCurrentBranch(currentBranch: string | null | undefined): string | null { if (!currentBranch) { return null; } const trimmed = currentBranch.trim(); return trimmed.length === 0 || trimmed === "HEAD" ? null : trimmed; } export function createSidebarWorkspaceEntry(input: { serverId: string; workspace: WorkspaceDescriptor; pendingCreateAttempts?: Record; workspaceAgentActivity?: ReadonlyMap; }): SidebarWorkspaceEntry { const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId; const effectiveStatus = deriveEffectiveWorkspaceStatus(input); return { workspaceKey: `${input.serverId}:${input.workspace.id}`, serverId: input.serverId, workspaceId: input.workspace.id, projectKey, projectName: projectNameForWorkspace(input.workspace, projectKey), projectRootPath: input.workspace.projectRootPath, workspaceDirectory: input.workspace.workspaceDirectory, projectKind: input.workspace.projectKind, workspaceKind: input.workspace.workspaceKind, name: input.workspace.name, title: input.workspace.title ?? null, pinnedAt: input.workspace.pinnedAt, currentBranch: normalizeCurrentBranch(input.workspace.gitRuntime?.currentBranch), statusBucket: effectiveStatus.status, statusEnteredAt: effectiveStatus.enteredAt, archivingAt: input.workspace.archivingAt, diffStat: input.workspace.diffStat, prHint: selectPrHintFromStatus( input.workspace.githubRuntime?.pullRequest, input.workspace.forge, ), archiveHasUncommittedChanges: input.workspace.gitRuntime?.isDirty ?? null, archiveUnpushedCommitCount: input.workspace.gitRuntime?.aheadOfOrigin ?? null, scripts: input.workspace.scripts, hasRunningScripts: input.workspace.scripts.some((script) => script.lifecycle === "running"), }; } function deriveEffectiveWorkspaceStatus(input: { serverId: string; workspace: WorkspaceDescriptor; pendingCreateAttempts?: Record; workspaceAgentActivity?: ReadonlyMap; }): EffectiveWorkspaceStatus { if (input.workspace.status !== "done") { return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt }; } const pendingStartedAt = getPendingInitialAgentCreateStartedAt({ serverId: input.serverId, workspaceId: input.workspace.id, pendingCreateAttempts: input.pendingCreateAttempts, }); if (pendingStartedAt) { return { status: "running", enteredAt: pendingStartedAt }; } const rootAgentActivity = input.workspaceAgentActivity?.get(input.workspace.id); if (rootAgentActivity && rootAgentActivity.status !== "done") { return rootAgentActivity; } return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt }; } function getPendingInitialAgentCreateStartedAt(input: { serverId: string; workspaceId: string; pendingCreateAttempts: Record | undefined; }): Date | null { let latestStartedAt: Date | null = null; for (const pending of Object.values(input.pendingCreateAttempts ?? {})) { if (pending.serverId !== input.serverId) continue; if (pending.workspaceId !== input.workspaceId) continue; if (pending.lifecycle === "abandoned") continue; const startedAt = new Date(pending.timestamp); if (!latestStartedAt || startedAt > latestStartedAt) { latestStartedAt = startedAt; } } return latestStartedAt; } export function buildSidebarWorkspacePlacementModel(input: { projects: readonly HostProjectListItem[]; }): SidebarWorkspacePlacementModel { const projects = buildSidebarProjectsFromHostProjects({ projects: input.projects }); return { projects, workspaces: projects.flatMap((project) => project.workspaces), projectNamesByKey: new Map( projects.map((project) => [project.projectKey, project.projectName]), ), }; } function createStructuralWorkspaceEntry(input: { project: HostProjectListItem; workspaceKey: string; }): SidebarWorkspacePlacement { const identity = resolveStructuralWorkspaceIdentity({ project: input.project, workspaceKey: input.workspaceKey, }); return { workspaceKey: identity.workspaceKey, serverId: identity.serverId, workspaceId: identity.workspaceId, projectKey: input.project.projectKey, projectName: input.project.projectName, projectRootPath: input.project.iconWorkingDir, workspaceDirectory: undefined, projectKind: input.project.projectKind, workspaceKind: "checkout", name: identity.workspaceId, }; } function resolveStructuralWorkspaceIdentity(input: { project: HostProjectListItem; workspaceKey: string; }): { workspaceKey: string; serverId: string; workspaceId: string; } { const hostsByLongestPrefix = [...input.project.hosts].sort( (left, right) => right.serverId.length - left.serverId.length, ); for (const host of hostsByLongestPrefix) { const prefix = `${host.serverId}:`; if (!input.workspaceKey.startsWith(prefix)) continue; const workspaceId = input.workspaceKey.slice(prefix.length); if (!workspaceId) continue; return { workspaceKey: input.workspaceKey, serverId: host.serverId, workspaceId, }; } const separatorIndex = input.workspaceKey.indexOf(":"); if (separatorIndex > 0) { return { workspaceKey: input.workspaceKey, serverId: input.workspaceKey.slice(0, separatorIndex), workspaceId: input.workspaceKey.slice(separatorIndex + 1), }; } const serverId = input.project.hosts[0]?.serverId ?? input.workspaceKey; return { workspaceKey: `${serverId}:${input.workspaceKey}`, serverId, workspaceId: input.workspaceKey, }; } export function buildSidebarWorkspaceEntries(input: { placements: readonly SidebarWorkspacePlacement[]; sessions: SidebarWorkspaceSession[]; pendingCreateAttempts?: Record; previousEntries?: ReadonlyMap; }): Map { if (input.placements.length === 0 || input.sessions.length === 0) { return new Map(); } const sessionByServerId = new Map(input.sessions.map((session) => [session.serverId, session])); const entries = new Map(); for (const placement of input.placements) { const session = sessionByServerId.get(placement.serverId); if (!session) continue; const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces: session.workspaces, workspaceId: placement.workspaceId, }); const workspace = workspaceKey ? session.workspaces.get(workspaceKey) : null; if (!workspace) continue; const entry = createSidebarWorkspaceEntry({ serverId: placement.serverId, workspace, pendingCreateAttempts: input.pendingCreateAttempts, workspaceAgentActivity: session.workspaceAgentActivity, }); const previousEntry = input.previousEntries?.get(placement.workspaceKey); entries.set( placement.workspaceKey, previousEntry && areSidebarWorkspaceEntriesEqual(previousEntry, entry) ? previousEntry : entry, ); } return entries; } function areSidebarWorkspaceEntriesEqual( left: SidebarWorkspaceEntry, right: SidebarWorkspaceEntry, ): boolean { const keys = Object.keys(left) as Array; if (keys.length !== Object.keys(right).length) return false; return keys.every((key) => { if (key !== "prHint") return Object.is(left[key], right[key]); const leftHint = left.prHint; const rightHint = right.prHint; return ( leftHint === rightHint || (leftHint !== null && rightHint !== null && leftHint.url === rightHint.url && leftHint.number === rightHint.number && leftHint.state === rightHint.state && leftHint.checks === rightHint.checks && leftHint.checksStatus === rightHint.checksStatus && leftHint.reviewDecision === rightHint.reviewDecision) ); }); } export function buildSidebarProjectsFromStructure(input: { projects: WorkspaceStructureProject[]; }): SidebarProjectEntry[] { return buildSidebarProjectsFromHostProjects({ projects: input.projects.map((project) => ({ projectKey: project.projectKey, projectName: project.projectName, projectKind: project.projectKind, iconWorkingDir: project.iconWorkingDir, hosts: project.hosts, workspaceKeys: project.workspaceKeys, })), }); } export function buildSidebarProjectsFromHostProjects(input: { projects: readonly HostProjectListItem[]; }): SidebarProjectEntry[] { if (input.projects.length === 0) { return EMPTY_PROJECTS; } return input.projects.map((project) => ({ projectKey: project.projectKey, projectName: project.projectName, projectKind: project.projectKind, iconWorkingDir: project.iconWorkingDir, hosts: project.hosts, workspaces: project.workspaceKeys.map((workspaceKey) => createStructuralWorkspaceEntry({ project, workspaceKey, }), ), })); } // Host labels disambiguate which machine a workspace lives on; they only earn their // space once the visible sidebar spans more than one host. Counting distinct hosts // across the visible projects (not all connected hosts) keeps labels off when a host // filter pins the view to a single host. export function shouldShowSidebarHostLabels(projects: SidebarProjectEntry[]): boolean { const serverIds = new Set(); for (const project of projects) { for (const host of project.hosts) { serverIds.add(host.serverId); } } return serverIds.size >= 2; } export function applyStoredOrdering(input: { items: T[]; storedOrder: string[]; getKey: (item: T) => string; }): T[] { if (input.items.length <= 1 || input.storedOrder.length === 0) { return input.items; } const itemByKey = new Map(); for (const item of input.items) { itemByKey.set(input.getKey(item), item); } const prunedOrder: string[] = []; const seen = new Set(); for (const key of input.storedOrder) { if (!itemByKey.has(key) || seen.has(key)) { continue; } seen.add(key); prunedOrder.push(key); } if (prunedOrder.length === 0) { return input.items; } const orderedSet = new Set(prunedOrder); const ordered: T[] = []; let orderedIndex = 0; for (const item of input.items) { const key = input.getKey(item); if (!orderedSet.has(key)) { ordered.push(item); continue; } const targetKey = prunedOrder[orderedIndex] ?? key; orderedIndex += 1; ordered.push(itemByKey.get(targetKey) ?? item); } return ordered; } export function appendMissingOrderKeys(input: { currentOrder: string[]; visibleKeys: string[]; }): string[] { if (input.visibleKeys.length === 0) { return input.currentOrder; } const existingKeys = new Set(input.currentOrder); const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key)); if (missingKeys.length === 0) { return input.currentOrder; } return [...input.currentOrder, ...missingKeys]; } export function prependMissingOrderKeys(input: { currentOrder: string[]; visibleKeys: string[]; }): string[] { if (input.visibleKeys.length === 0) { return input.currentOrder; } const existingKeys = new Set(input.currentOrder); const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key)); if (missingKeys.length === 0) { return input.currentOrder; } return [...missingKeys, ...input.currentOrder]; } export interface SidebarOrderUpdates { projectOrder: string[] | null; workspaceOrders: Array<{ projectKey: string; order: string[] }>; } export function computeSidebarOrderUpdates(input: { projects: SidebarProjectEntry[]; persistedProjectOrder: string[]; getWorkspaceOrder: (projectKey: string) => string[]; }): SidebarOrderUpdates { if (input.projects.length === 0) { return { projectOrder: null, workspaceOrders: [] }; } const nextProjectOrder = appendMissingOrderKeys({ currentOrder: input.persistedProjectOrder, visibleKeys: input.projects.map((project) => project.projectKey), }); const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder; const workspaceOrders: Array<{ projectKey: string; order: string[] }> = []; for (const project of input.projects) { const persistedWorkspaceOrder = input.getWorkspaceOrder(project.projectKey); const nextWorkspaceOrder = prependMissingOrderKeys({ currentOrder: persistedWorkspaceOrder, visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey), }); if (nextWorkspaceOrder !== persistedWorkspaceOrder) { workspaceOrders.push({ projectKey: project.projectKey, order: nextWorkspaceOrder }); } } return { projectOrder, workspaceOrders }; } export interface SidebarLoadingState { isLoading: boolean; isInitialLoad: boolean; isRevalidating: boolean; } export function deriveSidebarLoadingState(input: { isActive: boolean; serverIds: string[]; hydratedServerIds: string[]; hasProjects: boolean; }): SidebarLoadingState { const hasRegisteredHosts = input.serverIds.length > 0; const allHydrated = input.serverIds.length > 0 && input.serverIds.length === input.hydratedServerIds.length; const isLoading = input.isActive && hasRegisteredHosts && !allHydrated; const isInitialLoad = isLoading && !input.hasProjects; return { isLoading, isInitialLoad, isRevalidating: false }; }