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).
This commit is contained in:
@@ -6,13 +6,15 @@ import {
|
||||
useSessionStore,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import {
|
||||
useWorkspaceStructure,
|
||||
type WorkspaceStructureProject,
|
||||
} from "@/stores/session-store-hooks";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
||||
|
||||
const EMPTY_ORDER: string[] = [];
|
||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||
const EMPTY_WORKSPACE_ORDER_SCOPE: Record<string, string[]> = {};
|
||||
|
||||
export type SidebarStateBucket = WorkspaceDescriptor["status"];
|
||||
|
||||
@@ -36,9 +38,6 @@ export interface SidebarProjectEntry {
|
||||
projectName: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
iconWorkingDir: string;
|
||||
statusBucket: SidebarStateBucket;
|
||||
activeCount: number;
|
||||
totalWorkspaces: number;
|
||||
workspaces: SidebarWorkspaceEntry[];
|
||||
}
|
||||
|
||||
@@ -50,119 +49,68 @@ export interface SidebarWorkspacesListResult {
|
||||
refreshAll: () => void;
|
||||
}
|
||||
|
||||
const SIDEBAR_BUCKET_PRIORITY: Record<SidebarStateBucket, number> = {
|
||||
done: 0,
|
||||
attention: 1,
|
||||
running: 2,
|
||||
failed: 3,
|
||||
needs_input: 4,
|
||||
};
|
||||
|
||||
function aggregateBucket(
|
||||
current: SidebarStateBucket,
|
||||
candidate: SidebarStateBucket,
|
||||
): SidebarStateBucket {
|
||||
return SIDEBAR_BUCKET_PRIORITY[candidate] > SIDEBAR_BUCKET_PRIORITY[current]
|
||||
? candidate
|
||||
: current;
|
||||
}
|
||||
|
||||
function compareWorkspaceBaseline(
|
||||
left: SidebarWorkspaceEntry,
|
||||
right: SidebarWorkspaceEntry,
|
||||
): number {
|
||||
const nameDelta = left.name.localeCompare(right.name, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
if (nameDelta !== 0) {
|
||||
return nameDelta;
|
||||
}
|
||||
|
||||
return left.workspaceId.localeCompare(right.workspaceId, undefined, {
|
||||
sensitivity: "base",
|
||||
});
|
||||
}
|
||||
|
||||
function compareProjectBaseline(left: SidebarProjectEntry, right: SidebarProjectEntry): number {
|
||||
return left.projectName.localeCompare(right.projectName, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
}
|
||||
|
||||
export function buildSidebarProjectsFromWorkspaces(input: {
|
||||
function createStructuralWorkspaceEntry(input: {
|
||||
serverId: string;
|
||||
workspaces: Iterable<WorkspaceDescriptor>;
|
||||
projectOrder: string[];
|
||||
workspaceOrderByScope: Record<string, string[]>;
|
||||
project: WorkspaceStructureProject;
|
||||
workspaceId: string;
|
||||
}): SidebarWorkspaceEntry {
|
||||
return {
|
||||
workspaceKey: `${input.serverId}:${input.workspaceId}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
projectRootPath: input.project.iconWorkingDir,
|
||||
workspaceDirectory: undefined,
|
||||
projectKind: input.project.projectKind,
|
||||
workspaceKind: "checkout",
|
||||
name: input.workspaceId,
|
||||
statusBucket: "done",
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSidebarWorkspaceEntry(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
}): SidebarWorkspaceEntry {
|
||||
return {
|
||||
workspaceKey: `${input.serverId}:${input.workspace.id}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
projectRootPath: input.workspace.projectRootPath,
|
||||
workspaceDirectory: input.workspace.workspaceDirectory,
|
||||
projectKind: input.workspace.projectKind,
|
||||
workspaceKind: input.workspace.workspaceKind,
|
||||
name: input.workspace.name,
|
||||
statusBucket: input.workspace.status,
|
||||
diffStat: input.workspace.diffStat,
|
||||
scripts: input.workspace.scripts,
|
||||
hasRunningScripts: input.workspace.scripts.some((script) => script.lifecycle === "running"),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSidebarProjectsFromStructure(input: {
|
||||
serverId: string;
|
||||
projects: WorkspaceStructureProject[];
|
||||
}): SidebarProjectEntry[] {
|
||||
const byProject = new Map<string, SidebarProjectEntry>();
|
||||
|
||||
for (const workspace of input.workspaces) {
|
||||
const project =
|
||||
byProject.get(workspace.projectId) ??
|
||||
({
|
||||
projectKey: workspace.projectId,
|
||||
projectName:
|
||||
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
|
||||
projectKind: workspace.projectKind,
|
||||
iconWorkingDir: workspace.projectRootPath,
|
||||
statusBucket: "done",
|
||||
activeCount: 0,
|
||||
totalWorkspaces: 0,
|
||||
workspaces: [],
|
||||
} satisfies SidebarProjectEntry);
|
||||
|
||||
const row: SidebarWorkspaceEntry = {
|
||||
workspaceKey: `${input.serverId}:${workspace.id}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: workspace.id,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
projectKind: workspace.projectKind,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
name: workspace.name,
|
||||
statusBucket: workspace.status,
|
||||
diffStat: workspace.diffStat,
|
||||
scripts: workspace.scripts,
|
||||
hasRunningScripts: workspace.scripts.some((script) => script.lifecycle === "running"),
|
||||
};
|
||||
|
||||
project.workspaces.push(row);
|
||||
project.totalWorkspaces += 1;
|
||||
if (workspace.status !== "done") {
|
||||
project.activeCount += 1;
|
||||
}
|
||||
project.statusBucket = aggregateBucket(project.statusBucket, workspace.status);
|
||||
|
||||
byProject.set(workspace.projectId, project);
|
||||
if (input.projects.length === 0) {
|
||||
return EMPTY_PROJECTS;
|
||||
}
|
||||
|
||||
const baselineProjects = Array.from(byProject.values()).map((project) => {
|
||||
const baselineWorkspaces = [...project.workspaces];
|
||||
baselineWorkspaces.sort(compareWorkspaceBaseline);
|
||||
|
||||
const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(input.serverId, project.projectKey);
|
||||
const orderedWorkspaces = applyStoredOrdering({
|
||||
items: baselineWorkspaces,
|
||||
storedOrder: input.workspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER,
|
||||
getKey: (workspace) => workspace.workspaceKey,
|
||||
});
|
||||
|
||||
return {
|
||||
...project,
|
||||
workspaces: orderedWorkspaces,
|
||||
};
|
||||
});
|
||||
|
||||
baselineProjects.sort(compareProjectBaseline);
|
||||
|
||||
return applyStoredOrdering({
|
||||
items: baselineProjects,
|
||||
storedOrder: input.projectOrder,
|
||||
getKey: (project) => project.projectKey,
|
||||
});
|
||||
return input.projects.map((project) => ({
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
projectKind: project.projectKind,
|
||||
iconWorkingDir: project.iconWorkingDir,
|
||||
workspaces: project.workspaceKeys.map((workspaceId) =>
|
||||
createStructuralWorkspaceEntry({
|
||||
serverId: input.serverId,
|
||||
project,
|
||||
workspaceId,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
export function applyStoredOrdering<T>(input: {
|
||||
@@ -229,10 +177,6 @@ export function appendMissingOrderKeys(input: {
|
||||
return [...input.currentOrder, ...missingKeys];
|
||||
}
|
||||
|
||||
function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string {
|
||||
return `${serverId.trim()}::${projectKey.trim()}`;
|
||||
}
|
||||
|
||||
function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor {
|
||||
return normalizeWorkspaceDescriptor(payload);
|
||||
}
|
||||
@@ -250,17 +194,12 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const persistedProjectOrder = useSidebarOrderStore((state) =>
|
||||
serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER,
|
||||
);
|
||||
const persistedWorkspaceOrderByScope = useSidebarOrderStore((state) =>
|
||||
serverId ? state.workspaceOrderByServerAndProject : EMPTY_WORKSPACE_ORDER_SCOPE,
|
||||
);
|
||||
|
||||
const isActive = Boolean(serverId);
|
||||
const sessionWorkspaces = useSessionStore((state) =>
|
||||
isActive && serverId ? (state.sessions[serverId]?.workspaces ?? null) : null,
|
||||
);
|
||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||
);
|
||||
const workspaceStructure = useWorkspaceStructure(isActive ? serverId : null);
|
||||
|
||||
const connectionStatus = useSyncExternalStore(
|
||||
(onStoreChange) =>
|
||||
@@ -282,22 +221,20 @@ export function useSidebarWorkspacesList(options?: {
|
||||
);
|
||||
|
||||
const projects = useMemo(() => {
|
||||
if (!sessionWorkspaces || sessionWorkspaces.size === 0 || !serverId) {
|
||||
if (!serverId || workspaceStructure.projects.length === 0) {
|
||||
return EMPTY_PROJECTS;
|
||||
}
|
||||
return buildSidebarProjectsFromWorkspaces({
|
||||
return buildSidebarProjectsFromStructure({
|
||||
serverId,
|
||||
workspaces: sessionWorkspaces.values(),
|
||||
projectOrder: persistedProjectOrder,
|
||||
workspaceOrderByScope: persistedWorkspaceOrderByScope,
|
||||
projects: workspaceStructure.projects,
|
||||
});
|
||||
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces]);
|
||||
}, [serverId, workspaceStructure]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId, sessionWorkspaces]);
|
||||
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId || projects.length === 0) {
|
||||
@@ -313,9 +250,9 @@ export function useSidebarWorkspacesList(options?: {
|
||||
}
|
||||
|
||||
for (const project of projects) {
|
||||
const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(serverId, project.projectKey);
|
||||
const persistedWorkspaceOrder =
|
||||
persistedWorkspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER;
|
||||
const persistedWorkspaceOrder = useSidebarOrderStore
|
||||
.getState()
|
||||
.getWorkspaceOrder(serverId, project.projectKey);
|
||||
const nextWorkspaceOrder = appendMissingOrderKeys({
|
||||
currentOrder: persistedWorkspaceOrder,
|
||||
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
|
||||
@@ -326,7 +263,7 @@ export function useSidebarWorkspacesList(options?: {
|
||||
.setWorkspaceOrder(serverId, project.projectKey, nextWorkspaceOrder);
|
||||
}
|
||||
}
|
||||
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, projects, serverId]);
|
||||
}, [persistedProjectOrder, projects, serverId]);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
if (!isActive || !serverId || connectionStatus !== "online") {
|
||||
|
||||
Reference in New Issue
Block a user