Large project, status, and pinned groups now render 20 rows until expanded. Newly discovered workspaces are prepended without disturbing the saved order.
551 lines
18 KiB
TypeScript
551 lines
18 KiB
TypeScript
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<string, string>;
|
|
}
|
|
|
|
export interface SidebarWorkspaceSession {
|
|
serverId: string;
|
|
workspaces: Map<string, WorkspaceDescriptor>;
|
|
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
|
}
|
|
|
|
interface SidebarWorkspaceSessionSource {
|
|
workspaces: Map<string, WorkspaceDescriptor>;
|
|
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
|
}
|
|
|
|
export function selectSidebarWorkspaceSessions(
|
|
sessions: Record<string, SidebarWorkspaceSessionSource | undefined>,
|
|
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<string, PendingCreateAttempt>;
|
|
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
|
}): 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<string, PendingCreateAttempt>;
|
|
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
|
}): 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<string, PendingCreateAttempt> | 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<string, PendingCreateAttempt>;
|
|
previousEntries?: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
|
}): Map<string, SidebarWorkspaceEntry> {
|
|
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<string, SidebarWorkspaceEntry>();
|
|
|
|
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<keyof SidebarWorkspaceEntry>;
|
|
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<string>();
|
|
for (const project of projects) {
|
|
for (const host of project.hosts) {
|
|
serverIds.add(host.serverId);
|
|
}
|
|
}
|
|
return serverIds.size >= 2;
|
|
}
|
|
|
|
export function applyStoredOrdering<T>(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<string, T>();
|
|
for (const item of input.items) {
|
|
itemByKey.set(input.getKey(item), item);
|
|
}
|
|
|
|
const prunedOrder: string[] = [];
|
|
const seen = new Set<string>();
|
|
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 };
|
|
}
|