diff --git a/package-lock.json b/package-lock.json index c230c4747..d5ae5a373 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/app/package.json b/packages/app/package.json index c82d4b9ad..d819c6767 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 06e7f53ed..343466715 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -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; } diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx index a4bd68c61..7bc2e7239 100644 --- a/packages/app/src/components/project-picker-modal.tsx +++ b/packages/app/src/components/project-picker-modal.tsx @@ -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(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 () => { diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx new file mode 100644 index 000000000..e54c881a7 --- /dev/null +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -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; + rows: Record; +}; + +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, 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) => ( +
+ + {project.workspaces.map((workspace) => ( + + ))} +
+ ))} + + ); +} + +function getHostController(): HostRuntimeController { + const controllers = ( + getHostRuntimeStore() as unknown as { + controllers: Map; + } + ).controllers; + const controller = controllers.get(SERVER_ID); + if (!controller) { + throw new Error("Host runtime controller was not initialized"); + } + return controller; +} + +function updateControllerSnapshot( + patch: Partial>, +): void { + ( + getHostController() as unknown as { + updateSnapshot: ( + patch: Partial>, + ) => 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(); + }); + 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: {} }); + }); +}); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index da2f46af0..de988712b 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -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 ( { - 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(getSystemColorScheme); const lastDockBadgeCountRef = useRef(undefined); diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index 8edfbd8c1..2dccfe618 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -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( diff --git a/packages/app/src/hooks/use-session-directory.ts b/packages/app/src/hooks/use-session-directory.ts deleted file mode 100644 index a718c0cbe..000000000 --- a/packages/app/src/hooks/use-session-directory.ts +++ /dev/null @@ -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 { - const sessions = useSessionStore((state) => state.sessions); - - return useMemo(() => { - return new Map(Object.entries(sessions)); - }, [sessions]); -} - -type SessionSelector = (session: SessionState | null) => T; -type EqualityFn = ((left: T, right: T) => boolean) | undefined; - -export function useSessionForServer(serverId: string | null): SessionState | null; -export function useSessionForServer( - serverId: string | null, - selector: SessionSelector, - equalityFn?: EqualityFn, -): T; -export function useSessionForServer( - serverId: string | null, - selector?: SessionSelector, - equalityFn?: EqualityFn, -): SessionState | null | T { - const storeSelector = useCallback( - (state: ReturnType): 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, - ); -} diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index 40ae92b41..f49d09fd2 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -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 & - 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", ]); }); }); diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index d8e847e34..356341062 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -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 = {}; 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 = { - 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; - projectOrder: string[]; - workspaceOrderByScope: Record; + 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(); - - 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(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") { diff --git a/packages/app/src/panels/file-panel.tsx b/packages/app/src/panels/file-panel.tsx index 3d60a6c57..d830ab259 100644 --- a/packages/app/src/panels/file-panel.tsx +++ b/packages/app/src/panels/file-panel.tsx @@ -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 ( Workspace execution directory not found. ); } - return ( - - ); + return ; } export const filePanelRegistration: PanelRegistration<"file"> = { diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index 020d46479..b15c53b05 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -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; diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 610d6f4c9..dab40f358 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -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>; + +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 ? 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", () => { diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index cd4a37caa..562bc278e 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -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>; + +function setSnapshotPatchField( + 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>, - ): 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) { + 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> = { + const patch: HostRuntimeSnapshotPatch = { ...toSnapshotConnectionPatch(this.connectionMachineState), }; diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index a09209d7f..a519b74cf 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -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); diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 23afbe233..71e254cde 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -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( diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 3b8ab6e4e..07c04135e 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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. diff --git a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts index 1d747d84f..8c6101b40 100644 --- a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts +++ b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts @@ -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", () => { diff --git a/packages/app/src/stores/session-store-hooks.test.ts b/packages/app/src/stores/session-store-hooks.test.ts new file mode 100644 index 000000000..4f5229a2b --- /dev/null +++ b/packages/app/src/stores/session-store-hooks.test.ts @@ -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 & Pick, +): 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"]); + }); +}); diff --git a/packages/app/src/stores/session-store-hooks.ts b/packages/app/src/stores/session-store-hooks.ts new file mode 100644 index 000000000..fb6a2f021 --- /dev/null +++ b/packages/app/src/stores/session-store-hooks.ts @@ -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; + +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(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(); + for (const item of input.items) { + itemByKey.set(input.getKey(item), item); + } + + const prunedOrder: string[] = []; + const seen = new Set(); + 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( + 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, + serverId: string | null, +): Record { + if (!serverId) { + return {}; + } + const prefix = `${serverId.trim()}::`; + const relevantOrderByScope: Record = {}; + 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, + ); +} diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index a286cb0ca..9b6edba4f 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -9,6 +9,7 @@ import { useSessionStore, type WorkspaceDescriptor, } from "./session-store"; +import { patchWorkspaceScripts } from "../contexts/session-workspace-scripts"; function createWorkspace( input: Partial & Pick, @@ -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", () => { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index f259adb30..5c8f71e87 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -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, + incoming: Map, +): Map { + if (existing === incoming) { + return existing; + } + + const next = new Map(); + 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()( } 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()( 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) { diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index e3640f525..492d1b52e 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -31,9 +31,6 @@ function project(overrides: Partial = {}): SidebarProjectEn projectName: "paseo", projectKind: "git", iconWorkingDir: "/repo", - statusBucket: "done", - activeCount: 0, - totalWorkspaces: 1, workspaces: [workspace()], ...overrides, }; diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index ef7b31701..3fe0d20eb 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -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, }; } diff --git a/packages/app/src/utils/workspace-fetch-debug.ts b/packages/app/src/utils/workspace-fetch-debug.ts deleted file mode 100644 index 765ceb7d4..000000000 --- a/packages/app/src/utils/workspace-fetch-debug.ts +++ /dev/null @@ -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): Record { - const counts = new Map(); - for (const value of values) { - counts.set(value, (counts.get(value) ?? 0) + 1); - } - return Object.fromEntries(counts); -} - -export function summarizeWorkspaceCollection( - workspaces: Iterable | null | undefined, -): { - count: number; - projectIds: string[]; - statusCounts: Record; - 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 | 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, - }; -}