Files
paseo/packages/app/src/stores/session-store-hooks.ts
T
Mohamed Boudra bd1fb311ea refactor(app): reshape session store reads around narrow hook surface
Eliminate sidebar re-render cascades by fixing both sides of the
subscription contract:

- Session-store writes preserve identity: setWorkspaces, mergeWorkspaces,
  removeWorkspace, and patchWorkspaceScripts return the previous Map/entry
  reference when content is unchanged.
- HostRuntimeController.updateSnapshot only notifies when a patched field
  actually changed; idle probe ticks no longer replace the snapshot.
- New canonical hook surface in stores/session-store-hooks.ts
  (useWorkspace, useWorkspaceFields, useWorkspaceStructure,
  useWorkspaceKeys, useResolveWorkspaceIdByCwd,
  useWorkspaceStatusesForBadges, useWorkspaceExecutionAuthority,
  useRecommendedProjectPaths, useHasWorkspaces). These are the only
  place to subscribe to workspaces.
- 15 greedy subscribers migrated to the hook surface. The sidebar is now
  driven by useWorkspaceStructure for identity/ordering; each row
  hydrates its own descriptor via useWorkspaceFields, so a workspace
  update re-renders only its row. Archive-redirect callbacks switched to
  event-time useSessionStore.getState().
- Deleted dead code: use-session-directory.ts, workspace-fetch-debug.ts,
  and the unused SidebarProjectEntry aggregates (activeCount,
  totalWorkspaces, aggregated statusBucket).

No user-visible behavior change. Targeted tests green (79 tests across
host-runtime, session-store, session-store-hooks, sidebar list, sidebar
row model, sidebar shortcuts, workspace source of truth).
2026-04-19 20:19:45 +07:00

385 lines
11 KiB
TypeScript

import equal from "fast-deep-equal";
import { useMemo } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
import {
getWorkspaceExecutionAuthority,
resolveWorkspaceIdByExecutionDirectory,
resolveWorkspaceMapKeyByIdentity,
type WorkspaceExecutionAuthorityResult,
} from "@/utils/workspace-execution";
import { useSessionStore, type WorkspaceDescriptor } from "./session-store";
// These are the ONLY supported ways to read workspaces from the session store.
// Do not write raw `useSessionStore` selectors that return the workspaces Map, a session object,
// or the sessions dict — it breaks re-render isolation.
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
export interface WorkspaceStructureProject {
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
workspaceKeys: string[];
}
export interface WorkspaceStructure {
projects: WorkspaceStructureProject[];
}
type SessionStoreSnapshot = ReturnType<typeof useSessionStore.getState>;
const EMPTY_WORKSPACE_KEYS: string[] = [];
const EMPTY_WORKSPACE_STRUCTURE: WorkspaceStructure = { projects: [] };
function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string {
return `${serverId.trim()}::${projectKey.trim()}`;
}
function compareWorkspaceStructureItems(
left: { workspaceId: string; workspaceName: string },
right: { workspaceId: string; workspaceName: string },
): number {
const nameDelta = left.workspaceName.localeCompare(right.workspaceName, undefined, {
numeric: true,
sensitivity: "base",
});
if (nameDelta !== 0) {
return nameDelta;
}
return left.workspaceId.localeCompare(right.workspaceId, undefined, {
sensitivity: "base",
});
}
function compareWorkspaceStructureProjects(
left: WorkspaceStructureProject,
right: WorkspaceStructureProject,
): number {
return left.projectName.localeCompare(right.projectName, undefined, {
numeric: true,
sensitivity: "base",
});
}
function applyStoredOrdering<T>(input: {
items: T[];
storedOrder: readonly 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;
}
function selectWorkspace(
state: SessionStoreSnapshot,
serverId: string | null,
workspaceId: string | null,
): WorkspaceDescriptor | null {
if (!serverId || !workspaceId) {
return null;
}
const workspaces = state.sessions[serverId]?.workspaces;
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceId,
});
return workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
}
export function useWorkspace(
serverId: string | null,
workspaceId: string | null,
): WorkspaceDescriptor | null {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectWorkspace(state, serverId, workspaceId),
Object.is,
);
}
export function useWorkspaceFields<T>(
serverId: string | null,
workspaceId: string | null,
project: (w: WorkspaceDescriptor) => T,
): T | null {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
const workspace = selectWorkspace(state, serverId, workspaceId);
return workspace ? project(workspace) : null;
},
equal,
);
}
export function useWorkspaceExecutionAuthority(
serverId: string | null,
workspaceId: string | null,
): WorkspaceExecutionAuthorityResult | null {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (serverId === null || workspaceId === null) {
return null;
}
return getWorkspaceExecutionAuthority({
workspaces: state.sessions[serverId]?.workspaces,
workspaceId,
});
},
equal,
);
}
function selectWorkspaceStructureProjects(
state: SessionStoreSnapshot,
serverId: string | null,
): WorkspaceStructureProject[] {
if (!serverId) {
return EMPTY_WORKSPACE_STRUCTURE.projects;
}
const workspaces = state.sessions[serverId]?.workspaces;
if (!workspaces || workspaces.size === 0) {
return EMPTY_WORKSPACE_STRUCTURE.projects;
}
const byProject = new Map<
string,
WorkspaceStructureProject & {
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
}
>();
for (const workspace of workspaces.values()) {
const project =
byProject.get(workspace.projectId) ??
({
projectKey: workspace.projectId,
projectName:
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
projectKind: workspace.projectKind,
iconWorkingDir: workspace.projectRootPath,
workspaceKeys: [],
workspaces: [],
} satisfies WorkspaceStructureProject & {
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
});
project.workspaces.push({
workspaceId: workspace.id,
workspaceName: workspace.name,
workspaceKey: `${serverId}:${workspace.id}`,
});
byProject.set(workspace.projectId, project);
}
const projects = Array.from(byProject.values()).map(
({ workspaces: projectWorkspaces, ...project }) => {
const sortedWorkspaces = [...projectWorkspaces].sort(compareWorkspaceStructureItems);
return {
...project,
workspaceKeys: sortedWorkspaces.map((workspace) => workspace.workspaceId),
};
},
);
projects.sort(compareWorkspaceStructureProjects);
return projects;
}
function selectWorkspaceOrderByScopeForServer(
workspaceOrderByScope: Record<string, string[]>,
serverId: string | null,
): Record<string, string[]> {
if (!serverId) {
return {};
}
const prefix = `${serverId.trim()}::`;
const relevantOrderByScope: Record<string, string[]> = {};
for (const [scopeKey, order] of Object.entries(workspaceOrderByScope)) {
if (scopeKey.startsWith(prefix)) {
relevantOrderByScope[scopeKey] = order;
}
}
return relevantOrderByScope;
}
export function useWorkspaceStructure(serverId: string | null): WorkspaceStructure {
const projects = useStoreWithEqualityFn(
useSessionStore,
(state) => selectWorkspaceStructureProjects(state, serverId),
equal,
);
const projectOrder = useStoreWithEqualityFn(
useSidebarOrderStore,
(state) =>
serverId
? (state.projectOrderByServerId[serverId] ?? EMPTY_WORKSPACE_KEYS)
: EMPTY_WORKSPACE_KEYS,
equal,
);
const workspaceOrderByScope = useStoreWithEqualityFn(
useSidebarOrderStore,
(state) =>
selectWorkspaceOrderByScopeForServer(state.workspaceOrderByServerAndProject, serverId),
equal,
);
return useMemo(() => {
if (!serverId || projects.length === 0) {
return EMPTY_WORKSPACE_STRUCTURE;
}
const orderedProjects = applyStoredOrdering({
items: projects.map((project) => {
const workspaceOrder =
workspaceOrderByScope[getWorkspaceOrderScopeKey(serverId, project.projectKey)] ??
EMPTY_WORKSPACE_KEYS;
const workspaceItems = project.workspaceKeys.map((workspaceId) => ({
workspaceId,
workspaceKey: `${serverId}:${workspaceId}`,
}));
return {
...project,
workspaceKeys: applyStoredOrdering({
items: workspaceItems,
storedOrder: workspaceOrder,
getKey: (workspace) => workspace.workspaceKey,
}).map((workspace) => workspace.workspaceId),
};
}),
storedOrder: projectOrder,
getKey: (project) => project.projectKey,
});
return { projects: orderedProjects };
}, [projectOrder, projects, serverId, workspaceOrderByScope]);
}
export function useWorkspaceKeys(serverId: string | null): string[] {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (!serverId) {
return EMPTY_WORKSPACE_KEYS;
}
const workspaces = state.sessions[serverId]?.workspaces;
return workspaces ? Array.from(workspaces.keys()) : EMPTY_WORKSPACE_KEYS;
},
equal,
);
}
export function useRecommendedProjectPaths(serverId: string | null): string[] {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (!serverId) {
return EMPTY_WORKSPACE_KEYS;
}
const workspaces = state.sessions[serverId]?.workspaces;
if (!workspaces) {
return EMPTY_WORKSPACE_KEYS;
}
return Array.from(workspaces.values())
.map((workspace) => workspace.projectRootPath)
.filter((path) => path.length > 0);
},
equal,
);
}
export function useHasWorkspaces(serverId: string | null): boolean {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (!serverId) {
return false;
}
return (state.sessions[serverId]?.workspaces?.size ?? 0) > 0;
},
Object.is,
);
}
export function useResolveWorkspaceIdByCwd(
serverId: string | null,
cwd: string | null | undefined,
): string | null {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (!serverId || !cwd) {
return null;
}
const workspaces = state.sessions[serverId]?.workspaces;
return resolveWorkspaceIdByExecutionDirectory({
workspaces: workspaces?.values(),
workspaceDirectory: cwd,
});
},
Object.is,
);
}
export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] {
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
const statuses: DesktopBadgeWorkspaceStatus[] = [];
for (const session of Object.values(state.sessions)) {
for (const workspace of session.workspaces.values()) {
statuses.push(workspace.status);
}
}
return statuses;
},
equal,
);
}