From 8991d34f78fa0c12d59d91c5e7506d36c741710b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 19 Apr 2026 16:49:09 +0800 Subject: [PATCH 1/2] fix(app): allow collapsing the parent folder of a selected file (#500) The file explorer had a useEffect that re-enforced expansion of every ancestor of `selectedEntryPath` whenever `expandedPaths` changed. As a result, the moment a user collapsed the parent folder of a file they had just opened, the effect re-added that folder to the expanded set and the folder appeared stuck open. Selection and expansion are orthogonal: clicking a file should not dictate that its parent folder stays open forever. There is also no current caller that sets `selectedEntryPath` from outside the tree, so the reveal-on-external-selection use case the effect was designed for is unused. Delete the effect and its two now-dead helpers. If a future "Reveal in Explorer" action is introduced, it can expand ancestors at the call site. Adds an E2E regression test that reproduces the original scenario: expand a folder, open an image, collapse the parent folder, assert children are hidden. Also asserts an unrelated sibling folder still expands after the image is open, guarding against any future regression that makes the bug global. --- .../app/e2e/file-explorer-collapse.spec.ts | 61 +++++++++++++++++++ packages/app/e2e/helpers/file-explorer.ts | 41 +++++++++++++ .../app/src/components/file-explorer-pane.tsx | 58 ------------------ 3 files changed, 102 insertions(+), 58 deletions(-) create mode 100644 packages/app/e2e/file-explorer-collapse.spec.ts create mode 100644 packages/app/e2e/helpers/file-explorer.ts diff --git a/packages/app/e2e/file-explorer-collapse.spec.ts b/packages/app/e2e/file-explorer-collapse.spec.ts new file mode 100644 index 000000000..97fcd9e30 --- /dev/null +++ b/packages/app/e2e/file-explorer-collapse.spec.ts @@ -0,0 +1,61 @@ +import { test } from "./fixtures"; +import { + collapseFolder, + expandFolder, + expectExplorerEntryHidden, + expectExplorerEntryVisible, + expectFileTabOpen, + openFileExplorer, + openFileFromExplorer, +} from "./helpers/file-explorer"; +import { gotoWorkspace } from "./helpers/launcher"; +import { createTempGitRepo } from "./helpers/workspace"; +import { + connectWorkspaceSetupClient, + type WorkspaceSetupDaemonClient, +} from "./helpers/workspace-setup"; + +let tempRepo: { path: string; cleanup: () => Promise }; +let workspaceId: string; +let seedClient: WorkspaceSetupDaemonClient; + +test.beforeAll(async () => { + tempRepo = await createTempGitRepo("file-explorer-collapse-", { + files: [ + { path: "assets/logo.png", content: "image bytes for explorer e2e\n" }, + { path: "docs/guide.md", content: "# Guide\n" }, + ], + }); + seedClient = await connectWorkspaceSetupClient(); + const result = await seedClient.openProject(tempRepo.path); + if (!result.workspace) { + throw new Error(result.error ?? "Failed to seed workspace"); + } + workspaceId = String(result.workspace.id); +}); + +test.afterAll(async () => { + await seedClient?.close(); + await tempRepo?.cleanup(); +}); + +test.describe("File explorer collapse", () => { + test("collapses an opened image file parent folder and still expands other folders", async ({ + page, + }) => { + await gotoWorkspace(page, workspaceId); + await openFileExplorer(page); + + await expandFolder(page, "assets"); + await expectExplorerEntryVisible(page, "logo.png"); + + await openFileFromExplorer(page, "logo.png"); + await expectFileTabOpen(page, "assets/logo.png"); + + await collapseFolder(page, "assets"); + await expectExplorerEntryHidden(page, "logo.png"); + + await expandFolder(page, "docs"); + await expectExplorerEntryVisible(page, "guide.md"); + }); +}); diff --git a/packages/app/e2e/helpers/file-explorer.ts b/packages/app/e2e/helpers/file-explorer.ts new file mode 100644 index 000000000..0d63f34ba --- /dev/null +++ b/packages/app/e2e/helpers/file-explorer.ts @@ -0,0 +1,41 @@ +import { expect, type Page } from "@playwright/test"; + +function fileExplorerTree(page: Page) { + return page.getByTestId("file-explorer-tree-scroll"); +} + +function fileExplorerEntry(page: Page, name: string) { + return fileExplorerTree(page).getByText(name, { exact: true }).first(); +} + +export async function openFileExplorer(page: Page): Promise { + await page.getByRole("button", { name: "Open explorer" }).first().click(); + await page.getByTestId("explorer-tab-files").click(); + await expect(fileExplorerTree(page)).toBeVisible({ timeout: 30_000 }); +} + +export async function expandFolder(page: Page, folderName: string): Promise { + await fileExplorerEntry(page, folderName).click(); +} + +export async function collapseFolder(page: Page, folderName: string): Promise { + await fileExplorerEntry(page, folderName).click(); +} + +export async function openFileFromExplorer(page: Page, fileName: string): Promise { + await fileExplorerEntry(page, fileName).click(); +} + +export async function expectExplorerEntryVisible(page: Page, name: string): Promise { + await expect(fileExplorerEntry(page, name)).toBeVisible({ timeout: 30_000 }); +} + +export async function expectExplorerEntryHidden(page: Page, name: string): Promise { + await expect(fileExplorerEntry(page, name)).toBeHidden({ timeout: 30_000 }); +} + +export async function expectFileTabOpen(page: Page, filePath: string): Promise { + await expect(page.getByTestId(`workspace-tab-file_${filePath}`).first()).toBeVisible({ + timeout: 30_000, + }); +} diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 9575eb891..7b09137ca 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -195,35 +195,6 @@ export function FileExplorerPane({ }); }, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]); - // Expand ancestor directories when a file is selected (e.g., from an inline path click) - useEffect(() => { - if (!selectedEntryPath || !workspaceStateKey) { - return; - } - const parentDir = getParentDirectory(selectedEntryPath); - const ancestors = getAncestorDirectories(parentDir); - const newPaths = ancestors.filter((path) => !expandedPaths.has(path)); - if (newPaths.length === 0) { - return; - } - setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), ...newPaths]); - newPaths.forEach((path) => { - if (!directories.has(path)) { - void requestDirectoryListing(path, { - recordHistory: false, - setCurrentPath: false, - }); - } - }); - }, [ - directories, - workspaceStateKey, - expandedPaths, - requestDirectoryListing, - selectedEntryPath, - setExpandedPathsForWorkspace, - ]); - const handleToggleDirectory = useCallback( (entry: ExplorerEntry) => { if (!workspaceStateKey) { @@ -682,35 +653,6 @@ function buildTreeRows({ return rows; } -function getParentDirectory(path: string): string { - const normalized = path.replace(/\/+$/, ""); - if (!normalized || normalized === ".") { - return "."; - } - const lastSlash = normalized.lastIndexOf("/"); - if (lastSlash === -1) { - return "."; - } - const dir = normalized.slice(0, lastSlash); - return dir.length > 0 ? dir : "."; -} - -function getAncestorDirectories(directory: string): string[] { - const trimmed = directory.replace(/^\.\/+/, "").replace(/\/+$/, ""); - if (!trimmed || trimmed === ".") { - return ["."]; - } - - const parts = trimmed.split("/").filter(Boolean); - const ancestors: string[] = ["."]; - let acc = ""; - for (const part of parts) { - acc = acc ? `${acc}/${part}` : part; - ancestors.push(acc); - } - return ancestors; -} - function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string { if (!state) { return "."; From bd1fb311ea52243b8192d8a84230349de0753db8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 19 Apr 2026 20:19:45 +0700 Subject: [PATCH 2/2] refactor(app): reshape session store reads around narrow hook surface Eliminate sidebar re-render cascades by fixing both sides of the subscription contract: - Session-store writes preserve identity: setWorkspaces, mergeWorkspaces, removeWorkspace, and patchWorkspaceScripts return the previous Map/entry reference when content is unchanged. - HostRuntimeController.updateSnapshot only notifies when a patched field actually changed; idle probe ticks no longer replace the snapshot. - New canonical hook surface in stores/session-store-hooks.ts (useWorkspace, useWorkspaceFields, useWorkspaceStructure, useWorkspaceKeys, useResolveWorkspaceIdByCwd, useWorkspaceStatusesForBadges, useWorkspaceExecutionAuthority, useRecommendedProjectPaths, useHasWorkspaces). These are the only place to subscribe to workspaces. - 15 greedy subscribers migrated to the hook surface. The sidebar is now driven by useWorkspaceStructure for identity/ordering; each row hydrates its own descriptor via useWorkspaceFields, so a workspace update re-renders only its row. Archive-redirect callbacks switched to event-time useSessionStore.getState(). - Deleted dead code: use-session-directory.ts, workspace-fetch-debug.ts, and the unused SidebarProjectEntry aggregates (activeCount, totalWorkspaces, aggregated statusBucket). No user-visible behavior change. Targeted tests green (79 tests across host-runtime, session-store, session-store-hooks, sidebar list, sidebar row model, sidebar shortcuts, workspace source of truth). --- package-lock.json | 127 ++++++ packages/app/package.json | 2 + .../src/app/h/[serverId]/agent/[agentId].tsx | 19 +- .../src/components/project-picker-modal.tsx | 13 +- .../sidebar-workspace-list.test.tsx | 337 +++++++++++++++ .../src/components/sidebar-workspace-list.tsx | 56 ++- .../src/contexts/session-workspace-scripts.ts | 5 + packages/app/src/hooks/use-favicon-status.ts | 20 +- .../app/src/hooks/use-providers-snapshot.ts | 7 +- .../app/src/hooks/use-session-directory.ts | 40 -- .../hooks/use-sidebar-workspaces-list.test.ts | 264 +++--------- .../src/hooks/use-sidebar-workspaces-list.ts | 207 ++++------ packages/app/src/panels/file-panel.tsx | 22 +- packages/app/src/panels/terminal-panel.tsx | 11 +- packages/app/src/runtime/host-runtime.test.ts | 150 +++++++ packages/app/src/runtime/host-runtime.ts | 35 +- .../app/src/screens/open-project-screen.tsx | 3 +- .../workspace/workspace-draft-agent-tab.tsx | 9 +- .../screens/workspace/workspace-screen.tsx | 22 +- .../workspace-source-of-truth.test.ts | 19 +- .../src/stores/session-store-hooks.test.ts | 364 +++++++++++++++++ .../app/src/stores/session-store-hooks.ts | 384 ++++++++++++++++++ packages/app/src/stores/session-store.test.ts | 138 +++++++ packages/app/src/stores/session-store.ts | 56 ++- .../utils/sidebar-project-row-model.test.ts | 3 - .../app/src/utils/sidebar-shortcuts.test.ts | 3 - .../app/src/utils/workspace-fetch-debug.ts | 104 ----- 27 files changed, 1797 insertions(+), 623 deletions(-) create mode 100644 packages/app/src/components/sidebar-workspace-list.test.tsx delete mode 100644 packages/app/src/hooks/use-session-directory.ts create mode 100644 packages/app/src/stores/session-store-hooks.test.ts create mode 100644 packages/app/src/stores/session-store-hooks.ts delete mode 100644 packages/app/src/utils/workspace-fetch-debug.ts 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, - }; -}