Files
paseo/packages/app/src/hooks/use-sidebar-workspaces-list.ts
T
Mohamed BoudraandGitHub 31ae545289 Group and clear workspace status in the sidebar (#1317)
* Add status grouping to the workspace sidebar

* Mark workspaces as read from the sidebar

* Clean package builds before release checks

* Fix sidebar status follow-up checks
2026-06-04 01:20:20 +08:00

205 lines
6.7 KiB
TypeScript

import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import {
normalizeWorkspaceDescriptor,
useSessionStore,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { selectPrHintFromStatus } from "@/git/use-pr-status-query";
import { useWorkspaceStructure } from "@/stores/session-store-hooks";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
import {
buildSidebarProjectsFromStructure,
computeSidebarOrderUpdates,
deriveSidebarLoadingState,
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
} from "./sidebar-workspaces-view-model";
export {
appendMissingOrderKeys,
applyStoredOrdering,
buildSidebarProjectsFromStructure,
computeSidebarOrderUpdates,
deriveSidebarLoadingState,
type SidebarLoadingState,
type SidebarOrderUpdates,
type SidebarProjectEntry,
type SidebarStateBucket,
type SidebarWorkspaceEntry,
} from "./sidebar-workspaces-view-model";
export function createSidebarWorkspaceEntry(input: {
serverId: string;
workspace: WorkspaceDescriptor;
}): SidebarWorkspaceEntry {
return {
workspaceKey: `${input.serverId}:${input.workspace.id}`,
serverId: input.serverId,
workspaceId: input.workspace.id,
projectKey: input.workspace.project?.projectKey ?? input.workspace.projectId,
projectRootPath: input.workspace.projectRootPath,
workspaceDirectory: input.workspace.workspaceDirectory,
projectKind: input.workspace.projectKind,
workspaceKind: input.workspace.workspaceKind,
name: input.workspace.name,
statusBucket: input.workspace.status,
statusEnteredAt: input.workspace.statusEnteredAt,
archivingAt: input.workspace.archivingAt,
diffStat: input.workspace.diffStat,
prHint: selectPrHintFromStatus(input.workspace.githubRuntime?.pullRequest),
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"),
};
}
const EMPTY_ORDER: string[] = [];
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
export interface SidebarWorkspacesListResult {
projects: SidebarProjectEntry[];
isLoading: boolean;
isInitialLoad: boolean;
isRevalidating: boolean;
refreshAll: () => void;
}
function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor {
return normalizeWorkspaceDescriptor(payload);
}
export function useSidebarWorkspacesList(options?: {
serverId?: string | null;
enabled?: boolean;
}): SidebarWorkspacesListResult {
const runtime = getHostRuntimeStore();
const serverId = useMemo(() => {
const value = options?.serverId;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options?.serverId]);
const isActive = Boolean(serverId) && options?.enabled !== false;
const persistedProjectOrder = useSidebarOrderStore((state) =>
isActive && serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER,
);
const hasHydratedWorkspaces = useSessionStore((state) =>
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
const workspaceStructure = useWorkspaceStructure(isActive ? serverId : null);
const connectionStatus = useSyncExternalStore(
(onStoreChange) =>
isActive && serverId ? runtime.subscribe(serverId, onStoreChange) : () => {},
() => {
if (!isActive || !serverId) {
return "idle";
}
const snapshot = runtime.getSnapshot(serverId);
return snapshot?.connectionStatus ?? "idle";
},
() => {
if (!isActive || !serverId) {
return "idle";
}
const snapshot = runtime.getSnapshot(serverId);
return snapshot?.connectionStatus ?? "idle";
},
);
const projects = useMemo(() => {
if (!serverId || workspaceStructure.projects.length === 0) {
return EMPTY_PROJECTS;
}
return buildSidebarProjectsFromStructure({
serverId,
projects: workspaceStructure.projects,
});
}, [serverId, workspaceStructure]);
useEffect(() => {
if (!serverId) {
return;
}
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId]);
useEffect(() => {
if (!serverId) {
return;
}
const orderStore = useSidebarOrderStore.getState();
const updates = computeSidebarOrderUpdates({
projects,
persistedProjectOrder,
getWorkspaceOrder: (projectKey) => orderStore.getWorkspaceOrder(serverId, projectKey),
});
if (updates.projectOrder) {
orderStore.setProjectOrder(serverId, updates.projectOrder);
}
for (const { projectKey, order } of updates.workspaceOrders) {
orderStore.setWorkspaceOrder(serverId, projectKey, order);
}
}, [persistedProjectOrder, projects, serverId]);
const refreshAll = useCallback(() => {
if (!isActive || !serverId || connectionStatus !== "online") {
return;
}
const client = runtime.getClient(serverId);
if (!client) {
return;
}
void (async () => {
const next = new Map<string, WorkspaceDescriptor>();
let cursor: string | null = null;
try {
while (true) {
const payload = await client.fetchWorkspaces({
sort: [{ key: "activity_at", direction: "desc" }],
page: cursor ? { limit: 200, cursor } : { limit: 200 },
});
for (const entry of payload.entries) {
const workspace = toWorkspaceDescriptor(entry);
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
continue;
}
next.set(workspace.id, workspace);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;
}
cursor = payload.pageInfo.nextCursor;
}
const store = useSessionStore.getState();
store.setWorkspaces(serverId, next);
store.setHasHydratedWorkspaces(serverId, true);
} catch (error) {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
cursor,
error,
});
// ignore explicit refresh failures; hook keeps existing data
}
})();
}, [connectionStatus, isActive, runtime, serverId]);
const loadingState = deriveSidebarLoadingState({
isActive,
serverId,
hasHydratedWorkspaces,
hasProjects: projects.length > 0,
});
return {
projects,
...loadingState,
refreshAll,
};
}