mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
127
package-lock.json
generated
127
package-lock.json
generated
@@ -12765,6 +12765,93 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@testing-library/react": {
|
||||
"version": "16.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
|
||||
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/react-hooks": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz",
|
||||
@@ -12880,6 +12967,14 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -14893,6 +14988,17 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/array-buffer-byte-length": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
|
||||
@@ -18008,6 +18114,14 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
@@ -27199,6 +27313,17 @@
|
||||
"react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -37233,6 +37358,7 @@
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-updates": "~29.0.12",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"react": "19.1.0",
|
||||
@@ -37261,6 +37387,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "~19.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eas-cli": "^16.24.1",
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-updates": "~29.0.12",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"react": "19.1.0",
|
||||
@@ -107,6 +108,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "~19.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eas-cli": "^16.24.1",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
@@ -32,21 +33,10 @@ function HostAgentReadyRouteContent() {
|
||||
}
|
||||
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
|
||||
});
|
||||
const sessionWorkspaces = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
||||
);
|
||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||
);
|
||||
const resolvedWorkspaceId = useSessionStore((state) => {
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
}
|
||||
return resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: state.sessions[serverId]?.workspaces?.values(),
|
||||
workspaceDirectory: state.sessions[serverId]?.agents?.get(agentId)?.cwd,
|
||||
});
|
||||
});
|
||||
const resolvedWorkspaceId = useResolveWorkspaceIdByCwd(serverId, agentCwd);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
@@ -102,8 +92,9 @@ function HostAgentReadyRouteContent() {
|
||||
return;
|
||||
}
|
||||
const cwd = result?.agent?.cwd?.trim();
|
||||
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: sessionWorkspaces?.values(),
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
if (!workspaceId && !hasHydratedWorkspaces) {
|
||||
@@ -133,7 +124,7 @@ function HostAgentReadyRouteContent() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId, sessionWorkspaces]);
|
||||
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { usePathname } from "expo-router";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
|
||||
import { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
@@ -29,9 +29,7 @@ export function ProjectPickerModal() {
|
||||
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const workspaces = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
||||
);
|
||||
const recommendedPaths = useRecommendedProjectPaths(serverId);
|
||||
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -39,13 +37,6 @@ export function ProjectPickerModal() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const openProject = useOpenProject(serverId);
|
||||
|
||||
const recommendedPaths = useMemo(() => {
|
||||
if (!workspaces) return [];
|
||||
return Array.from(workspaces.values())
|
||||
.map((workspace) => workspace.projectRootPath)
|
||||
.filter((path) => path.length > 0);
|
||||
}, [workspaces]);
|
||||
|
||||
const directorySuggestionsQuery = useQuery({
|
||||
queryKey: ["project-picker-directory-suggestions", serverId, query],
|
||||
queryFn: async () => {
|
||||
|
||||
337
packages/app/src/components/sidebar-workspace-list.test.tsx
Normal file
337
packages/app/src/components/sidebar-workspace-list.test.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from "@testing-library/react";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { WorkspaceScriptPayload } from "@server/shared/messages";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import React from "react";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
vi.hoisted(() => {
|
||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||
});
|
||||
|
||||
import {
|
||||
createSidebarWorkspaceEntry,
|
||||
type SidebarProjectEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
type HostRuntimeController,
|
||||
type HostRuntimeSnapshot,
|
||||
} from "@/runtime/host-runtime";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const SERVER_ID = "sidebar-render-count";
|
||||
|
||||
type RenderCounts = {
|
||||
frame: number;
|
||||
headers: Record<string, number>;
|
||||
rows: Record<string, number>;
|
||||
};
|
||||
|
||||
const runningScript: WorkspaceScriptPayload = {
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web.paseo.localhost",
|
||||
port: 3000,
|
||||
proxyUrl: "http://web.paseo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
};
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
name: string;
|
||||
status?: WorkspaceDescriptor["status"];
|
||||
scripts?: WorkspaceDescriptor["scripts"];
|
||||
}): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
projectDisplayName: input.projectDisplayName,
|
||||
projectRootPath: `/repo/${input.projectId}`,
|
||||
workspaceDirectory: `/repo/${input.projectId}/${input.id}`,
|
||||
projectKind: "git",
|
||||
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
|
||||
name: input.name,
|
||||
status: input.status ?? "done",
|
||||
diffStat: null,
|
||||
scripts: input.scripts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkspaces(): WorkspaceDescriptor[] {
|
||||
return [
|
||||
workspace({
|
||||
id: "a-main",
|
||||
projectId: "project-a",
|
||||
projectDisplayName: "Project A",
|
||||
name: "main",
|
||||
scripts: [runningScript],
|
||||
}),
|
||||
workspace({
|
||||
id: "a-one",
|
||||
projectId: "project-a",
|
||||
projectDisplayName: "Project A",
|
||||
name: "one",
|
||||
}),
|
||||
workspace({
|
||||
id: "a-two",
|
||||
projectId: "project-a",
|
||||
projectDisplayName: "Project A",
|
||||
name: "two",
|
||||
}),
|
||||
workspace({
|
||||
id: "b-main",
|
||||
projectId: "project-b",
|
||||
projectDisplayName: "Project B",
|
||||
name: "main",
|
||||
}),
|
||||
workspace({
|
||||
id: "b-one",
|
||||
projectId: "project-b",
|
||||
projectDisplayName: "Project B",
|
||||
name: "one",
|
||||
}),
|
||||
workspace({
|
||||
id: "b-two",
|
||||
projectId: "project-b",
|
||||
projectDisplayName: "Project B",
|
||||
name: "two",
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function makeHost(): HostProfile {
|
||||
const now = "2026-04-19T00:00:00.000Z";
|
||||
return {
|
||||
serverId: SERVER_ID,
|
||||
label: "Render Count Host",
|
||||
lifecycle: {},
|
||||
connections: [],
|
||||
preferredConnectionId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
|
||||
act(() => {
|
||||
getHostRuntimeStore().syncHosts([makeHost()]);
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
|
||||
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrderByServerId: {
|
||||
[SERVER_ID]: ["project-a", "project-b"],
|
||||
},
|
||||
workspaceOrderByServerAndProject: {
|
||||
[`${SERVER_ID}::project-a`]: [
|
||||
`${SERVER_ID}:a-main`,
|
||||
`${SERVER_ID}:a-one`,
|
||||
`${SERVER_ID}:a-two`,
|
||||
],
|
||||
[`${SERVER_ID}::project-b`]: [
|
||||
`${SERVER_ID}:b-main`,
|
||||
`${SERVER_ID}:b-one`,
|
||||
`${SERVER_ID}:b-two`,
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resetCounts(counts: RenderCounts): void {
|
||||
counts.frame = 0;
|
||||
counts.headers = {};
|
||||
counts.rows = {};
|
||||
}
|
||||
|
||||
function incrementRecord(record: Record<string, number>, key: string): void {
|
||||
record[key] = (record[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
function ProjectHeaderProbe({
|
||||
project,
|
||||
counts,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
counts: RenderCounts;
|
||||
}): null {
|
||||
incrementRecord(counts.headers, project.projectKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
function WorkspaceRowProbe({
|
||||
serverId,
|
||||
workspaceId,
|
||||
counts,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
counts: RenderCounts;
|
||||
}): null {
|
||||
const workspace = useWorkspaceFields(serverId, workspaceId, (entry) =>
|
||||
createSidebarWorkspaceEntry({ serverId, workspace: entry }),
|
||||
);
|
||||
if (workspace) {
|
||||
incrementRecord(counts.rows, workspace.workspaceId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
|
||||
counts.frame += 1;
|
||||
const { projects } = useSidebarWorkspacesList({ serverId: SERVER_ID });
|
||||
|
||||
return (
|
||||
<>
|
||||
{projects.map((project) => (
|
||||
<div key={project.projectKey}>
|
||||
<ProjectHeaderProbe project={project} counts={counts} />
|
||||
{project.workspaces.map((workspace) => (
|
||||
<WorkspaceRowProbe
|
||||
key={workspace.workspaceKey}
|
||||
serverId={workspace.serverId}
|
||||
workspaceId={workspace.workspaceId}
|
||||
counts={counts}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getHostController(): HostRuntimeController {
|
||||
const controllers = (
|
||||
getHostRuntimeStore() as unknown as {
|
||||
controllers: Map<string, HostRuntimeController>;
|
||||
}
|
||||
).controllers;
|
||||
const controller = controllers.get(SERVER_ID);
|
||||
if (!controller) {
|
||||
throw new Error("Host runtime controller was not initialized");
|
||||
}
|
||||
return controller;
|
||||
}
|
||||
|
||||
function updateControllerSnapshot(
|
||||
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
|
||||
): void {
|
||||
(
|
||||
getHostController() as unknown as {
|
||||
updateSnapshot: (
|
||||
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
|
||||
) => void;
|
||||
}
|
||||
).updateSnapshot(patch);
|
||||
}
|
||||
|
||||
async function renderProbe(counts: RenderCounts): Promise<{ root: Root; container: HTMLElement }> {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<SidebarFrameProbe counts={counts} />);
|
||||
});
|
||||
resetCounts(counts);
|
||||
return { root, container };
|
||||
}
|
||||
|
||||
describe("sidebar workspace render isolation", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
initializeSidebarState(createWorkspaces());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
act(() => {
|
||||
getHostRuntimeStore().syncHosts([]);
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrderByServerId: {},
|
||||
workspaceOrderByServerAndProject: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("re-renders only the changed workspace row for a status update", async () => {
|
||||
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} };
|
||||
({ root, container } = await renderProbe(counts));
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
|
||||
{
|
||||
...createWorkspaces()[1]!,
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
expect(counts.frame).toBe(0);
|
||||
expect(counts.headers).toEqual({});
|
||||
expect(counts.rows).toEqual({ "a-one": 1 });
|
||||
});
|
||||
|
||||
it("does not re-render the sidebar for a host-runtime probe tick with no content change", async () => {
|
||||
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} };
|
||||
({ root, container } = await renderProbe(counts));
|
||||
|
||||
act(() => {
|
||||
const probeByConnectionId = getHostController().getSnapshot().probeByConnectionId;
|
||||
updateControllerSnapshot({
|
||||
probeByConnectionId: new Map(probeByConnectionId),
|
||||
});
|
||||
});
|
||||
|
||||
expect(counts).toEqual({ frame: 0, headers: {}, rows: {} });
|
||||
});
|
||||
|
||||
it("does not re-render for a deep-equal scripts patch", async () => {
|
||||
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} };
|
||||
({ root, container } = await renderProbe(counts));
|
||||
|
||||
act(() => {
|
||||
useSessionStore.getState().setWorkspaces(SERVER_ID, (current) =>
|
||||
patchWorkspaceScripts(current, {
|
||||
workspaceId: "a-main",
|
||||
scripts: [{ ...runningScript }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(counts).toEqual({ frame: 0, headers: {}, rows: {} });
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
} from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import {
|
||||
createSidebarWorkspaceEntry,
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
@@ -92,7 +93,12 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
@@ -114,7 +120,6 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
|
||||
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
|
||||
|
||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||
const EMPTY_WORKSPACES = new Map();
|
||||
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||
@@ -194,6 +199,19 @@ interface WorkspaceRowInnerProps {
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}
|
||||
|
||||
function useSidebarWorkspaceEntry(
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
): SidebarWorkspaceEntry | null {
|
||||
const projectWorkspaceEntry = useCallback(
|
||||
(workspace: WorkspaceDescriptor): SidebarWorkspaceEntry =>
|
||||
createSidebarWorkspaceEntry({ serverId: serverId ?? "", workspace }),
|
||||
[serverId],
|
||||
);
|
||||
|
||||
return useWorkspaceFields(serverId, workspaceId, projectWorkspaceEntry);
|
||||
}
|
||||
|
||||
export function PrBadge({ hint }: { hint: PrHint }) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -1151,9 +1169,6 @@ function WorkspaceRowWithMenu({
|
||||
const toast = useToast();
|
||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
const sessionWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
|
||||
);
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
@@ -1181,10 +1196,11 @@ function WorkspaceRowWithMenu({
|
||||
buildWorkspaceArchiveRedirectRoute({
|
||||
serverId: workspace.serverId,
|
||||
archivedWorkspaceId: workspace.workspaceId,
|
||||
workspaces: sessionWorkspaces.values(),
|
||||
workspaces:
|
||||
useSessionStore.getState().sessions[workspace.serverId]?.workspaces.values() ?? [],
|
||||
}),
|
||||
);
|
||||
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
|
||||
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
if (isArchiving) {
|
||||
@@ -1378,9 +1394,6 @@ function NonGitProjectRowWithMenuContent({
|
||||
const toast = useToast();
|
||||
const contextMenu = useContextMenu();
|
||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
||||
const sessionWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
|
||||
);
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
const redirectAfterArchive = useCallback(() => {
|
||||
if (
|
||||
@@ -1394,10 +1407,11 @@ function NonGitProjectRowWithMenuContent({
|
||||
buildWorkspaceArchiveRedirectRoute({
|
||||
serverId: workspace.serverId,
|
||||
archivedWorkspaceId: workspace.workspaceId,
|
||||
workspaces: sessionWorkspaces.values(),
|
||||
workspaces:
|
||||
useSessionStore.getState().sessions[workspace.serverId]?.workspaces.values() ?? [],
|
||||
}) as any,
|
||||
);
|
||||
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
|
||||
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
if (isArchivingWorkspace) {
|
||||
@@ -1542,13 +1556,19 @@ function FlattenedProjectRow({
|
||||
onRemoveProject?: () => void;
|
||||
removeProjectStatus?: "idle" | "pending";
|
||||
}) {
|
||||
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
|
||||
|
||||
if (!workspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (project.projectKind === "directory") {
|
||||
return (
|
||||
<NonGitProjectRowWithMenu
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={rowModel.workspace}
|
||||
workspace={workspace}
|
||||
selected={rowModel.selected}
|
||||
onPress={onPress}
|
||||
shortcutNumber={shortcutNumber}
|
||||
@@ -1565,7 +1585,7 @@ function FlattenedProjectRow({
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={rowModel.workspace}
|
||||
workspace={workspace}
|
||||
selected={rowModel.selected}
|
||||
chevron={rowModel.chevron}
|
||||
onPress={onPress}
|
||||
@@ -1609,9 +1629,15 @@ function WorkspaceRow({
|
||||
canCopyBranchName: boolean;
|
||||
isCreating?: boolean;
|
||||
}) {
|
||||
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
||||
|
||||
if (!hydratedWorkspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceRowWithMenu
|
||||
workspace={workspace}
|
||||
workspace={hydratedWorkspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import type { ScriptStatusUpdateMessage } from "@server/shared/messages";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||
@@ -19,6 +20,10 @@ export function patchWorkspaceScripts(
|
||||
return workspaces;
|
||||
}
|
||||
|
||||
if (equal(existing.scripts, update.scripts)) {
|
||||
return workspaces;
|
||||
}
|
||||
|
||||
const next = new Map(workspaces);
|
||||
next.set(workspaceKey, {
|
||||
...existing,
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { getIsElectronRuntimeMac } from "@/constants/layout";
|
||||
import { useAggregatedAgents } from "./use-aggregated-agents";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
deriveMacDockBadgeCountFromWorkspaceStatuses,
|
||||
type DesktopBadgeWorkspaceStatus,
|
||||
} from "@/utils/desktop-badge-state";
|
||||
import { useWorkspaceStatusesForBadges } from "@/stores/session-store-hooks";
|
||||
import { deriveMacDockBadgeCountFromWorkspaceStatuses } from "@/utils/desktop-badge-state";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
type FaviconStatus = "none" | "running" | "attention";
|
||||
@@ -99,17 +95,7 @@ async function updateMacDockBadge(count?: number) {
|
||||
|
||||
export function useFaviconStatus() {
|
||||
const { agents } = useAggregatedAgents();
|
||||
const workspaceStatuses = useSessionStore(
|
||||
useShallow((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;
|
||||
}),
|
||||
);
|
||||
const workspaceStatuses = useWorkspaceStatusesForBadges();
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
|
||||
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionForServer } from "./use-session-directory";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { queryClient as singletonQueryClient } from "@/query/query-client";
|
||||
|
||||
function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
|
||||
@@ -39,9 +39,8 @@ export function useProvidersSnapshot(
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const normalizedCwd = cwd?.trim() || undefined;
|
||||
const normalizedCwdKey = normalizeProvidersSnapshotCwdKey(normalizedCwd);
|
||||
const supportsSnapshot = useSessionForServer(
|
||||
serverId,
|
||||
(session) => session?.serverInfo?.features?.providersSnapshot === true,
|
||||
const supportsSnapshot = useSessionStore(
|
||||
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true,
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useSessionStore, type SessionState } from "@/stores/session-store";
|
||||
|
||||
export function useSessionDirectory(): Map<string, SessionState> {
|
||||
const sessions = useSessionStore((state) => state.sessions);
|
||||
|
||||
return useMemo(() => {
|
||||
return new Map<string, SessionState>(Object.entries(sessions));
|
||||
}, [sessions]);
|
||||
}
|
||||
|
||||
type SessionSelector<T> = (session: SessionState | null) => T;
|
||||
type EqualityFn<T> = ((left: T, right: T) => boolean) | undefined;
|
||||
|
||||
export function useSessionForServer(serverId: string | null): SessionState | null;
|
||||
export function useSessionForServer<T>(
|
||||
serverId: string | null,
|
||||
selector: SessionSelector<T>,
|
||||
equalityFn?: EqualityFn<T>,
|
||||
): T;
|
||||
export function useSessionForServer<T>(
|
||||
serverId: string | null,
|
||||
selector?: SessionSelector<T>,
|
||||
equalityFn?: EqualityFn<T>,
|
||||
): SessionState | null | T {
|
||||
const storeSelector = useCallback(
|
||||
(state: ReturnType<typeof useSessionStore.getState>): SessionState | null | T => {
|
||||
const session = serverId ? (state.sessions[serverId] ?? null) : null;
|
||||
return selector ? selector(session) : session;
|
||||
},
|
||||
[selector, serverId],
|
||||
);
|
||||
|
||||
return useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
storeSelector,
|
||||
equalityFn as EqualityFn<SessionState | null | T>,
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceScriptPayload } from "@server/shared/messages";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||
});
|
||||
|
||||
import {
|
||||
appendMissingOrderKeys,
|
||||
applyStoredOrdering,
|
||||
buildSidebarProjectsFromWorkspaces,
|
||||
buildSidebarProjectsFromStructure,
|
||||
} from "./use-sidebar-workspaces-list";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { WorkspaceStructureProject } from "@/stores/session-store-hooks";
|
||||
|
||||
interface OrderedItem {
|
||||
key: string;
|
||||
@@ -15,57 +19,22 @@ function item(key: string): OrderedItem {
|
||||
return { key };
|
||||
}
|
||||
|
||||
function workspace(
|
||||
input: Pick<WorkspaceDescriptor, "id" | "projectId" | "name" | "status"> &
|
||||
Partial<
|
||||
Pick<
|
||||
WorkspaceDescriptor,
|
||||
| "projectDisplayName"
|
||||
| "projectRootPath"
|
||||
| "workspaceDirectory"
|
||||
| "projectKind"
|
||||
| "workspaceKind"
|
||||
| "scripts"
|
||||
>
|
||||
>,
|
||||
): WorkspaceDescriptor {
|
||||
function project(input: {
|
||||
projectKey: string;
|
||||
projectName?: string;
|
||||
projectKind?: WorkspaceStructureProject["projectKind"];
|
||||
iconWorkingDir?: string;
|
||||
workspaceKeys: string[];
|
||||
}): WorkspaceStructureProject {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
projectDisplayName: input.projectDisplayName ?? input.projectId,
|
||||
projectRootPath: input.projectRootPath ?? input.id,
|
||||
workspaceDirectory: input.workspaceDirectory ?? input.projectRootPath ?? input.id,
|
||||
projectKey: input.projectKey,
|
||||
projectName: input.projectName ?? input.projectKey,
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "checkout",
|
||||
name: input.name,
|
||||
status: input.status,
|
||||
diffStat: null,
|
||||
scripts: input.scripts ?? [],
|
||||
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
|
||||
workspaceKeys: input.workspaceKeys,
|
||||
};
|
||||
}
|
||||
|
||||
const runningScript: WorkspaceScriptPayload = {
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web.paseo.localhost",
|
||||
port: 3000,
|
||||
proxyUrl: "http://web.paseo.localhost:6767",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
};
|
||||
|
||||
const stoppedScript: WorkspaceScriptPayload = {
|
||||
scriptName: "api",
|
||||
type: "service",
|
||||
hostname: "api.paseo.localhost",
|
||||
port: 3001,
|
||||
proxyUrl: "http://api.paseo.localhost:6767",
|
||||
lifecycle: "stopped",
|
||||
health: null,
|
||||
exitCode: null,
|
||||
};
|
||||
|
||||
describe("applyStoredOrdering", () => {
|
||||
it("keeps unknown items on the baseline while applying stored order", () => {
|
||||
const result = applyStoredOrdering({
|
||||
@@ -121,187 +90,52 @@ describe("appendMissingOrderKeys", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSidebarProjectsFromWorkspaces", () => {
|
||||
it("uses workspace descriptor name and status directly", () => {
|
||||
const workspaces: WorkspaceDescriptor[] = [
|
||||
workspace({
|
||||
id: "/repo/main",
|
||||
projectId: "project-1",
|
||||
name: "feat/hard-cut",
|
||||
status: "failed",
|
||||
}),
|
||||
];
|
||||
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
describe("buildSidebarProjectsFromStructure", () => {
|
||||
it("creates structural workspace rows from ordered workspace keys", () => {
|
||||
const projects = buildSidebarProjectsFromStructure({
|
||||
serverId: "srv",
|
||||
workspaces,
|
||||
projectOrder: [],
|
||||
workspaceOrderByScope: {},
|
||||
projects: [
|
||||
project({
|
||||
projectKey: "project-1",
|
||||
projectName: "Project 1",
|
||||
iconWorkingDir: "/repo/main",
|
||||
workspaceKeys: ["ws-main"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(projects).toHaveLength(1);
|
||||
expect(projects[0]?.statusBucket).toBe("failed");
|
||||
expect(projects[0]?.workspaces[0]?.name).toBe("feat/hard-cut");
|
||||
expect(projects[0]?.workspaces[0]?.statusBucket).toBe("failed");
|
||||
expect(projects[0]?.projectName).toBe("Project 1");
|
||||
expect(projects[0]?.workspaces[0]).toMatchObject({
|
||||
workspaceKey: "srv:ws-main",
|
||||
serverId: "srv",
|
||||
workspaceId: "ws-main",
|
||||
projectRootPath: "/repo/main",
|
||||
projectKind: "git",
|
||||
});
|
||||
});
|
||||
|
||||
it("threads scripts into workspace rows and derives hasRunningScripts", () => {
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
it("preserves the structure hook project order", () => {
|
||||
const projects = buildSidebarProjectsFromStructure({
|
||||
serverId: "srv",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "/repo/main",
|
||||
projectId: "project-1",
|
||||
name: "main",
|
||||
status: "running",
|
||||
scripts: [runningScript, stoppedScript],
|
||||
}),
|
||||
projects: [
|
||||
project({ projectKey: "project-b", workspaceKeys: ["ws-b"] }),
|
||||
project({ projectKey: "project-a", workspaceKeys: ["ws-a"] }),
|
||||
],
|
||||
projectOrder: [],
|
||||
workspaceOrderByScope: {},
|
||||
});
|
||||
|
||||
expect(projects[0]?.workspaces[0]?.scripts).toEqual([runningScript, stoppedScript]);
|
||||
expect(projects[0]?.workspaces[0]?.hasRunningScripts).toBe(true);
|
||||
expect(projects.map((project) => project.projectKey)).toEqual(["project-b", "project-a"]);
|
||||
});
|
||||
|
||||
it("preserves stored project order even when input order differs", () => {
|
||||
const initialWorkspaces: WorkspaceDescriptor[] = [
|
||||
workspace({
|
||||
id: "/repo/b",
|
||||
projectId: "project-b",
|
||||
name: "feat/b",
|
||||
status: "running",
|
||||
}),
|
||||
workspace({
|
||||
id: "/repo/a",
|
||||
projectId: "project-a",
|
||||
name: "feat/a",
|
||||
status: "running",
|
||||
}),
|
||||
];
|
||||
|
||||
const seededOrder = appendMissingOrderKeys({
|
||||
currentOrder: [],
|
||||
visibleKeys: buildSidebarProjectsFromWorkspaces({
|
||||
serverId: "srv",
|
||||
workspaces: initialWorkspaces,
|
||||
projectOrder: [],
|
||||
workspaceOrderByScope: {},
|
||||
}).map((project) => project.projectKey),
|
||||
});
|
||||
|
||||
const updatedProjects = buildSidebarProjectsFromWorkspaces({
|
||||
it("preserves the structure hook workspace order", () => {
|
||||
const projects = buildSidebarProjectsFromStructure({
|
||||
serverId: "srv",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "/repo/a",
|
||||
projectId: "project-a",
|
||||
name: "feat/a",
|
||||
status: "running",
|
||||
}),
|
||||
workspace({
|
||||
id: "/repo/b",
|
||||
projectId: "project-b",
|
||||
name: "feat/b",
|
||||
status: "running",
|
||||
}),
|
||||
],
|
||||
projectOrder: seededOrder,
|
||||
workspaceOrderByScope: {},
|
||||
});
|
||||
|
||||
expect(updatedProjects.map((project) => project.projectKey)).toEqual([
|
||||
"project-a",
|
||||
"project-b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends new projects after the stored project order", () => {
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: "srv",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "/repo/c",
|
||||
projectId: "project-c",
|
||||
name: "feat/c",
|
||||
status: "running",
|
||||
}),
|
||||
workspace({
|
||||
id: "/repo/b",
|
||||
projectId: "project-b",
|
||||
name: "feat/b",
|
||||
status: "running",
|
||||
}),
|
||||
workspace({
|
||||
id: "/repo/a",
|
||||
projectId: "project-a",
|
||||
name: "feat/a",
|
||||
status: "running",
|
||||
}),
|
||||
],
|
||||
projectOrder: ["project-b", "project-a", "project-c"],
|
||||
workspaceOrderByScope: {},
|
||||
});
|
||||
|
||||
expect(projects.map((project) => project.projectKey)).toEqual([
|
||||
"project-b",
|
||||
"project-a",
|
||||
"project-c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves stored workspace order when workspace activity changes", () => {
|
||||
const initialProjects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: "srv",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "/repo/main",
|
||||
projectId: "project-1",
|
||||
name: "main",
|
||||
status: "running",
|
||||
}),
|
||||
workspace({
|
||||
id: "/repo/feature",
|
||||
projectId: "project-1",
|
||||
name: "feature",
|
||||
status: "running",
|
||||
}),
|
||||
],
|
||||
projectOrder: ["project-1"],
|
||||
workspaceOrderByScope: {},
|
||||
});
|
||||
|
||||
const seededWorkspaceOrder = appendMissingOrderKeys({
|
||||
currentOrder: [],
|
||||
visibleKeys: initialProjects[0]?.workspaces.map((workspace) => workspace.workspaceKey) ?? [],
|
||||
});
|
||||
|
||||
const projects = buildSidebarProjectsFromWorkspaces({
|
||||
serverId: "srv",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "/repo/main",
|
||||
projectId: "project-1",
|
||||
name: "main",
|
||||
status: "running",
|
||||
}),
|
||||
workspace({
|
||||
id: "/repo/feature",
|
||||
projectId: "project-1",
|
||||
name: "feature",
|
||||
status: "running",
|
||||
}),
|
||||
],
|
||||
projectOrder: ["project-1"],
|
||||
workspaceOrderByScope: {
|
||||
"srv::project-1": seededWorkspaceOrder,
|
||||
},
|
||||
projects: [project({ projectKey: "project-1", workspaceKeys: ["feature", "main"] })],
|
||||
});
|
||||
|
||||
expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([
|
||||
"/repo/feature",
|
||||
"/repo/main",
|
||||
"feature",
|
||||
"main",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { FileText } from "lucide-react-native";
|
||||
import invariant from "tiny-invariant";
|
||||
import { FilePane } from "@/components/file-pane";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { resolveWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||
|
||||
function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
|
||||
const fileName = target.path.split("/").filter(Boolean).pop() ?? target.path;
|
||||
@@ -21,25 +19,19 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
|
||||
|
||||
function FilePanel() {
|
||||
const { serverId, workspaceId, target } = usePaneContext();
|
||||
const workspace = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.workspaces.get(workspaceId) ?? null,
|
||||
);
|
||||
const authority = useMemo(() => resolveWorkspaceExecutionAuthority({ workspace }), [workspace]);
|
||||
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId);
|
||||
const workspaceDirectory = workspaceAuthority?.ok
|
||||
? workspaceAuthority.authority.workspaceDirectory
|
||||
: null;
|
||||
invariant(target.kind === "file", "FilePanel requires file target");
|
||||
if (!authority) {
|
||||
if (!workspaceDirectory) {
|
||||
return (
|
||||
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 16 }}>
|
||||
<Text>Workspace execution directory not found.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FilePane
|
||||
serverId={serverId}
|
||||
workspaceRoot={authority.workspaceDirectory}
|
||||
filePath={target.path}
|
||||
/>
|
||||
);
|
||||
return <FilePane serverId={serverId} workspaceRoot={workspaceDirectory} filePath={target.path} />;
|
||||
}
|
||||
|
||||
export const filePanelRegistration: PanelRegistration<"file"> = {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { TerminalPane } from "@/components/terminal-pane";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
@@ -25,11 +25,7 @@ function useTerminalPanelDescriptor(
|
||||
context: { serverId: string; workspaceId: string },
|
||||
): PanelDescriptor {
|
||||
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
|
||||
const workspaces = useSessionStore((state) => state.sessions[context.serverId]?.workspaces);
|
||||
const workspaceAuthority = getWorkspaceExecutionAuthority({
|
||||
workspaces,
|
||||
workspaceId: context.workspaceId,
|
||||
});
|
||||
const workspaceAuthority = useWorkspaceExecutionAuthority(context.serverId, context.workspaceId)!;
|
||||
const workspaceDirectory = workspaceAuthority.ok
|
||||
? workspaceAuthority.authority.workspaceDirectory
|
||||
: null;
|
||||
@@ -63,8 +59,7 @@ function useTerminalPanelDescriptor(
|
||||
function TerminalPanel() {
|
||||
const isFocused = useIsFocused();
|
||||
const { serverId, workspaceId, target, isPaneFocused } = usePaneContext();
|
||||
const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
|
||||
const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId });
|
||||
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
|
||||
const workspaceDirectory = workspaceAuthority.ok
|
||||
? workspaceAuthority.authority.workspaceDirectory
|
||||
: null;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
HostRuntimeController,
|
||||
HostRuntimeStore,
|
||||
type HostRuntimeControllerDeps,
|
||||
type HostRuntimeSnapshot,
|
||||
} from "./host-runtime";
|
||||
|
||||
class FakeDaemonClient {
|
||||
@@ -264,6 +265,27 @@ function clearProbeBackoff(controller: HostRuntimeController): void {
|
||||
).connectionLastProbedAt.clear();
|
||||
}
|
||||
|
||||
type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>;
|
||||
|
||||
function updateControllerSnapshot(
|
||||
controller: HostRuntimeController,
|
||||
patch: HostRuntimeSnapshotPatch,
|
||||
): void {
|
||||
(
|
||||
controller as unknown as {
|
||||
updateSnapshot: (patch: HostRuntimeSnapshotPatch) => void;
|
||||
}
|
||||
).updateSnapshot(patch);
|
||||
}
|
||||
|
||||
function makeProbeMap(
|
||||
entries: Array<
|
||||
[string, HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never]
|
||||
>,
|
||||
): HostRuntimeSnapshot["probeByConnectionId"] {
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
describe("HostRuntimeController", () => {
|
||||
it("keeps known hosts in connecting when a created client reports idle during connect", async () => {
|
||||
const host = makeHost({
|
||||
@@ -923,6 +945,134 @@ describe("HostRuntimeController", () => {
|
||||
expect(controller.getSnapshot().clientGeneration).toBe(generationBeforeProbes);
|
||||
expect(createdClients).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not notify or replace the snapshot for equal probe maps", () => {
|
||||
const controller = new HostRuntimeController({ host: makeHost() });
|
||||
const firstProbeMap = makeProbeMap([
|
||||
["direct:lan:6767", { status: "available", latencyMs: 12 }],
|
||||
]);
|
||||
const equalProbeMap = makeProbeMap([
|
||||
["direct:lan:6767", { status: "available", latencyMs: 12 }],
|
||||
]);
|
||||
|
||||
updateControllerSnapshot(controller, { probeByConnectionId: firstProbeMap });
|
||||
const snapshotAfterFirstProbe = controller.getSnapshot();
|
||||
let notifyCount = 0;
|
||||
const unsubscribe = controller.subscribe(() => {
|
||||
notifyCount += 1;
|
||||
});
|
||||
|
||||
updateControllerSnapshot(controller, { probeByConnectionId: equalProbeMap });
|
||||
|
||||
expect(notifyCount).toBe(0);
|
||||
expect(controller.getSnapshot()).toBe(snapshotAfterFirstProbe);
|
||||
expect(controller.getSnapshot().probeByConnectionId).toBe(firstProbeMap);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("does not notify or replace the snapshot when connection status is already equal", () => {
|
||||
const controller = new HostRuntimeController({ host: makeHost() });
|
||||
|
||||
updateControllerSnapshot(controller, { connectionStatus: "online" });
|
||||
const snapshotAfterOnline = controller.getSnapshot();
|
||||
let notifyCount = 0;
|
||||
const unsubscribe = controller.subscribe(() => {
|
||||
notifyCount += 1;
|
||||
});
|
||||
|
||||
updateControllerSnapshot(controller, { connectionStatus: "online" });
|
||||
|
||||
expect(notifyCount).toBe(0);
|
||||
expect(controller.getSnapshot()).toBe(snapshotAfterOnline);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("does not notify or replace the snapshot when every patched field is equal", () => {
|
||||
const controller = new HostRuntimeController({ host: makeHost() });
|
||||
const firstProbeMap = makeProbeMap([
|
||||
["direct:lan:6767", { status: "available", latencyMs: 12 }],
|
||||
]);
|
||||
const equalProbeMap = makeProbeMap([
|
||||
["direct:lan:6767", { status: "available", latencyMs: 12 }],
|
||||
]);
|
||||
|
||||
updateControllerSnapshot(controller, {
|
||||
connectionStatus: "online",
|
||||
probeByConnectionId: firstProbeMap,
|
||||
});
|
||||
const snapshotAfterSetup = controller.getSnapshot();
|
||||
let notifyCount = 0;
|
||||
const unsubscribe = controller.subscribe(() => {
|
||||
notifyCount += 1;
|
||||
});
|
||||
|
||||
updateControllerSnapshot(controller, {
|
||||
connectionStatus: "online",
|
||||
probeByConnectionId: equalProbeMap,
|
||||
});
|
||||
|
||||
expect(notifyCount).toBe(0);
|
||||
expect(controller.getSnapshot()).toBe(snapshotAfterSetup);
|
||||
expect(controller.getSnapshot().probeByConnectionId).toBe(firstProbeMap);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("notifies once for a changed field while preserving equal field identity", () => {
|
||||
const controller = new HostRuntimeController({ host: makeHost() });
|
||||
const firstProbeMap = makeProbeMap([
|
||||
["direct:lan:6767", { status: "available", latencyMs: 12 }],
|
||||
]);
|
||||
const equalProbeMap = makeProbeMap([
|
||||
["direct:lan:6767", { status: "available", latencyMs: 12 }],
|
||||
]);
|
||||
|
||||
updateControllerSnapshot(controller, {
|
||||
connectionStatus: "online",
|
||||
probeByConnectionId: firstProbeMap,
|
||||
});
|
||||
const snapshotBeforeChange = controller.getSnapshot();
|
||||
let notifyCount = 0;
|
||||
const unsubscribe = controller.subscribe(() => {
|
||||
notifyCount += 1;
|
||||
});
|
||||
|
||||
updateControllerSnapshot(controller, {
|
||||
connectionStatus: "offline",
|
||||
probeByConnectionId: equalProbeMap,
|
||||
});
|
||||
|
||||
expect(notifyCount).toBe(1);
|
||||
expect(controller.getSnapshot()).not.toBe(snapshotBeforeChange);
|
||||
expect(controller.getSnapshot().connectionStatus).toBe("offline");
|
||||
expect(controller.getSnapshot().probeByConnectionId).toBe(firstProbeMap);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("notifies once and replaces the probe map when probe contents change", () => {
|
||||
const controller = new HostRuntimeController({ host: makeHost() });
|
||||
const firstProbeMap = makeProbeMap([
|
||||
["direct:lan:6767", { status: "available", latencyMs: 12 }],
|
||||
]);
|
||||
const changedProbeMap = makeProbeMap([
|
||||
["direct:lan:6767", { status: "available", latencyMs: 12 }],
|
||||
["relay:relay.paseo.sh:443", { status: "unavailable", latencyMs: null }],
|
||||
]);
|
||||
|
||||
updateControllerSnapshot(controller, { probeByConnectionId: firstProbeMap });
|
||||
const snapshotBeforeChange = controller.getSnapshot();
|
||||
let notifyCount = 0;
|
||||
const unsubscribe = controller.subscribe(() => {
|
||||
notifyCount += 1;
|
||||
});
|
||||
|
||||
updateControllerSnapshot(controller, { probeByConnectionId: changedProbeMap });
|
||||
|
||||
expect(notifyCount).toBe(1);
|
||||
expect(controller.getSnapshot()).not.toBe(snapshotBeforeChange);
|
||||
expect(controller.getSnapshot().probeByConnectionId).toBe(changedProbeMap);
|
||||
expect(controller.getSnapshot().probeByConnectionId).not.toBe(firstProbeMap);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostRuntimeStore", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useSyncExternalStore, useCallback, useMemo } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import equal from "fast-deep-equal/es6";
|
||||
import {
|
||||
DaemonClient,
|
||||
type ConnectionState,
|
||||
@@ -67,6 +68,16 @@ export type HostRuntimeSnapshot = {
|
||||
clientGeneration: number;
|
||||
};
|
||||
|
||||
type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>;
|
||||
|
||||
function setSnapshotPatchField<Key extends keyof HostRuntimeSnapshotPatch>(
|
||||
patch: HostRuntimeSnapshotPatch,
|
||||
key: Key,
|
||||
value: HostRuntimeSnapshot[Key],
|
||||
): void {
|
||||
patch[key] = value;
|
||||
}
|
||||
|
||||
export function isHostRuntimeConnected(snapshot: HostRuntimeSnapshot | null): boolean {
|
||||
return snapshot?.connectionStatus === "online";
|
||||
}
|
||||
@@ -831,15 +842,25 @@ export class HostRuntimeController {
|
||||
});
|
||||
}
|
||||
|
||||
private updateSnapshot(
|
||||
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
|
||||
): void {
|
||||
const next: HostRuntimeSnapshot = {
|
||||
private updateSnapshot(patch: HostRuntimeSnapshotPatch): void {
|
||||
const preservedPatch: HostRuntimeSnapshotPatch = { ...patch };
|
||||
let hasChanged = this.host.serverId !== this.snapshot.serverId;
|
||||
for (const key of Object.keys(patch) as Array<keyof HostRuntimeSnapshotPatch>) {
|
||||
const incomingValue = patch[key];
|
||||
if (equal(this.snapshot[key], incomingValue)) {
|
||||
setSnapshotPatchField(preservedPatch, key, this.snapshot[key]);
|
||||
continue;
|
||||
}
|
||||
hasChanged = true;
|
||||
}
|
||||
if (!hasChanged) {
|
||||
return;
|
||||
}
|
||||
this.snapshot = {
|
||||
...this.snapshot,
|
||||
...patch,
|
||||
...preservedPatch,
|
||||
serverId: this.host.serverId,
|
||||
};
|
||||
this.snapshot = next;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
@@ -1021,7 +1042,7 @@ export class HostRuntimeController {
|
||||
state,
|
||||
lastError: client.lastError,
|
||||
});
|
||||
const patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">> = {
|
||||
const patch: HostRuntimeSnapshotPatch = {
|
||||
...toSnapshotConnectionPatch(this.connectionMachineState),
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHasWorkspaces } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
useIsCompactFormFactor,
|
||||
HEADER_INNER_HEIGHT,
|
||||
@@ -22,7 +23,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
const openAgentList = usePanelStore((s) => s.openAgentList);
|
||||
const openProjectPicker = useOpenProjectPicker(serverId);
|
||||
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
|
||||
const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0);
|
||||
const hasProjects = useHasWorkspaces(serverId);
|
||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||
const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false);
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { type Agent, useSessionStore } from "@/stores/session-store";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
||||
@@ -50,9 +50,8 @@ export function WorkspaceDraftAgentTab({
|
||||
}: WorkspaceDraftAgentTabProps) {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
|
||||
const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId });
|
||||
const workspaceExecutionAuthority = workspaceAuthority.ok ? workspaceAuthority.authority : null;
|
||||
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId);
|
||||
const workspaceExecutionAuthority = workspaceAuthority?.ok ? workspaceAuthority.authority : null;
|
||||
const workspaceDirectory = workspaceExecutionAuthority?.workspaceDirectory ?? null;
|
||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||
const draftStoreKey = useMemo(
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
|
||||
import {
|
||||
checkoutStatusQueryKey,
|
||||
@@ -79,7 +80,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
resolveWorkspaceExecutionAuthority,
|
||||
getWorkspaceExecutionAuthority,
|
||||
resolveWorkspaceRouteId,
|
||||
} from "@/utils/workspace-execution";
|
||||
import {
|
||||
@@ -617,9 +618,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
resolveWorkspaceRouteId({
|
||||
routeWorkspaceId: workspaceId,
|
||||
}) ?? "";
|
||||
const sessionWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.workspaces,
|
||||
);
|
||||
const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId);
|
||||
|
||||
const workspaceTerminalScopeKey =
|
||||
normalizedServerId && normalizedWorkspaceId
|
||||
@@ -632,17 +631,18 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(normalizedServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||
const workspaceDescriptor = sessionWorkspaces?.get(normalizedWorkspaceId) ?? null;
|
||||
const workspaceAuthority = useMemo(
|
||||
() =>
|
||||
resolveWorkspaceExecutionAuthority({
|
||||
workspaces: sessionWorkspaces,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
getWorkspaceExecutionAuthority({
|
||||
workspace: workspaceDescriptor,
|
||||
}),
|
||||
[normalizedWorkspaceId, sessionWorkspaces],
|
||||
[workspaceDescriptor],
|
||||
);
|
||||
const workspaceExecutionAuthority = workspaceAuthority.ok ? workspaceAuthority.authority : null;
|
||||
const workspaceDirectory = workspaceExecutionAuthority?.workspaceDirectory ?? null;
|
||||
const isMissingWorkspaceExecutionAuthority = Boolean(
|
||||
workspaceDescriptor && !workspaceExecutionAuthority,
|
||||
);
|
||||
const workspaceDirectory = workspaceAuthority?.workspaceDirectory ?? null;
|
||||
const isMissingWorkspaceExecutionAuthority = Boolean(workspaceDescriptor && !workspaceAuthority);
|
||||
|
||||
// Warm the server-side provider snapshot for this workspace cwd so the model
|
||||
// picker is ready when opened. Consumers share the same query cache key.
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||
});
|
||||
|
||||
import {
|
||||
resolveWorkspaceHeader,
|
||||
shouldRenderMissingWorkspaceDescriptor,
|
||||
} from "./workspace-header-source";
|
||||
import { buildSidebarProjectsFromWorkspaces } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { createSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
describe("workspace source of truth consumption", () => {
|
||||
@@ -23,17 +28,15 @@ describe("workspace source of truth consumption", () => {
|
||||
};
|
||||
|
||||
const header = resolveWorkspaceHeader({ workspace });
|
||||
const sidebarProjects = buildSidebarProjectsFromWorkspaces({
|
||||
const sidebarWorkspace = createSidebarWorkspaceEntry({
|
||||
serverId: "srv",
|
||||
workspaces: [workspace],
|
||||
projectOrder: [],
|
||||
workspaceOrderByScope: {},
|
||||
workspace,
|
||||
});
|
||||
|
||||
expect(header.title).toBe("feat/workspace-sot");
|
||||
expect(header.subtitle).toBe("getpaseo/paseo");
|
||||
expect(sidebarProjects[0]?.workspaces[0]?.name).toBe(header.title);
|
||||
expect(sidebarProjects[0]?.workspaces[0]?.statusBucket).toBe("running");
|
||||
expect(sidebarWorkspace.name).toBe(header.title);
|
||||
expect(sidebarWorkspace.statusBucket).toBe("running");
|
||||
});
|
||||
|
||||
it("renders explicit missing state only after workspace hydration", () => {
|
||||
|
||||
364
packages/app/src/stores/session-store-hooks.test.ts
Normal file
364
packages/app/src/stores/session-store-hooks.test.ts
Normal file
@@ -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"]);
|
||||
});
|
||||
});
|
||||
384
packages/app/src/stores/session-store-hooks.ts
Normal file
384
packages/app/src/stores/session-store-hooks.ts
Normal file
@@ -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) {
|
||||
|
||||
@@ -31,9 +31,6 @@ function project(overrides: Partial<SidebarProjectEntry> = {}): SidebarProjectEn
|
||||
projectName: "paseo",
|
||||
projectKind: "git",
|
||||
iconWorkingDir: "/repo",
|
||||
statusBucket: "done",
|
||||
activeCount: 0,
|
||||
totalWorkspaces: 1,
|
||||
workspaces: [workspace()],
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -33,9 +33,6 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
|
||||
projectName: projectKey,
|
||||
projectKind: "git",
|
||||
iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "",
|
||||
statusBucket: "done",
|
||||
activeCount: 0,
|
||||
totalWorkspaces: workspaces.length,
|
||||
workspaces,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
type WorkspaceLike = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectDisplayName?: string;
|
||||
name: string;
|
||||
status: string;
|
||||
workspaceKind: string;
|
||||
};
|
||||
|
||||
type SidebarWorkspaceLike = {
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
statusBucket: string;
|
||||
workspaceKind: string;
|
||||
};
|
||||
|
||||
type SidebarProjectLike = {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
statusBucket: string;
|
||||
activeCount: number;
|
||||
totalWorkspaces: number;
|
||||
workspaces: SidebarWorkspaceLike[];
|
||||
};
|
||||
|
||||
function countByValue(values: Iterable<string>): Record<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const value of values) {
|
||||
counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||
}
|
||||
return Object.fromEntries(counts);
|
||||
}
|
||||
|
||||
export function summarizeWorkspaceCollection(
|
||||
workspaces: Iterable<WorkspaceLike> | null | undefined,
|
||||
): {
|
||||
count: number;
|
||||
projectIds: string[];
|
||||
statusCounts: Record<string, number>;
|
||||
workspaces: Array<{
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string | null;
|
||||
name: string;
|
||||
status: string;
|
||||
workspaceKind: string;
|
||||
}>;
|
||||
} {
|
||||
const entries = Array.from(workspaces ?? [], (workspace) => ({
|
||||
id: workspace.id,
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName?.trim() || null,
|
||||
name: workspace.name,
|
||||
status: workspace.status,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
}));
|
||||
|
||||
return {
|
||||
count: entries.length,
|
||||
projectIds: [...new Set(entries.map((workspace) => workspace.projectId))],
|
||||
statusCounts: countByValue(entries.map((workspace) => workspace.status)),
|
||||
workspaces: entries,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeSidebarProjects(
|
||||
projects: Iterable<SidebarProjectLike> | null | undefined,
|
||||
): {
|
||||
count: number;
|
||||
projectKeys: string[];
|
||||
projects: Array<{
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
statusBucket: string;
|
||||
activeCount: number;
|
||||
totalWorkspaces: number;
|
||||
workspaces: Array<{
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
statusBucket: string;
|
||||
workspaceKind: string;
|
||||
}>;
|
||||
}>;
|
||||
} {
|
||||
const entries = Array.from(projects ?? [], (project) => ({
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
statusBucket: project.statusBucket,
|
||||
activeCount: project.activeCount,
|
||||
totalWorkspaces: project.totalWorkspaces,
|
||||
workspaces: project.workspaces.map((workspace) => ({
|
||||
workspaceId: workspace.workspaceId,
|
||||
name: workspace.name,
|
||||
statusBucket: workspace.statusBucket,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
})),
|
||||
}));
|
||||
|
||||
return {
|
||||
count: entries.length,
|
||||
projectKeys: entries.map((project) => project.projectKey),
|
||||
projects: entries,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user