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:
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import {
|
||||
useHasWorkspaces,
|
||||
useRecommendedProjectPaths,
|
||||
useResolveWorkspaceIdByCwd,
|
||||
useWorkspace,
|
||||
useWorkspaceExecutionAuthority,
|
||||
useWorkspaceFields,
|
||||
useWorkspaceKeys,
|
||||
useWorkspaceStatusesForBadges,
|
||||
useWorkspaceStructure,
|
||||
} from "./session-store-hooks";
|
||||
import { useSidebarOrderStore } from "./sidebar-order-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "./session-store";
|
||||
|
||||
const SERVER_ID = "test-server";
|
||||
|
||||
function createWorkspace(
|
||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||
): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
||||
projectRootPath: input.projectRootPath ?? "/repo",
|
||||
workspaceDirectory: input.workspaceDirectory ?? "/repo",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||
name: input.name ?? "main",
|
||||
status: input.status ?? "done",
|
||||
diffStat: input.diffStat ?? null,
|
||||
scripts: input.scripts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function initializeWorkspaces(workspaces: WorkspaceDescriptor[]): void {
|
||||
act(() => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(SERVER_ID, new Map(workspaces.map((workspace) => [workspace.id, workspace])));
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrderByServerId: {},
|
||||
workspaceOrderByServerAndProject: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkspace", () => {
|
||||
it("resolves a descriptor when the route id matches workspace identity but not the map key", () => {
|
||||
const workspace = createWorkspace({ id: "workspace-a" });
|
||||
act(() => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map([["map-key-a", workspace]]));
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useWorkspace(SERVER_ID, workspace.id));
|
||||
|
||||
expect(result.current).toBe(workspace);
|
||||
});
|
||||
|
||||
it("keeps the descriptor reference for unrelated workspace updates", () => {
|
||||
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
|
||||
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
|
||||
initializeWorkspaces([workspaceA, workspaceB]);
|
||||
|
||||
const { result } = renderHook(() => useWorkspace(SERVER_ID, workspaceA.id));
|
||||
const before = result.current;
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
|
||||
});
|
||||
expect(result.current).toBe(before);
|
||||
|
||||
act(() => {
|
||||
useSessionStore
|
||||
.getState()
|
||||
.mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "attention" }]);
|
||||
});
|
||||
expect(result.current).not.toBe(before);
|
||||
});
|
||||
|
||||
it("keeps the descriptor reference when the observed workspace is rewritten with content-equal data", () => {
|
||||
const workspace = createWorkspace({ id: "workspace-a", scripts: [] });
|
||||
initializeWorkspaces([workspace]);
|
||||
|
||||
const { result } = renderHook(() => useWorkspace(SERVER_ID, workspace.id));
|
||||
const before = result.current;
|
||||
|
||||
act(() => {
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(SERVER_ID, new Map([[workspace.id, { ...workspace, scripts: [] }]]));
|
||||
});
|
||||
expect(result.current).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkspaceExecutionAuthority", () => {
|
||||
it("preserves the old missing-workspace message with the requested id", () => {
|
||||
initializeWorkspaces([]);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceExecutionAuthority(SERVER_ID, "missing-id"));
|
||||
|
||||
expect(result.current).toMatchObject({
|
||||
ok: false,
|
||||
message: "Workspace not found: missing-id",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps deep-equal authority references under unrelated workspace updates", () => {
|
||||
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
|
||||
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
|
||||
initializeWorkspaces([workspaceA, workspaceB]);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceExecutionAuthority(SERVER_ID, workspaceA.id));
|
||||
const before = result.current;
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkspaceFields", () => {
|
||||
it("keeps deep-equal projection references until projected fields change", () => {
|
||||
const workspace = createWorkspace({ id: "workspace-a", name: "A", status: "done" });
|
||||
initializeWorkspaces([workspace]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWorkspaceFields(SERVER_ID, workspace.id, (current) => ({
|
||||
identity: {
|
||||
id: current.id,
|
||||
name: current.name,
|
||||
},
|
||||
})),
|
||||
);
|
||||
const before = result.current;
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]);
|
||||
});
|
||||
expect(result.current).toBe(before);
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
|
||||
{
|
||||
...workspace,
|
||||
name: "A renamed",
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
});
|
||||
expect(result.current).not.toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkspaceStructure", () => {
|
||||
it("changes for membership updates but not status-only updates", () => {
|
||||
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
|
||||
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
|
||||
initializeWorkspaces([workspaceA]);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID));
|
||||
const before = result.current;
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]);
|
||||
});
|
||||
const afterAdd = result.current;
|
||||
expect(afterAdd).not.toBe(before);
|
||||
expect(afterAdd.projects[0]?.workspaceKeys).toEqual(["workspace-a", "workspace-b"]);
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]);
|
||||
});
|
||||
expect(result.current).toBe(afterAdd);
|
||||
});
|
||||
|
||||
it("changes when a structure-relevant project identity field changes", () => {
|
||||
const workspace = createWorkspace({
|
||||
id: "workspace-a",
|
||||
projectDisplayName: "Project 1",
|
||||
});
|
||||
initializeWorkspaces([workspace]);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID));
|
||||
const before = result.current;
|
||||
|
||||
act(() => {
|
||||
useSessionStore
|
||||
.getState()
|
||||
.mergeWorkspaces(SERVER_ID, [{ ...workspace, projectDisplayName: "Project Renamed" }]);
|
||||
});
|
||||
expect(result.current).not.toBe(before);
|
||||
});
|
||||
|
||||
it("changes when persisted sidebar project order changes", () => {
|
||||
const workspaceA = createWorkspace({
|
||||
id: "workspace-a",
|
||||
projectId: "project-a",
|
||||
projectDisplayName: "Project A",
|
||||
});
|
||||
const workspaceB = createWorkspace({
|
||||
id: "workspace-b",
|
||||
projectId: "project-b",
|
||||
projectDisplayName: "Project B",
|
||||
});
|
||||
initializeWorkspaces([workspaceA, workspaceB]);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID));
|
||||
const before = result.current;
|
||||
|
||||
act(() => {
|
||||
useSidebarOrderStore.getState().setProjectOrder(SERVER_ID, ["project-b", "project-a"]);
|
||||
});
|
||||
expect(result.current).not.toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkspaceKeys", () => {
|
||||
it("changes for reorder updates but not content-only updates", () => {
|
||||
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
|
||||
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
|
||||
initializeWorkspaces([workspaceA, workspaceB]);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceKeys(SERVER_ID));
|
||||
const before = result.current;
|
||||
expect(before).toEqual(["workspace-a", "workspace-b"]);
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().setWorkspaces(
|
||||
SERVER_ID,
|
||||
new Map([
|
||||
[workspaceB.id, workspaceB],
|
||||
[workspaceA.id, workspaceA],
|
||||
]),
|
||||
);
|
||||
});
|
||||
const afterReorder = result.current;
|
||||
expect(afterReorder).not.toBe(before);
|
||||
expect(afterReorder).toEqual(["workspace-b", "workspace-a"]);
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]);
|
||||
});
|
||||
expect(result.current).toBe(afterReorder);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useRecommendedProjectPaths", () => {
|
||||
it("updates when an existing workspace project root changes", () => {
|
||||
const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" });
|
||||
initializeWorkspaces([workspace]);
|
||||
|
||||
const { result } = renderHook(() => useRecommendedProjectPaths(SERVER_ID));
|
||||
|
||||
act(() => {
|
||||
useSessionStore
|
||||
.getState()
|
||||
.mergeWorkspaces(SERVER_ID, [{ ...workspace, projectRootPath: "/repo/b" }]);
|
||||
});
|
||||
|
||||
expect(result.current).toEqual(["/repo/b"]);
|
||||
});
|
||||
|
||||
it("keeps the path list reference under unrelated workspace updates", () => {
|
||||
const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" });
|
||||
initializeWorkspaces([workspace]);
|
||||
|
||||
const { result } = renderHook(() => useRecommendedProjectPaths(SERVER_ID));
|
||||
const before = result.current;
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useHasWorkspaces", () => {
|
||||
it("stays stable when workspace membership changes without flipping the boolean", () => {
|
||||
const workspaceA = createWorkspace({ id: "workspace-a" });
|
||||
const workspaceB = createWorkspace({ id: "workspace-b" });
|
||||
initializeWorkspaces([workspaceA]);
|
||||
|
||||
const { result } = renderHook(() => useHasWorkspaces(SERVER_ID));
|
||||
const before = result.current;
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useResolveWorkspaceIdByCwd", () => {
|
||||
it("resolves by cwd and stays stable under unrelated updates", () => {
|
||||
const workspaceA = createWorkspace({
|
||||
id: "workspace-a",
|
||||
workspaceDirectory: "/repo/a",
|
||||
});
|
||||
const workspaceB = createWorkspace({
|
||||
id: "workspace-b",
|
||||
workspaceDirectory: "/repo/b",
|
||||
});
|
||||
initializeWorkspaces([workspaceA, workspaceB]);
|
||||
|
||||
const { result } = renderHook(() => useResolveWorkspaceIdByCwd(SERVER_ID, "/repo/a"));
|
||||
const before = result.current;
|
||||
expect(before).toBe("workspace-a");
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
|
||||
});
|
||||
expect(result.current).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkspaceStatusesForBadges", () => {
|
||||
it("tracks status changes without changing for no-ops or unrelated descriptor updates", () => {
|
||||
const workspaceA = createWorkspace({ id: "workspace-a", status: "done" });
|
||||
const workspaceB = createWorkspace({ id: "workspace-b", status: "attention" });
|
||||
initializeWorkspaces([workspaceA, workspaceB]);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceStatusesForBadges());
|
||||
const before = result.current;
|
||||
expect(before).toEqual(["done", "attention"]);
|
||||
|
||||
act(() => {
|
||||
useSessionStore
|
||||
.getState()
|
||||
.mergeWorkspaces(SERVER_ID, [{ ...workspaceA, scripts: [...workspaceA.scripts] }]);
|
||||
});
|
||||
expect(result.current).toBe(before);
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, name: "Renamed" }]);
|
||||
});
|
||||
expect(result.current).toBe(before);
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "failed" }]);
|
||||
});
|
||||
expect(result.current).not.toBe(before);
|
||||
expect(result.current).toEqual(["failed", "attention"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,384 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useSessionStore,
|
||||
type WorkspaceDescriptor,
|
||||
} from "./session-store";
|
||||
import { patchWorkspaceScripts } from "../contexts/session-workspace-scripts";
|
||||
|
||||
function createWorkspace(
|
||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||
@@ -32,6 +33,23 @@ afterEach(() => {
|
||||
useSessionStore.getState().clearSession("test-server");
|
||||
});
|
||||
|
||||
function initializeTestSession(): void {
|
||||
useSessionStore.getState().initializeSession("test-server", null as unknown as DaemonClient);
|
||||
}
|
||||
|
||||
function getTestSessionReferences() {
|
||||
const state = useSessionStore.getState();
|
||||
const session = state.sessions["test-server"];
|
||||
if (!session) {
|
||||
throw new Error("test session is not initialized");
|
||||
}
|
||||
return {
|
||||
sessions: state.sessions,
|
||||
session,
|
||||
workspaces: session.workspaces,
|
||||
};
|
||||
}
|
||||
|
||||
describe("normalizeWorkspaceDescriptor", () => {
|
||||
it("normalizes workspace scripts and invalid activity timestamps", () => {
|
||||
const scripts = [
|
||||
@@ -138,6 +156,126 @@ describe("mergeWorkspaces", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves identity when merging content-equal workspace descriptors", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
const workspace = createWorkspace({ id: "/repo/main" });
|
||||
|
||||
store.mergeWorkspaces("test-server", [workspace]);
|
||||
const first = getTestSessionReferences();
|
||||
|
||||
store.mergeWorkspaces("test-server", [{ ...workspace, scripts: [...workspace.scripts] }]);
|
||||
const second = getTestSessionReferences();
|
||||
|
||||
expect(second.sessions).toBe(first.sessions);
|
||||
expect(second.session).toBe(first.session);
|
||||
expect(second.workspaces).toBe(first.workspaces);
|
||||
expect(second.workspaces.get("/repo/main")).toBe(first.workspaces.get("/repo/main"));
|
||||
});
|
||||
|
||||
it("preserves unaffected workspace entry identity when one workspace changes", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
const workspaceA = createWorkspace({ id: "/repo/a", name: "main" });
|
||||
const workspaceB = createWorkspace({ id: "/repo/b", name: "feature" });
|
||||
|
||||
store.mergeWorkspaces("test-server", [workspaceA, workspaceB]);
|
||||
const before = getTestSessionReferences();
|
||||
const beforeA = before.workspaces.get("/repo/a");
|
||||
const beforeB = before.workspaces.get("/repo/b");
|
||||
|
||||
store.mergeWorkspaces("test-server", [{ ...workspaceA, status: "running" }]);
|
||||
const after = getTestSessionReferences();
|
||||
|
||||
expect(after.sessions).not.toBe(before.sessions);
|
||||
expect(after.session).not.toBe(before.session);
|
||||
expect(after.workspaces).not.toBe(before.workspaces);
|
||||
expect(after.workspaces.get("/repo/a")).not.toBe(beforeA);
|
||||
expect(after.workspaces.get("/repo/b")).toBe(beforeB);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setWorkspaces", () => {
|
||||
it("preserves identity when replacing workspaces with content-equal entries", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
const workspace = createWorkspace({ id: "/repo/main" });
|
||||
store.setWorkspaces("test-server", new Map([[workspace.id, workspace]]));
|
||||
const before = getTestSessionReferences();
|
||||
|
||||
store.setWorkspaces(
|
||||
"test-server",
|
||||
new Map([[workspace.id, { ...workspace, scripts: [...workspace.scripts] }]]),
|
||||
);
|
||||
const after = getTestSessionReferences();
|
||||
|
||||
expect(after.sessions).toBe(before.sessions);
|
||||
expect(after.session).toBe(before.session);
|
||||
expect(after.workspaces).toBe(before.workspaces);
|
||||
expect(after.workspaces.get(workspace.id)).toBe(before.workspaces.get(workspace.id));
|
||||
});
|
||||
|
||||
it("stores replacement descriptors as-is when diff stat is cleared", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
const workspace = createWorkspace({
|
||||
id: "/repo/main",
|
||||
diffStat: { additions: 2, deletions: 1 },
|
||||
});
|
||||
store.setWorkspaces("test-server", new Map([[workspace.id, workspace]]));
|
||||
const before = getTestSessionReferences();
|
||||
|
||||
store.setWorkspaces("test-server", new Map([[workspace.id, { ...workspace, diffStat: null }]]));
|
||||
const after = getTestSessionReferences();
|
||||
|
||||
expect(after.sessions).not.toBe(before.sessions);
|
||||
expect(after.session).not.toBe(before.session);
|
||||
expect(after.workspaces).not.toBe(before.workspaces);
|
||||
expect(after.workspaces.get(workspace.id)?.diffStat).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeWorkspace", () => {
|
||||
it("preserves identity when removing a missing workspace", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
const workspace = createWorkspace({ id: "/repo/main" });
|
||||
store.setWorkspaces("test-server", new Map([[workspace.id, workspace]]));
|
||||
const before = getTestSessionReferences();
|
||||
|
||||
store.removeWorkspace("test-server", "/repo/missing");
|
||||
const after = getTestSessionReferences();
|
||||
|
||||
expect(after.sessions).toBe(before.sessions);
|
||||
expect(after.session).toBe(before.session);
|
||||
expect(after.workspaces).toBe(before.workspaces);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchWorkspaceScripts", () => {
|
||||
it("preserves workspace entry identity when scripts are content-equal", () => {
|
||||
const script = {
|
||||
scriptName: "web",
|
||||
type: "service" as const,
|
||||
hostname: "web.paseo.localhost",
|
||||
port: 3000,
|
||||
proxyUrl: "http://web.paseo.localhost:6767",
|
||||
lifecycle: "running" as const,
|
||||
health: "healthy" as const,
|
||||
exitCode: null,
|
||||
};
|
||||
const workspace = createWorkspace({ id: "/repo/main", scripts: [script] });
|
||||
const current = new Map([[workspace.id, workspace]]);
|
||||
|
||||
const next = patchWorkspaceScripts(current, {
|
||||
workspaceId: workspace.id,
|
||||
scripts: [{ ...script }],
|
||||
});
|
||||
|
||||
expect(next).toBe(current);
|
||||
expect(next.get(workspace.id)).toBe(workspace);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeWorkspaceSnapshotWithExisting", () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import { create } from "zustand";
|
||||
import { subscribeWithSelector } from "zustand/middleware";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
@@ -159,6 +160,41 @@ export function mergeWorkspaceSnapshotWithExisting(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function preserveWorkspaceDescriptorIdentity(
|
||||
incoming: WorkspaceDescriptor,
|
||||
existing?: WorkspaceDescriptor | null,
|
||||
): WorkspaceDescriptor {
|
||||
if (existing && equal(existing, incoming)) {
|
||||
return existing;
|
||||
}
|
||||
return incoming;
|
||||
}
|
||||
|
||||
function preserveWorkspaceMapIdentity(
|
||||
existing: Map<string, WorkspaceDescriptor>,
|
||||
incoming: Map<string, WorkspaceDescriptor>,
|
||||
): Map<string, WorkspaceDescriptor> {
|
||||
if (existing === incoming) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const next = new Map<string, WorkspaceDescriptor>();
|
||||
let changed = existing.size !== incoming.size;
|
||||
const existingEntries = existing.entries();
|
||||
|
||||
for (const [key, workspace] of incoming) {
|
||||
const existingWorkspace = existing.get(key);
|
||||
const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existingWorkspace);
|
||||
next.set(key, nextWorkspace);
|
||||
const existingEntry = existingEntries.next().value;
|
||||
if (!existingEntry || existingEntry[0] !== key || existingEntry[1] !== nextWorkspace) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : existing;
|
||||
}
|
||||
|
||||
export type ExplorerEntryKind = "file" | "directory";
|
||||
export type ExplorerFileKind = "text" | "image" | "binary";
|
||||
export type ExplorerEncoding = "utf-8" | "base64" | "none";
|
||||
@@ -938,14 +974,18 @@ export const useSessionStore = create<SessionStore>()(
|
||||
}
|
||||
const nextWorkspaces =
|
||||
typeof workspaces === "function" ? workspaces(session.workspaces) : workspaces;
|
||||
if (session.workspaces === nextWorkspaces) {
|
||||
const preservedWorkspaces = preserveWorkspaceMapIdentity(
|
||||
session.workspaces,
|
||||
nextWorkspaces,
|
||||
);
|
||||
if (session.workspaces === preservedWorkspaces) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, workspaces: nextWorkspaces },
|
||||
[serverId]: { ...session, workspaces: preservedWorkspaces },
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -962,13 +1002,15 @@ export const useSessionStore = create<SessionStore>()(
|
||||
let changed = false;
|
||||
for (const workspace of nextEntries) {
|
||||
const existing = next.get(workspace.id);
|
||||
if (existing === workspace) {
|
||||
const mergedWorkspace = mergeWorkspaceSnapshotWithExisting({
|
||||
incoming: workspace,
|
||||
existing,
|
||||
});
|
||||
const nextWorkspace = preserveWorkspaceDescriptorIdentity(mergedWorkspace, existing);
|
||||
if (existing === nextWorkspace) {
|
||||
continue;
|
||||
}
|
||||
next.set(
|
||||
workspace.id,
|
||||
mergeWorkspaceSnapshotWithExisting({ incoming: workspace, existing }),
|
||||
);
|
||||
next.set(workspace.id, nextWorkspace);
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) {
|
||||
|
||||
Reference in New Issue
Block a user