refactor(app): reshape session store reads around narrow hook surface
Eliminate sidebar re-render cascades by fixing both sides of the subscription contract: - Session-store writes preserve identity: setWorkspaces, mergeWorkspaces, removeWorkspace, and patchWorkspaceScripts return the previous Map/entry reference when content is unchanged. - HostRuntimeController.updateSnapshot only notifies when a patched field actually changed; idle probe ticks no longer replace the snapshot. - New canonical hook surface in stores/session-store-hooks.ts (useWorkspace, useWorkspaceFields, useWorkspaceStructure, useWorkspaceKeys, useResolveWorkspaceIdByCwd, useWorkspaceStatusesForBadges, useWorkspaceExecutionAuthority, useRecommendedProjectPaths, useHasWorkspaces). These are the only place to subscribe to workspaces. - 15 greedy subscribers migrated to the hook surface. The sidebar is now driven by useWorkspaceStructure for identity/ordering; each row hydrates its own descriptor via useWorkspaceFields, so a workspace update re-renders only its row. Archive-redirect callbacks switched to event-time useSessionStore.getState(). - Deleted dead code: use-session-directory.ts, workspace-fetch-debug.ts, and the unused SidebarProjectEntry aggregates (activeCount, totalWorkspaces, aggregated statusBucket). No user-visible behavior change. Targeted tests green (79 tests across host-runtime, session-store, session-store-hooks, sidebar list, sidebar row model, sidebar shortcuts, workspace source of truth).
This commit is contained in:
Generated
+127
@@ -12765,6 +12765,93 @@
|
|||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"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": {
|
"node_modules/@testing-library/react-hooks": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz",
|
||||||
@@ -12880,6 +12967,14 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -14893,6 +14988,17 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/array-buffer-byte-length": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
|
"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": ">=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": {
|
"node_modules/dom-serializer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -37233,6 +37358,7 @@
|
|||||||
"expo-system-ui": "~6.0.7",
|
"expo-system-ui": "~6.0.7",
|
||||||
"expo-updates": "~29.0.12",
|
"expo-updates": "~29.0.12",
|
||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.8",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
"lucide-react-native": "^0.546.0",
|
"lucide-react-native": "^0.546.0",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -37261,6 +37387,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"eas-cli": "^16.24.1",
|
"eas-cli": "^16.24.1",
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
"expo-system-ui": "~6.0.7",
|
"expo-system-ui": "~6.0.7",
|
||||||
"expo-updates": "~29.0.12",
|
"expo-updates": "~29.0.12",
|
||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.8",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
"lucide-react-native": "^0.546.0",
|
"lucide-react-native": "^0.546.0",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -107,6 +108,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"eas-cli": "^16.24.1",
|
"eas-cli": "^16.24.1",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
@@ -32,21 +33,10 @@ function HostAgentReadyRouteContent() {
|
|||||||
}
|
}
|
||||||
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
|
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
|
||||||
});
|
});
|
||||||
const sessionWorkspaces = useSessionStore((state) =>
|
|
||||||
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
|
||||||
);
|
|
||||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||||
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||||
);
|
);
|
||||||
const resolvedWorkspaceId = useSessionStore((state) => {
|
const resolvedWorkspaceId = useResolveWorkspaceIdByCwd(serverId, agentCwd);
|
||||||
if (!serverId || !agentId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return resolveWorkspaceIdByExecutionDirectory({
|
|
||||||
workspaces: state.sessions[serverId]?.workspaces?.values(),
|
|
||||||
workspaceDirectory: state.sessions[serverId]?.agents?.get(agentId)?.cwd,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (redirectedRef.current) {
|
if (redirectedRef.current) {
|
||||||
@@ -102,8 +92,9 @@ function HostAgentReadyRouteContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cwd = result?.agent?.cwd?.trim();
|
const cwd = result?.agent?.cwd?.trim();
|
||||||
|
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||||
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||||
workspaces: sessionWorkspaces?.values(),
|
workspaces: workspaces?.values(),
|
||||||
workspaceDirectory: cwd,
|
workspaceDirectory: cwd,
|
||||||
});
|
});
|
||||||
if (!workspaceId && !hasHydratedWorkspaces) {
|
if (!workspaceId && !hasHydratedWorkspaces) {
|
||||||
@@ -133,7 +124,7 @@ function HostAgentReadyRouteContent() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId, sessionWorkspaces]);
|
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { usePathname } from "expo-router";
|
import { usePathname } from "expo-router";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
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 { useHosts, useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useOpenProject } from "@/hooks/use-open-project";
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
||||||
@@ -29,9 +29,7 @@ export function ProjectPickerModal() {
|
|||||||
|
|
||||||
const client = useHostRuntimeClient(serverId ?? "");
|
const client = useHostRuntimeClient(serverId ?? "");
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||||
const workspaces = useSessionStore((state) =>
|
const recommendedPaths = useRecommendedProjectPaths(serverId);
|
||||||
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -39,13 +37,6 @@ export function ProjectPickerModal() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const openProject = useOpenProject(serverId);
|
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({
|
const directorySuggestionsQuery = useQuery({
|
||||||
queryKey: ["project-picker-directory-suggestions", serverId, query],
|
queryKey: ["project-picker-directory-suggestions", serverId, query],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { act } from "@testing-library/react";
|
||||||
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
import type { WorkspaceScriptPayload } from "@server/shared/messages";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import React from "react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
vi.hoisted(() => {
|
||||||
|
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
import {
|
||||||
|
createSidebarWorkspaceEntry,
|
||||||
|
type SidebarProjectEntry,
|
||||||
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
|
||||||
|
import {
|
||||||
|
getHostRuntimeStore,
|
||||||
|
type HostRuntimeController,
|
||||||
|
type HostRuntimeSnapshot,
|
||||||
|
} from "@/runtime/host-runtime";
|
||||||
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
|
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
|
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||||
|
|
||||||
|
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||||
|
default: {
|
||||||
|
getItem: vi.fn(),
|
||||||
|
setItem: vi.fn(),
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const SERVER_ID = "sidebar-render-count";
|
||||||
|
|
||||||
|
type RenderCounts = {
|
||||||
|
frame: number;
|
||||||
|
headers: Record<string, number>;
|
||||||
|
rows: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runningScript: WorkspaceScriptPayload = {
|
||||||
|
scriptName: "web",
|
||||||
|
type: "service",
|
||||||
|
hostname: "web.paseo.localhost",
|
||||||
|
port: 3000,
|
||||||
|
proxyUrl: "http://web.paseo.localhost:6767",
|
||||||
|
lifecycle: "running",
|
||||||
|
health: "healthy",
|
||||||
|
exitCode: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function workspace(input: {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
projectDisplayName: string;
|
||||||
|
name: string;
|
||||||
|
status?: WorkspaceDescriptor["status"];
|
||||||
|
scripts?: WorkspaceDescriptor["scripts"];
|
||||||
|
}): WorkspaceDescriptor {
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
projectId: input.projectId,
|
||||||
|
projectDisplayName: input.projectDisplayName,
|
||||||
|
projectRootPath: `/repo/${input.projectId}`,
|
||||||
|
workspaceDirectory: `/repo/${input.projectId}/${input.id}`,
|
||||||
|
projectKind: "git",
|
||||||
|
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
|
||||||
|
name: input.name,
|
||||||
|
status: input.status ?? "done",
|
||||||
|
diffStat: null,
|
||||||
|
scripts: input.scripts ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWorkspaces(): WorkspaceDescriptor[] {
|
||||||
|
return [
|
||||||
|
workspace({
|
||||||
|
id: "a-main",
|
||||||
|
projectId: "project-a",
|
||||||
|
projectDisplayName: "Project A",
|
||||||
|
name: "main",
|
||||||
|
scripts: [runningScript],
|
||||||
|
}),
|
||||||
|
workspace({
|
||||||
|
id: "a-one",
|
||||||
|
projectId: "project-a",
|
||||||
|
projectDisplayName: "Project A",
|
||||||
|
name: "one",
|
||||||
|
}),
|
||||||
|
workspace({
|
||||||
|
id: "a-two",
|
||||||
|
projectId: "project-a",
|
||||||
|
projectDisplayName: "Project A",
|
||||||
|
name: "two",
|
||||||
|
}),
|
||||||
|
workspace({
|
||||||
|
id: "b-main",
|
||||||
|
projectId: "project-b",
|
||||||
|
projectDisplayName: "Project B",
|
||||||
|
name: "main",
|
||||||
|
}),
|
||||||
|
workspace({
|
||||||
|
id: "b-one",
|
||||||
|
projectId: "project-b",
|
||||||
|
projectDisplayName: "Project B",
|
||||||
|
name: "one",
|
||||||
|
}),
|
||||||
|
workspace({
|
||||||
|
id: "b-two",
|
||||||
|
projectId: "project-b",
|
||||||
|
projectDisplayName: "Project B",
|
||||||
|
name: "two",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHost(): HostProfile {
|
||||||
|
const now = "2026-04-19T00:00:00.000Z";
|
||||||
|
return {
|
||||||
|
serverId: SERVER_ID,
|
||||||
|
label: "Render Count Host",
|
||||||
|
lifecycle: {},
|
||||||
|
connections: [],
|
||||||
|
preferredConnectionId: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
|
||||||
|
act(() => {
|
||||||
|
getHostRuntimeStore().syncHosts([makeHost()]);
|
||||||
|
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||||
|
useSessionStore
|
||||||
|
.getState()
|
||||||
|
.setWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
|
||||||
|
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
|
||||||
|
useSidebarOrderStore.setState({
|
||||||
|
projectOrderByServerId: {
|
||||||
|
[SERVER_ID]: ["project-a", "project-b"],
|
||||||
|
},
|
||||||
|
workspaceOrderByServerAndProject: {
|
||||||
|
[`${SERVER_ID}::project-a`]: [
|
||||||
|
`${SERVER_ID}:a-main`,
|
||||||
|
`${SERVER_ID}:a-one`,
|
||||||
|
`${SERVER_ID}:a-two`,
|
||||||
|
],
|
||||||
|
[`${SERVER_ID}::project-b`]: [
|
||||||
|
`${SERVER_ID}:b-main`,
|
||||||
|
`${SERVER_ID}:b-one`,
|
||||||
|
`${SERVER_ID}:b-two`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCounts(counts: RenderCounts): void {
|
||||||
|
counts.frame = 0;
|
||||||
|
counts.headers = {};
|
||||||
|
counts.rows = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementRecord(record: Record<string, number>, key: string): void {
|
||||||
|
record[key] = (record[key] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectHeaderProbe({
|
||||||
|
project,
|
||||||
|
counts,
|
||||||
|
}: {
|
||||||
|
project: SidebarProjectEntry;
|
||||||
|
counts: RenderCounts;
|
||||||
|
}): null {
|
||||||
|
incrementRecord(counts.headers, project.projectKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceRowProbe({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
counts,
|
||||||
|
}: {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
counts: RenderCounts;
|
||||||
|
}): null {
|
||||||
|
const workspace = useWorkspaceFields(serverId, workspaceId, (entry) =>
|
||||||
|
createSidebarWorkspaceEntry({ serverId, workspace: entry }),
|
||||||
|
);
|
||||||
|
if (workspace) {
|
||||||
|
incrementRecord(counts.rows, workspace.workspaceId);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
|
||||||
|
counts.frame += 1;
|
||||||
|
const { projects } = useSidebarWorkspacesList({ serverId: SERVER_ID });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<div key={project.projectKey}>
|
||||||
|
<ProjectHeaderProbe project={project} counts={counts} />
|
||||||
|
{project.workspaces.map((workspace) => (
|
||||||
|
<WorkspaceRowProbe
|
||||||
|
key={workspace.workspaceKey}
|
||||||
|
serverId={workspace.serverId}
|
||||||
|
workspaceId={workspace.workspaceId}
|
||||||
|
counts={counts}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHostController(): HostRuntimeController {
|
||||||
|
const controllers = (
|
||||||
|
getHostRuntimeStore() as unknown as {
|
||||||
|
controllers: Map<string, HostRuntimeController>;
|
||||||
|
}
|
||||||
|
).controllers;
|
||||||
|
const controller = controllers.get(SERVER_ID);
|
||||||
|
if (!controller) {
|
||||||
|
throw new Error("Host runtime controller was not initialized");
|
||||||
|
}
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateControllerSnapshot(
|
||||||
|
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
|
||||||
|
): void {
|
||||||
|
(
|
||||||
|
getHostController() as unknown as {
|
||||||
|
updateSnapshot: (
|
||||||
|
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
).updateSnapshot(patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderProbe(counts: RenderCounts): Promise<{ root: Root; container: HTMLElement }> {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<SidebarFrameProbe counts={counts} />);
|
||||||
|
});
|
||||||
|
resetCounts(counts);
|
||||||
|
return { root, container };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sidebar workspace render isolation", () => {
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
initializeSidebarState(createWorkspaces());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
act(() => {
|
||||||
|
getHostRuntimeStore().syncHosts([]);
|
||||||
|
useSessionStore.getState().clearSession(SERVER_ID);
|
||||||
|
useSidebarOrderStore.setState({
|
||||||
|
projectOrderByServerId: {},
|
||||||
|
workspaceOrderByServerAndProject: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-renders only the changed workspace row for a status update", async () => {
|
||||||
|
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} };
|
||||||
|
({ root, container } = await renderProbe(counts));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
|
||||||
|
{
|
||||||
|
...createWorkspaces()[1]!,
|
||||||
|
status: "running",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(counts.frame).toBe(0);
|
||||||
|
expect(counts.headers).toEqual({});
|
||||||
|
expect(counts.rows).toEqual({ "a-one": 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-render the sidebar for a host-runtime probe tick with no content change", async () => {
|
||||||
|
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} };
|
||||||
|
({ root, container } = await renderProbe(counts));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
const probeByConnectionId = getHostController().getSnapshot().probeByConnectionId;
|
||||||
|
updateControllerSnapshot({
|
||||||
|
probeByConnectionId: new Map(probeByConnectionId),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(counts).toEqual({ frame: 0, headers: {}, rows: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-render for a deep-equal scripts patch", async () => {
|
||||||
|
const counts: RenderCounts = { frame: 0, headers: {}, rows: {} };
|
||||||
|
({ root, container } = await renderProbe(counts));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().setWorkspaces(SERVER_ID, (current) =>
|
||||||
|
patchWorkspaceScripts(current, {
|
||||||
|
workspaceId: "a-main",
|
||||||
|
scripts: [{ ...runningScript }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(counts).toEqual({ frame: 0, headers: {}, rows: {} });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import {
|
import {
|
||||||
|
createSidebarWorkspaceEntry,
|
||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
type SidebarWorkspaceEntry,
|
type SidebarWorkspaceEntry,
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} 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 { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||||
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||||
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
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 { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import {
|
import {
|
||||||
@@ -114,7 +120,6 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
|
|||||||
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
|
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
|
||||||
|
|
||||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||||
const EMPTY_WORKSPACES = new Map();
|
|
||||||
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||||
const DEFAULT_STATUS_DOT_SIZE = 7;
|
const DEFAULT_STATUS_DOT_SIZE = 7;
|
||||||
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
const EMPHASIZED_STATUS_DOT_SIZE = 9;
|
||||||
@@ -194,6 +199,19 @@ interface WorkspaceRowInnerProps {
|
|||||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
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 }) {
|
export function PrBadge({ hint }: { hint: PrHint }) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
@@ -1151,9 +1169,6 @@ function WorkspaceRowWithMenu({
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
||||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||||
const sessionWorkspaces = useSessionStore(
|
|
||||||
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
|
|
||||||
);
|
|
||||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||||
workspaceDirectory: workspace.workspaceDirectory,
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
@@ -1181,10 +1196,11 @@ function WorkspaceRowWithMenu({
|
|||||||
buildWorkspaceArchiveRedirectRoute({
|
buildWorkspaceArchiveRedirectRoute({
|
||||||
serverId: workspace.serverId,
|
serverId: workspace.serverId,
|
||||||
archivedWorkspaceId: workspace.workspaceId,
|
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(() => {
|
const handleArchiveWorktree = useCallback(() => {
|
||||||
if (isArchiving) {
|
if (isArchiving) {
|
||||||
@@ -1378,9 +1394,6 @@ function NonGitProjectRowWithMenuContent({
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const contextMenu = useContextMenu();
|
const contextMenu = useContextMenu();
|
||||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
|
||||||
const sessionWorkspaces = useSessionStore(
|
|
||||||
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
|
|
||||||
);
|
|
||||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||||
const redirectAfterArchive = useCallback(() => {
|
const redirectAfterArchive = useCallback(() => {
|
||||||
if (
|
if (
|
||||||
@@ -1394,10 +1407,11 @@ function NonGitProjectRowWithMenuContent({
|
|||||||
buildWorkspaceArchiveRedirectRoute({
|
buildWorkspaceArchiveRedirectRoute({
|
||||||
serverId: workspace.serverId,
|
serverId: workspace.serverId,
|
||||||
archivedWorkspaceId: workspace.workspaceId,
|
archivedWorkspaceId: workspace.workspaceId,
|
||||||
workspaces: sessionWorkspaces.values(),
|
workspaces:
|
||||||
|
useSessionStore.getState().sessions[workspace.serverId]?.workspaces.values() ?? [],
|
||||||
}) as any,
|
}) as any,
|
||||||
);
|
);
|
||||||
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
|
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
|
||||||
|
|
||||||
const handleArchiveWorkspace = useCallback(() => {
|
const handleArchiveWorkspace = useCallback(() => {
|
||||||
if (isArchivingWorkspace) {
|
if (isArchivingWorkspace) {
|
||||||
@@ -1542,13 +1556,19 @@ function FlattenedProjectRow({
|
|||||||
onRemoveProject?: () => void;
|
onRemoveProject?: () => void;
|
||||||
removeProjectStatus?: "idle" | "pending";
|
removeProjectStatus?: "idle" | "pending";
|
||||||
}) {
|
}) {
|
||||||
|
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (project.projectKind === "directory") {
|
if (project.projectKind === "directory") {
|
||||||
return (
|
return (
|
||||||
<NonGitProjectRowWithMenu
|
<NonGitProjectRowWithMenu
|
||||||
project={project}
|
project={project}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
iconDataUri={iconDataUri}
|
iconDataUri={iconDataUri}
|
||||||
workspace={rowModel.workspace}
|
workspace={workspace}
|
||||||
selected={rowModel.selected}
|
selected={rowModel.selected}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
shortcutNumber={shortcutNumber}
|
shortcutNumber={shortcutNumber}
|
||||||
@@ -1565,7 +1585,7 @@ function FlattenedProjectRow({
|
|||||||
project={project}
|
project={project}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
iconDataUri={iconDataUri}
|
iconDataUri={iconDataUri}
|
||||||
workspace={rowModel.workspace}
|
workspace={workspace}
|
||||||
selected={rowModel.selected}
|
selected={rowModel.selected}
|
||||||
chevron={rowModel.chevron}
|
chevron={rowModel.chevron}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
@@ -1609,9 +1629,15 @@ function WorkspaceRow({
|
|||||||
canCopyBranchName: boolean;
|
canCopyBranchName: boolean;
|
||||||
isCreating?: boolean;
|
isCreating?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
||||||
|
|
||||||
|
if (!hydratedWorkspace) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceRowWithMenu
|
<WorkspaceRowWithMenu
|
||||||
workspace={workspace}
|
workspace={hydratedWorkspace}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
shortcutNumber={shortcutNumber}
|
shortcutNumber={shortcutNumber}
|
||||||
showShortcutBadge={showShortcutBadge}
|
showShortcutBadge={showShortcutBadge}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import equal from "fast-deep-equal";
|
||||||
import type { ScriptStatusUpdateMessage } from "@server/shared/messages";
|
import type { ScriptStatusUpdateMessage } from "@server/shared/messages";
|
||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||||
@@ -19,6 +20,10 @@ export function patchWorkspaceScripts(
|
|||||||
return workspaces;
|
return workspaces;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (equal(existing.scripts, update.scripts)) {
|
||||||
|
return workspaces;
|
||||||
|
}
|
||||||
|
|
||||||
const next = new Map(workspaces);
|
const next = new Map(workspaces);
|
||||||
next.set(workspaceKey, {
|
next.set(workspaceKey, {
|
||||||
...existing,
|
...existing,
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useShallow } from "zustand/shallow";
|
|
||||||
import { getIsElectronRuntimeMac } from "@/constants/layout";
|
import { getIsElectronRuntimeMac } from "@/constants/layout";
|
||||||
import { useAggregatedAgents } from "./use-aggregated-agents";
|
import { useAggregatedAgents } from "./use-aggregated-agents";
|
||||||
import { getDesktopHost } from "@/desktop/host";
|
import { getDesktopHost } from "@/desktop/host";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useWorkspaceStatusesForBadges } from "@/stores/session-store-hooks";
|
||||||
import {
|
import { deriveMacDockBadgeCountFromWorkspaceStatuses } from "@/utils/desktop-badge-state";
|
||||||
deriveMacDockBadgeCountFromWorkspaceStatuses,
|
|
||||||
type DesktopBadgeWorkspaceStatus,
|
|
||||||
} from "@/utils/desktop-badge-state";
|
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
type FaviconStatus = "none" | "running" | "attention";
|
type FaviconStatus = "none" | "running" | "attention";
|
||||||
@@ -99,17 +95,7 @@ async function updateMacDockBadge(count?: number) {
|
|||||||
|
|
||||||
export function useFaviconStatus() {
|
export function useFaviconStatus() {
|
||||||
const { agents } = useAggregatedAgents();
|
const { agents } = useAggregatedAgents();
|
||||||
const workspaceStatuses = useSessionStore(
|
const workspaceStatuses = useWorkspaceStatusesForBadges();
|
||||||
useShallow((state) => {
|
|
||||||
const statuses: DesktopBadgeWorkspaceStatus[] = [];
|
|
||||||
for (const session of Object.values(state.sessions)) {
|
|
||||||
for (const workspace of session.workspaces.values()) {
|
|
||||||
statuses.push(workspace.status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return statuses;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
|
const [colorScheme, setColorScheme] = useState<ColorScheme>(getSystemColorScheme);
|
||||||
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
|
const lastDockBadgeCountRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
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";
|
import { queryClient as singletonQueryClient } from "@/query/query-client";
|
||||||
|
|
||||||
function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
|
function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
|
||||||
@@ -39,9 +39,8 @@ export function useProvidersSnapshot(
|
|||||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||||
const normalizedCwd = cwd?.trim() || undefined;
|
const normalizedCwd = cwd?.trim() || undefined;
|
||||||
const normalizedCwdKey = normalizeProvidersSnapshotCwdKey(normalizedCwd);
|
const normalizedCwdKey = normalizeProvidersSnapshotCwdKey(normalizedCwd);
|
||||||
const supportsSnapshot = useSessionForServer(
|
const supportsSnapshot = useSessionStore(
|
||||||
serverId,
|
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true,
|
||||||
(session) => session?.serverInfo?.features?.providersSnapshot === true,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const queryKey = useMemo(
|
const queryKey = useMemo(
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
|
||||||
import { useSessionStore, type SessionState } from "@/stores/session-store";
|
|
||||||
|
|
||||||
export function useSessionDirectory(): Map<string, SessionState> {
|
|
||||||
const sessions = useSessionStore((state) => state.sessions);
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
return new Map<string, SessionState>(Object.entries(sessions));
|
|
||||||
}, [sessions]);
|
|
||||||
}
|
|
||||||
|
|
||||||
type SessionSelector<T> = (session: SessionState | null) => T;
|
|
||||||
type EqualityFn<T> = ((left: T, right: T) => boolean) | undefined;
|
|
||||||
|
|
||||||
export function useSessionForServer(serverId: string | null): SessionState | null;
|
|
||||||
export function useSessionForServer<T>(
|
|
||||||
serverId: string | null,
|
|
||||||
selector: SessionSelector<T>,
|
|
||||||
equalityFn?: EqualityFn<T>,
|
|
||||||
): T;
|
|
||||||
export function useSessionForServer<T>(
|
|
||||||
serverId: string | null,
|
|
||||||
selector?: SessionSelector<T>,
|
|
||||||
equalityFn?: EqualityFn<T>,
|
|
||||||
): SessionState | null | T {
|
|
||||||
const storeSelector = useCallback(
|
|
||||||
(state: ReturnType<typeof useSessionStore.getState>): SessionState | null | T => {
|
|
||||||
const session = serverId ? (state.sessions[serverId] ?? null) : null;
|
|
||||||
return selector ? selector(session) : session;
|
|
||||||
},
|
|
||||||
[selector, serverId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return useStoreWithEqualityFn(
|
|
||||||
useSessionStore,
|
|
||||||
storeSelector,
|
|
||||||
equalityFn as EqualityFn<SessionState | null | T>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { WorkspaceScriptPayload } from "@server/shared/messages";
|
|
||||||
|
vi.hoisted(() => {
|
||||||
|
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||||
|
});
|
||||||
|
|
||||||
import {
|
import {
|
||||||
appendMissingOrderKeys,
|
appendMissingOrderKeys,
|
||||||
applyStoredOrdering,
|
applyStoredOrdering,
|
||||||
buildSidebarProjectsFromWorkspaces,
|
buildSidebarProjectsFromStructure,
|
||||||
} from "./use-sidebar-workspaces-list";
|
} from "./use-sidebar-workspaces-list";
|
||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
import type { WorkspaceStructureProject } from "@/stores/session-store-hooks";
|
||||||
|
|
||||||
interface OrderedItem {
|
interface OrderedItem {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -15,57 +19,22 @@ function item(key: string): OrderedItem {
|
|||||||
return { key };
|
return { key };
|
||||||
}
|
}
|
||||||
|
|
||||||
function workspace(
|
function project(input: {
|
||||||
input: Pick<WorkspaceDescriptor, "id" | "projectId" | "name" | "status"> &
|
projectKey: string;
|
||||||
Partial<
|
projectName?: string;
|
||||||
Pick<
|
projectKind?: WorkspaceStructureProject["projectKind"];
|
||||||
WorkspaceDescriptor,
|
iconWorkingDir?: string;
|
||||||
| "projectDisplayName"
|
workspaceKeys: string[];
|
||||||
| "projectRootPath"
|
}): WorkspaceStructureProject {
|
||||||
| "workspaceDirectory"
|
|
||||||
| "projectKind"
|
|
||||||
| "workspaceKind"
|
|
||||||
| "scripts"
|
|
||||||
>
|
|
||||||
>,
|
|
||||||
): WorkspaceDescriptor {
|
|
||||||
return {
|
return {
|
||||||
id: input.id,
|
projectKey: input.projectKey,
|
||||||
projectId: input.projectId,
|
projectName: input.projectName ?? input.projectKey,
|
||||||
projectDisplayName: input.projectDisplayName ?? input.projectId,
|
|
||||||
projectRootPath: input.projectRootPath ?? input.id,
|
|
||||||
workspaceDirectory: input.workspaceDirectory ?? input.projectRootPath ?? input.id,
|
|
||||||
projectKind: input.projectKind ?? "git",
|
projectKind: input.projectKind ?? "git",
|
||||||
workspaceKind: input.workspaceKind ?? "checkout",
|
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
|
||||||
name: input.name,
|
workspaceKeys: input.workspaceKeys,
|
||||||
status: input.status,
|
|
||||||
diffStat: null,
|
|
||||||
scripts: input.scripts ?? [],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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", () => {
|
describe("applyStoredOrdering", () => {
|
||||||
it("keeps unknown items on the baseline while applying stored order", () => {
|
it("keeps unknown items on the baseline while applying stored order", () => {
|
||||||
const result = applyStoredOrdering({
|
const result = applyStoredOrdering({
|
||||||
@@ -121,187 +90,52 @@ describe("appendMissingOrderKeys", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildSidebarProjectsFromWorkspaces", () => {
|
describe("buildSidebarProjectsFromStructure", () => {
|
||||||
it("uses workspace descriptor name and status directly", () => {
|
it("creates structural workspace rows from ordered workspace keys", () => {
|
||||||
const workspaces: WorkspaceDescriptor[] = [
|
const projects = buildSidebarProjectsFromStructure({
|
||||||
workspace({
|
|
||||||
id: "/repo/main",
|
|
||||||
projectId: "project-1",
|
|
||||||
name: "feat/hard-cut",
|
|
||||||
status: "failed",
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
const projects = buildSidebarProjectsFromWorkspaces({
|
|
||||||
serverId: "srv",
|
serverId: "srv",
|
||||||
workspaces,
|
projects: [
|
||||||
projectOrder: [],
|
project({
|
||||||
workspaceOrderByScope: {},
|
projectKey: "project-1",
|
||||||
|
projectName: "Project 1",
|
||||||
|
iconWorkingDir: "/repo/main",
|
||||||
|
workspaceKeys: ["ws-main"],
|
||||||
|
}),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(projects).toHaveLength(1);
|
expect(projects).toHaveLength(1);
|
||||||
expect(projects[0]?.statusBucket).toBe("failed");
|
expect(projects[0]?.projectName).toBe("Project 1");
|
||||||
expect(projects[0]?.workspaces[0]?.name).toBe("feat/hard-cut");
|
expect(projects[0]?.workspaces[0]).toMatchObject({
|
||||||
expect(projects[0]?.workspaces[0]?.statusBucket).toBe("failed");
|
workspaceKey: "srv:ws-main",
|
||||||
|
serverId: "srv",
|
||||||
|
workspaceId: "ws-main",
|
||||||
|
projectRootPath: "/repo/main",
|
||||||
|
projectKind: "git",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("threads scripts into workspace rows and derives hasRunningScripts", () => {
|
it("preserves the structure hook project order", () => {
|
||||||
const projects = buildSidebarProjectsFromWorkspaces({
|
const projects = buildSidebarProjectsFromStructure({
|
||||||
serverId: "srv",
|
serverId: "srv",
|
||||||
workspaces: [
|
projects: [
|
||||||
workspace({
|
project({ projectKey: "project-b", workspaceKeys: ["ws-b"] }),
|
||||||
id: "/repo/main",
|
project({ projectKey: "project-a", workspaceKeys: ["ws-a"] }),
|
||||||
projectId: "project-1",
|
|
||||||
name: "main",
|
|
||||||
status: "running",
|
|
||||||
scripts: [runningScript, stoppedScript],
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
projectOrder: [],
|
|
||||||
workspaceOrderByScope: {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(projects[0]?.workspaces[0]?.scripts).toEqual([runningScript, stoppedScript]);
|
expect(projects.map((project) => project.projectKey)).toEqual(["project-b", "project-a"]);
|
||||||
expect(projects[0]?.workspaces[0]?.hasRunningScripts).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves stored project order even when input order differs", () => {
|
it("preserves the structure hook workspace order", () => {
|
||||||
const initialWorkspaces: WorkspaceDescriptor[] = [
|
const projects = buildSidebarProjectsFromStructure({
|
||||||
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({
|
|
||||||
serverId: "srv",
|
serverId: "srv",
|
||||||
workspaces: [
|
projects: [project({ projectKey: "project-1", workspaceKeys: ["feature", "main"] })],
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([
|
expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([
|
||||||
"/repo/feature",
|
"feature",
|
||||||
"/repo/main",
|
"main",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ import {
|
|||||||
useSessionStore,
|
useSessionStore,
|
||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
|
import {
|
||||||
|
useWorkspaceStructure,
|
||||||
|
type WorkspaceStructureProject,
|
||||||
|
} from "@/stores/session-store-hooks";
|
||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
|
||||||
|
|
||||||
const EMPTY_ORDER: string[] = [];
|
const EMPTY_ORDER: string[] = [];
|
||||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||||
const EMPTY_WORKSPACE_ORDER_SCOPE: Record<string, string[]> = {};
|
|
||||||
|
|
||||||
export type SidebarStateBucket = WorkspaceDescriptor["status"];
|
export type SidebarStateBucket = WorkspaceDescriptor["status"];
|
||||||
|
|
||||||
@@ -36,9 +38,6 @@ export interface SidebarProjectEntry {
|
|||||||
projectName: string;
|
projectName: string;
|
||||||
projectKind: WorkspaceDescriptor["projectKind"];
|
projectKind: WorkspaceDescriptor["projectKind"];
|
||||||
iconWorkingDir: string;
|
iconWorkingDir: string;
|
||||||
statusBucket: SidebarStateBucket;
|
|
||||||
activeCount: number;
|
|
||||||
totalWorkspaces: number;
|
|
||||||
workspaces: SidebarWorkspaceEntry[];
|
workspaces: SidebarWorkspaceEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,119 +49,68 @@ export interface SidebarWorkspacesListResult {
|
|||||||
refreshAll: () => void;
|
refreshAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIDEBAR_BUCKET_PRIORITY: Record<SidebarStateBucket, number> = {
|
function createStructuralWorkspaceEntry(input: {
|
||||||
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: {
|
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaces: Iterable<WorkspaceDescriptor>;
|
project: WorkspaceStructureProject;
|
||||||
projectOrder: string[];
|
workspaceId: string;
|
||||||
workspaceOrderByScope: Record<string, 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[] {
|
}): SidebarProjectEntry[] {
|
||||||
const byProject = new Map<string, SidebarProjectEntry>();
|
if (input.projects.length === 0) {
|
||||||
|
return EMPTY_PROJECTS;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const baselineProjects = Array.from(byProject.values()).map((project) => {
|
return input.projects.map((project) => ({
|
||||||
const baselineWorkspaces = [...project.workspaces];
|
projectKey: project.projectKey,
|
||||||
baselineWorkspaces.sort(compareWorkspaceBaseline);
|
projectName: project.projectName,
|
||||||
|
projectKind: project.projectKind,
|
||||||
const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(input.serverId, project.projectKey);
|
iconWorkingDir: project.iconWorkingDir,
|
||||||
const orderedWorkspaces = applyStoredOrdering({
|
workspaces: project.workspaceKeys.map((workspaceId) =>
|
||||||
items: baselineWorkspaces,
|
createStructuralWorkspaceEntry({
|
||||||
storedOrder: input.workspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER,
|
serverId: input.serverId,
|
||||||
getKey: (workspace) => workspace.workspaceKey,
|
project,
|
||||||
});
|
workspaceId,
|
||||||
|
}),
|
||||||
return {
|
),
|
||||||
...project,
|
}));
|
||||||
workspaces: orderedWorkspaces,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
baselineProjects.sort(compareProjectBaseline);
|
|
||||||
|
|
||||||
return applyStoredOrdering({
|
|
||||||
items: baselineProjects,
|
|
||||||
storedOrder: input.projectOrder,
|
|
||||||
getKey: (project) => project.projectKey,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyStoredOrdering<T>(input: {
|
export function applyStoredOrdering<T>(input: {
|
||||||
@@ -229,10 +177,6 @@ export function appendMissingOrderKeys(input: {
|
|||||||
return [...input.currentOrder, ...missingKeys];
|
return [...input.currentOrder, ...missingKeys];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string {
|
|
||||||
return `${serverId.trim()}::${projectKey.trim()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor {
|
function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor {
|
||||||
return normalizeWorkspaceDescriptor(payload);
|
return normalizeWorkspaceDescriptor(payload);
|
||||||
}
|
}
|
||||||
@@ -250,17 +194,12 @@ export function useSidebarWorkspacesList(options?: {
|
|||||||
const persistedProjectOrder = useSidebarOrderStore((state) =>
|
const persistedProjectOrder = useSidebarOrderStore((state) =>
|
||||||
serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER,
|
serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER,
|
||||||
);
|
);
|
||||||
const persistedWorkspaceOrderByScope = useSidebarOrderStore((state) =>
|
|
||||||
serverId ? state.workspaceOrderByServerAndProject : EMPTY_WORKSPACE_ORDER_SCOPE,
|
|
||||||
);
|
|
||||||
|
|
||||||
const isActive = Boolean(serverId);
|
const isActive = Boolean(serverId);
|
||||||
const sessionWorkspaces = useSessionStore((state) =>
|
|
||||||
isActive && serverId ? (state.sessions[serverId]?.workspaces ?? null) : null,
|
|
||||||
);
|
|
||||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||||
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||||
);
|
);
|
||||||
|
const workspaceStructure = useWorkspaceStructure(isActive ? serverId : null);
|
||||||
|
|
||||||
const connectionStatus = useSyncExternalStore(
|
const connectionStatus = useSyncExternalStore(
|
||||||
(onStoreChange) =>
|
(onStoreChange) =>
|
||||||
@@ -282,22 +221,20 @@ export function useSidebarWorkspacesList(options?: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const projects = useMemo(() => {
|
const projects = useMemo(() => {
|
||||||
if (!sessionWorkspaces || sessionWorkspaces.size === 0 || !serverId) {
|
if (!serverId || workspaceStructure.projects.length === 0) {
|
||||||
return EMPTY_PROJECTS;
|
return EMPTY_PROJECTS;
|
||||||
}
|
}
|
||||||
return buildSidebarProjectsFromWorkspaces({
|
return buildSidebarProjectsFromStructure({
|
||||||
serverId,
|
serverId,
|
||||||
workspaces: sessionWorkspaces.values(),
|
projects: workspaceStructure.projects,
|
||||||
projectOrder: persistedProjectOrder,
|
|
||||||
workspaceOrderByScope: persistedWorkspaceOrderByScope,
|
|
||||||
});
|
});
|
||||||
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces]);
|
}, [serverId, workspaceStructure]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId, sessionWorkspaces]);
|
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverId || projects.length === 0) {
|
if (!serverId || projects.length === 0) {
|
||||||
@@ -313,9 +250,9 @@ export function useSidebarWorkspacesList(options?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(serverId, project.projectKey);
|
const persistedWorkspaceOrder = useSidebarOrderStore
|
||||||
const persistedWorkspaceOrder =
|
.getState()
|
||||||
persistedWorkspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER;
|
.getWorkspaceOrder(serverId, project.projectKey);
|
||||||
const nextWorkspaceOrder = appendMissingOrderKeys({
|
const nextWorkspaceOrder = appendMissingOrderKeys({
|
||||||
currentOrder: persistedWorkspaceOrder,
|
currentOrder: persistedWorkspaceOrder,
|
||||||
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
|
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
|
||||||
@@ -326,7 +263,7 @@ export function useSidebarWorkspacesList(options?: {
|
|||||||
.setWorkspaceOrder(serverId, project.projectKey, nextWorkspaceOrder);
|
.setWorkspaceOrder(serverId, project.projectKey, nextWorkspaceOrder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, projects, serverId]);
|
}, [persistedProjectOrder, projects, serverId]);
|
||||||
|
|
||||||
const refreshAll = useCallback(() => {
|
const refreshAll = useCallback(() => {
|
||||||
if (!isActive || !serverId || connectionStatus !== "online") {
|
if (!isActive || !serverId || connectionStatus !== "online") {
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { FileText } from "lucide-react-native";
|
import { FileText } from "lucide-react-native";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { FilePane } from "@/components/file-pane";
|
import { FilePane } from "@/components/file-pane";
|
||||||
import { usePaneContext } from "@/panels/pane-context";
|
import { usePaneContext } from "@/panels/pane-context";
|
||||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||||
import { resolveWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
|
||||||
|
|
||||||
function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
|
function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
|
||||||
const fileName = target.path.split("/").filter(Boolean).pop() ?? target.path;
|
const fileName = target.path.split("/").filter(Boolean).pop() ?? target.path;
|
||||||
@@ -21,25 +19,19 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
|
|||||||
|
|
||||||
function FilePanel() {
|
function FilePanel() {
|
||||||
const { serverId, workspaceId, target } = usePaneContext();
|
const { serverId, workspaceId, target } = usePaneContext();
|
||||||
const workspace = useSessionStore(
|
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId);
|
||||||
(state) => state.sessions[serverId]?.workspaces.get(workspaceId) ?? null,
|
const workspaceDirectory = workspaceAuthority?.ok
|
||||||
);
|
? workspaceAuthority.authority.workspaceDirectory
|
||||||
const authority = useMemo(() => resolveWorkspaceExecutionAuthority({ workspace }), [workspace]);
|
: null;
|
||||||
invariant(target.kind === "file", "FilePanel requires file target");
|
invariant(target.kind === "file", "FilePanel requires file target");
|
||||||
if (!authority) {
|
if (!workspaceDirectory) {
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 16 }}>
|
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 16 }}>
|
||||||
<Text>Workspace execution directory not found.</Text>
|
<Text>Workspace execution directory not found.</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return <FilePane serverId={serverId} workspaceRoot={workspaceDirectory} filePath={target.path} />;
|
||||||
<FilePane
|
|
||||||
serverId={serverId}
|
|
||||||
workspaceRoot={authority.workspaceDirectory}
|
|
||||||
filePath={target.path}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const filePanelRegistration: PanelRegistration<"file"> = {
|
export const filePanelRegistration: PanelRegistration<"file"> = {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { TerminalPane } from "@/components/terminal-pane";
|
|||||||
import { usePaneContext } from "@/panels/pane-context";
|
import { usePaneContext } from "@/panels/pane-context";
|
||||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
||||||
|
|
||||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||||
|
|
||||||
@@ -25,11 +25,7 @@ function useTerminalPanelDescriptor(
|
|||||||
context: { serverId: string; workspaceId: string },
|
context: { serverId: string; workspaceId: string },
|
||||||
): PanelDescriptor {
|
): PanelDescriptor {
|
||||||
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
|
||||||
const workspaces = useSessionStore((state) => state.sessions[context.serverId]?.workspaces);
|
const workspaceAuthority = useWorkspaceExecutionAuthority(context.serverId, context.workspaceId)!;
|
||||||
const workspaceAuthority = getWorkspaceExecutionAuthority({
|
|
||||||
workspaces,
|
|
||||||
workspaceId: context.workspaceId,
|
|
||||||
});
|
|
||||||
const workspaceDirectory = workspaceAuthority.ok
|
const workspaceDirectory = workspaceAuthority.ok
|
||||||
? workspaceAuthority.authority.workspaceDirectory
|
? workspaceAuthority.authority.workspaceDirectory
|
||||||
: null;
|
: null;
|
||||||
@@ -63,8 +59,7 @@ function useTerminalPanelDescriptor(
|
|||||||
function TerminalPanel() {
|
function TerminalPanel() {
|
||||||
const isFocused = useIsFocused();
|
const isFocused = useIsFocused();
|
||||||
const { serverId, workspaceId, target, isPaneFocused } = usePaneContext();
|
const { serverId, workspaceId, target, isPaneFocused } = usePaneContext();
|
||||||
const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
|
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
|
||||||
const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId });
|
|
||||||
const workspaceDirectory = workspaceAuthority.ok
|
const workspaceDirectory = workspaceAuthority.ok
|
||||||
? workspaceAuthority.authority.workspaceDirectory
|
? workspaceAuthority.authority.workspaceDirectory
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
HostRuntimeController,
|
HostRuntimeController,
|
||||||
HostRuntimeStore,
|
HostRuntimeStore,
|
||||||
type HostRuntimeControllerDeps,
|
type HostRuntimeControllerDeps,
|
||||||
|
type HostRuntimeSnapshot,
|
||||||
} from "./host-runtime";
|
} from "./host-runtime";
|
||||||
|
|
||||||
class FakeDaemonClient {
|
class FakeDaemonClient {
|
||||||
@@ -264,6 +265,27 @@ function clearProbeBackoff(controller: HostRuntimeController): void {
|
|||||||
).connectionLastProbedAt.clear();
|
).connectionLastProbedAt.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>;
|
||||||
|
|
||||||
|
function updateControllerSnapshot(
|
||||||
|
controller: HostRuntimeController,
|
||||||
|
patch: HostRuntimeSnapshotPatch,
|
||||||
|
): void {
|
||||||
|
(
|
||||||
|
controller as unknown as {
|
||||||
|
updateSnapshot: (patch: HostRuntimeSnapshotPatch) => void;
|
||||||
|
}
|
||||||
|
).updateSnapshot(patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProbeMap(
|
||||||
|
entries: Array<
|
||||||
|
[string, HostRuntimeSnapshot["probeByConnectionId"] extends Map<string, infer T> ? T : never]
|
||||||
|
>,
|
||||||
|
): HostRuntimeSnapshot["probeByConnectionId"] {
|
||||||
|
return new Map(entries);
|
||||||
|
}
|
||||||
|
|
||||||
describe("HostRuntimeController", () => {
|
describe("HostRuntimeController", () => {
|
||||||
it("keeps known hosts in connecting when a created client reports idle during connect", async () => {
|
it("keeps known hosts in connecting when a created client reports idle during connect", async () => {
|
||||||
const host = makeHost({
|
const host = makeHost({
|
||||||
@@ -923,6 +945,134 @@ describe("HostRuntimeController", () => {
|
|||||||
expect(controller.getSnapshot().clientGeneration).toBe(generationBeforeProbes);
|
expect(controller.getSnapshot().clientGeneration).toBe(generationBeforeProbes);
|
||||||
expect(createdClients).toHaveLength(0);
|
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", () => {
|
describe("HostRuntimeStore", () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useSyncExternalStore, useCallback, useMemo } from "react";
|
import { useSyncExternalStore, useCallback, useMemo } from "react";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import equal from "fast-deep-equal/es6";
|
||||||
import {
|
import {
|
||||||
DaemonClient,
|
DaemonClient,
|
||||||
type ConnectionState,
|
type ConnectionState,
|
||||||
@@ -67,6 +68,16 @@ export type HostRuntimeSnapshot = {
|
|||||||
clientGeneration: number;
|
clientGeneration: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type HostRuntimeSnapshotPatch = Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>;
|
||||||
|
|
||||||
|
function setSnapshotPatchField<Key extends keyof HostRuntimeSnapshotPatch>(
|
||||||
|
patch: HostRuntimeSnapshotPatch,
|
||||||
|
key: Key,
|
||||||
|
value: HostRuntimeSnapshot[Key],
|
||||||
|
): void {
|
||||||
|
patch[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
export function isHostRuntimeConnected(snapshot: HostRuntimeSnapshot | null): boolean {
|
export function isHostRuntimeConnected(snapshot: HostRuntimeSnapshot | null): boolean {
|
||||||
return snapshot?.connectionStatus === "online";
|
return snapshot?.connectionStatus === "online";
|
||||||
}
|
}
|
||||||
@@ -831,15 +842,25 @@ export class HostRuntimeController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateSnapshot(
|
private updateSnapshot(patch: HostRuntimeSnapshotPatch): void {
|
||||||
patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">>,
|
const preservedPatch: HostRuntimeSnapshotPatch = { ...patch };
|
||||||
): void {
|
let hasChanged = this.host.serverId !== this.snapshot.serverId;
|
||||||
const next: HostRuntimeSnapshot = {
|
for (const key of Object.keys(patch) as Array<keyof HostRuntimeSnapshotPatch>) {
|
||||||
|
const incomingValue = patch[key];
|
||||||
|
if (equal(this.snapshot[key], incomingValue)) {
|
||||||
|
setSnapshotPatchField(preservedPatch, key, this.snapshot[key]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hasChanged = true;
|
||||||
|
}
|
||||||
|
if (!hasChanged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.snapshot = {
|
||||||
...this.snapshot,
|
...this.snapshot,
|
||||||
...patch,
|
...preservedPatch,
|
||||||
serverId: this.host.serverId,
|
serverId: this.host.serverId,
|
||||||
};
|
};
|
||||||
this.snapshot = next;
|
|
||||||
for (const listener of this.listeners) {
|
for (const listener of this.listeners) {
|
||||||
listener();
|
listener();
|
||||||
}
|
}
|
||||||
@@ -1021,7 +1042,7 @@ export class HostRuntimeController {
|
|||||||
state,
|
state,
|
||||||
lastError: client.lastError,
|
lastError: client.lastError,
|
||||||
});
|
});
|
||||||
const patch: Partial<Omit<HostRuntimeSnapshot, "serverId" | "clientGeneration">> = {
|
const patch: HostRuntimeSnapshotPatch = {
|
||||||
...toSnapshotConnectionPatch(this.connectionMachineState),
|
...toSnapshotConnectionPatch(this.connectionMachineState),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { MenuHeader } from "@/components/headers/menu-header";
|
|||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { useHasWorkspaces } from "@/stores/session-store-hooks";
|
||||||
import {
|
import {
|
||||||
useIsCompactFormFactor,
|
useIsCompactFormFactor,
|
||||||
HEADER_INNER_HEIGHT,
|
HEADER_INNER_HEIGHT,
|
||||||
@@ -22,7 +23,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
|||||||
const openAgentList = usePanelStore((s) => s.openAgentList);
|
const openAgentList = usePanelStore((s) => s.openAgentList);
|
||||||
const openProjectPicker = useOpenProjectPicker(serverId);
|
const openProjectPicker = useOpenProjectPicker(serverId);
|
||||||
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
|
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 isLocalDaemon = useIsLocalDaemon(serverId);
|
||||||
const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false);
|
const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
|||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
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 { encodeImages } from "@/utils/encode-images";
|
||||||
import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
|
||||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
||||||
@@ -50,9 +50,8 @@ export function WorkspaceDraftAgentTab({
|
|||||||
}: WorkspaceDraftAgentTabProps) {
|
}: WorkspaceDraftAgentTabProps) {
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
|
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId);
|
||||||
const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId });
|
const workspaceExecutionAuthority = workspaceAuthority?.ok ? workspaceAuthority.authority : null;
|
||||||
const workspaceExecutionAuthority = workspaceAuthority.ok ? workspaceAuthority.authority : null;
|
|
||||||
const workspaceDirectory = workspaceExecutionAuthority?.workspaceDirectory ?? null;
|
const workspaceDirectory = workspaceExecutionAuthority?.workspaceDirectory ?? null;
|
||||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||||
const draftStoreKey = useMemo(
|
const draftStoreKey = useMemo(
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import {
|
|||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
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 { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
|
||||||
import {
|
import {
|
||||||
checkoutStatusQueryKey,
|
checkoutStatusQueryKey,
|
||||||
@@ -79,7 +80,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
|||||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||||
import { generateDraftId } from "@/stores/draft-keys";
|
import { generateDraftId } from "@/stores/draft-keys";
|
||||||
import {
|
import {
|
||||||
resolveWorkspaceExecutionAuthority,
|
getWorkspaceExecutionAuthority,
|
||||||
resolveWorkspaceRouteId,
|
resolveWorkspaceRouteId,
|
||||||
} from "@/utils/workspace-execution";
|
} from "@/utils/workspace-execution";
|
||||||
import {
|
import {
|
||||||
@@ -617,9 +618,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
resolveWorkspaceRouteId({
|
resolveWorkspaceRouteId({
|
||||||
routeWorkspaceId: workspaceId,
|
routeWorkspaceId: workspaceId,
|
||||||
}) ?? "";
|
}) ?? "";
|
||||||
const sessionWorkspaces = useSessionStore(
|
const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId);
|
||||||
(state) => state.sessions[normalizedServerId]?.workspaces,
|
|
||||||
);
|
|
||||||
|
|
||||||
const workspaceTerminalScopeKey =
|
const workspaceTerminalScopeKey =
|
||||||
normalizedServerId && normalizedWorkspaceId
|
normalizedServerId && normalizedWorkspaceId
|
||||||
@@ -632,17 +631,18 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const client = useHostRuntimeClient(normalizedServerId);
|
const client = useHostRuntimeClient(normalizedServerId);
|
||||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||||
const workspaceDescriptor = sessionWorkspaces?.get(normalizedWorkspaceId) ?? null;
|
|
||||||
const workspaceAuthority = useMemo(
|
const workspaceAuthority = useMemo(
|
||||||
() =>
|
() =>
|
||||||
resolveWorkspaceExecutionAuthority({
|
getWorkspaceExecutionAuthority({
|
||||||
workspaces: sessionWorkspaces,
|
workspace: workspaceDescriptor,
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
}),
|
}),
|
||||||
[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
|
// 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.
|
// picker is ready when opened. Consumers share the same query cache key.
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.hoisted(() => {
|
||||||
|
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||||
|
});
|
||||||
|
|
||||||
import {
|
import {
|
||||||
resolveWorkspaceHeader,
|
resolveWorkspaceHeader,
|
||||||
shouldRenderMissingWorkspaceDescriptor,
|
shouldRenderMissingWorkspaceDescriptor,
|
||||||
} from "./workspace-header-source";
|
} 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";
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
|
|
||||||
describe("workspace source of truth consumption", () => {
|
describe("workspace source of truth consumption", () => {
|
||||||
@@ -23,17 +28,15 @@ describe("workspace source of truth consumption", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const header = resolveWorkspaceHeader({ workspace });
|
const header = resolveWorkspaceHeader({ workspace });
|
||||||
const sidebarProjects = buildSidebarProjectsFromWorkspaces({
|
const sidebarWorkspace = createSidebarWorkspaceEntry({
|
||||||
serverId: "srv",
|
serverId: "srv",
|
||||||
workspaces: [workspace],
|
workspace,
|
||||||
projectOrder: [],
|
|
||||||
workspaceOrderByScope: {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(header.title).toBe("feat/workspace-sot");
|
expect(header.title).toBe("feat/workspace-sot");
|
||||||
expect(header.subtitle).toBe("getpaseo/paseo");
|
expect(header.subtitle).toBe("getpaseo/paseo");
|
||||||
expect(sidebarProjects[0]?.workspaces[0]?.name).toBe(header.title);
|
expect(sidebarWorkspace.name).toBe(header.title);
|
||||||
expect(sidebarProjects[0]?.workspaces[0]?.statusBucket).toBe("running");
|
expect(sidebarWorkspace.statusBucket).toBe("running");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders explicit missing state only after workspace hydration", () => {
|
it("renders explicit missing state only after workspace hydration", () => {
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
import {
|
||||||
|
useHasWorkspaces,
|
||||||
|
useRecommendedProjectPaths,
|
||||||
|
useResolveWorkspaceIdByCwd,
|
||||||
|
useWorkspace,
|
||||||
|
useWorkspaceExecutionAuthority,
|
||||||
|
useWorkspaceFields,
|
||||||
|
useWorkspaceKeys,
|
||||||
|
useWorkspaceStatusesForBadges,
|
||||||
|
useWorkspaceStructure,
|
||||||
|
} from "./session-store-hooks";
|
||||||
|
import { useSidebarOrderStore } from "./sidebar-order-store";
|
||||||
|
import { useSessionStore, type WorkspaceDescriptor } from "./session-store";
|
||||||
|
|
||||||
|
const SERVER_ID = "test-server";
|
||||||
|
|
||||||
|
function createWorkspace(
|
||||||
|
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||||
|
): WorkspaceDescriptor {
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
projectId: input.projectId ?? "project-1",
|
||||||
|
projectDisplayName: input.projectDisplayName ?? "Project 1",
|
||||||
|
projectRootPath: input.projectRootPath ?? "/repo",
|
||||||
|
workspaceDirectory: input.workspaceDirectory ?? "/repo",
|
||||||
|
projectKind: input.projectKind ?? "git",
|
||||||
|
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||||
|
name: input.name ?? "main",
|
||||||
|
status: input.status ?? "done",
|
||||||
|
diffStat: input.diffStat ?? null,
|
||||||
|
scripts: input.scripts ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeWorkspaces(workspaces: WorkspaceDescriptor[]): void {
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||||
|
useSessionStore
|
||||||
|
.getState()
|
||||||
|
.setWorkspaces(SERVER_ID, new Map(workspaces.map((workspace) => [workspace.id, workspace])));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().clearSession(SERVER_ID);
|
||||||
|
useSidebarOrderStore.setState({
|
||||||
|
projectOrderByServerId: {},
|
||||||
|
workspaceOrderByServerAndProject: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useWorkspace", () => {
|
||||||
|
it("resolves a descriptor when the route id matches workspace identity but not the map key", () => {
|
||||||
|
const workspace = createWorkspace({ id: "workspace-a" });
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||||
|
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map([["map-key-a", workspace]]));
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useWorkspace(SERVER_ID, workspace.id));
|
||||||
|
|
||||||
|
expect(result.current).toBe(workspace);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the descriptor reference for unrelated workspace updates", () => {
|
||||||
|
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
|
||||||
|
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
|
||||||
|
initializeWorkspaces([workspaceA, workspaceB]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useWorkspace(SERVER_ID, workspaceA.id));
|
||||||
|
const before = result.current;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
|
||||||
|
});
|
||||||
|
expect(result.current).toBe(before);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore
|
||||||
|
.getState()
|
||||||
|
.mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "attention" }]);
|
||||||
|
});
|
||||||
|
expect(result.current).not.toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the descriptor reference when the observed workspace is rewritten with content-equal data", () => {
|
||||||
|
const workspace = createWorkspace({ id: "workspace-a", scripts: [] });
|
||||||
|
initializeWorkspaces([workspace]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useWorkspace(SERVER_ID, workspace.id));
|
||||||
|
const before = result.current;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore
|
||||||
|
.getState()
|
||||||
|
.setWorkspaces(SERVER_ID, new Map([[workspace.id, { ...workspace, scripts: [] }]]));
|
||||||
|
});
|
||||||
|
expect(result.current).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useWorkspaceExecutionAuthority", () => {
|
||||||
|
it("preserves the old missing-workspace message with the requested id", () => {
|
||||||
|
initializeWorkspaces([]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useWorkspaceExecutionAuthority(SERVER_ID, "missing-id"));
|
||||||
|
|
||||||
|
expect(result.current).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
message: "Workspace not found: missing-id",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps deep-equal authority references under unrelated workspace updates", () => {
|
||||||
|
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
|
||||||
|
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
|
||||||
|
initializeWorkspaces([workspaceA, workspaceB]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useWorkspaceExecutionAuthority(SERVER_ID, workspaceA.id));
|
||||||
|
const before = result.current;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useWorkspaceFields", () => {
|
||||||
|
it("keeps deep-equal projection references until projected fields change", () => {
|
||||||
|
const workspace = createWorkspace({ id: "workspace-a", name: "A", status: "done" });
|
||||||
|
initializeWorkspaces([workspace]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useWorkspaceFields(SERVER_ID, workspace.id, (current) => ({
|
||||||
|
identity: {
|
||||||
|
id: current.id,
|
||||||
|
name: current.name,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const before = result.current;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]);
|
||||||
|
});
|
||||||
|
expect(result.current).toBe(before);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
|
||||||
|
{
|
||||||
|
...workspace,
|
||||||
|
name: "A renamed",
|
||||||
|
status: "running",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
expect(result.current).not.toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useWorkspaceStructure", () => {
|
||||||
|
it("changes for membership updates but not status-only updates", () => {
|
||||||
|
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
|
||||||
|
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
|
||||||
|
initializeWorkspaces([workspaceA]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID));
|
||||||
|
const before = result.current;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]);
|
||||||
|
});
|
||||||
|
const afterAdd = result.current;
|
||||||
|
expect(afterAdd).not.toBe(before);
|
||||||
|
expect(afterAdd.projects[0]?.workspaceKeys).toEqual(["workspace-a", "workspace-b"]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]);
|
||||||
|
});
|
||||||
|
expect(result.current).toBe(afterAdd);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when a structure-relevant project identity field changes", () => {
|
||||||
|
const workspace = createWorkspace({
|
||||||
|
id: "workspace-a",
|
||||||
|
projectDisplayName: "Project 1",
|
||||||
|
});
|
||||||
|
initializeWorkspaces([workspace]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID));
|
||||||
|
const before = result.current;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore
|
||||||
|
.getState()
|
||||||
|
.mergeWorkspaces(SERVER_ID, [{ ...workspace, projectDisplayName: "Project Renamed" }]);
|
||||||
|
});
|
||||||
|
expect(result.current).not.toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes when persisted sidebar project order changes", () => {
|
||||||
|
const workspaceA = createWorkspace({
|
||||||
|
id: "workspace-a",
|
||||||
|
projectId: "project-a",
|
||||||
|
projectDisplayName: "Project A",
|
||||||
|
});
|
||||||
|
const workspaceB = createWorkspace({
|
||||||
|
id: "workspace-b",
|
||||||
|
projectId: "project-b",
|
||||||
|
projectDisplayName: "Project B",
|
||||||
|
});
|
||||||
|
initializeWorkspaces([workspaceA, workspaceB]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useWorkspaceStructure(SERVER_ID));
|
||||||
|
const before = result.current;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSidebarOrderStore.getState().setProjectOrder(SERVER_ID, ["project-b", "project-a"]);
|
||||||
|
});
|
||||||
|
expect(result.current).not.toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useWorkspaceKeys", () => {
|
||||||
|
it("changes for reorder updates but not content-only updates", () => {
|
||||||
|
const workspaceA = createWorkspace({ id: "workspace-a", name: "A" });
|
||||||
|
const workspaceB = createWorkspace({ id: "workspace-b", name: "B" });
|
||||||
|
initializeWorkspaces([workspaceA, workspaceB]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useWorkspaceKeys(SERVER_ID));
|
||||||
|
const before = result.current;
|
||||||
|
expect(before).toEqual(["workspace-a", "workspace-b"]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().setWorkspaces(
|
||||||
|
SERVER_ID,
|
||||||
|
new Map([
|
||||||
|
[workspaceB.id, workspaceB],
|
||||||
|
[workspaceA.id, workspaceA],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const afterReorder = result.current;
|
||||||
|
expect(afterReorder).not.toBe(before);
|
||||||
|
expect(afterReorder).toEqual(["workspace-b", "workspace-a"]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "running" }]);
|
||||||
|
});
|
||||||
|
expect(result.current).toBe(afterReorder);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useRecommendedProjectPaths", () => {
|
||||||
|
it("updates when an existing workspace project root changes", () => {
|
||||||
|
const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" });
|
||||||
|
initializeWorkspaces([workspace]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRecommendedProjectPaths(SERVER_ID));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore
|
||||||
|
.getState()
|
||||||
|
.mergeWorkspaces(SERVER_ID, [{ ...workspace, projectRootPath: "/repo/b" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toEqual(["/repo/b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the path list reference under unrelated workspace updates", () => {
|
||||||
|
const workspace = createWorkspace({ id: "workspace-a", projectRootPath: "/repo/a" });
|
||||||
|
initializeWorkspaces([workspace]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRecommendedProjectPaths(SERVER_ID));
|
||||||
|
const before = result.current;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspace, status: "running" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useHasWorkspaces", () => {
|
||||||
|
it("stays stable when workspace membership changes without flipping the boolean", () => {
|
||||||
|
const workspaceA = createWorkspace({ id: "workspace-a" });
|
||||||
|
const workspaceB = createWorkspace({ id: "workspace-b" });
|
||||||
|
initializeWorkspaces([workspaceA]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useHasWorkspaces(SERVER_ID));
|
||||||
|
const before = result.current;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspaceB]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useResolveWorkspaceIdByCwd", () => {
|
||||||
|
it("resolves by cwd and stays stable under unrelated updates", () => {
|
||||||
|
const workspaceA = createWorkspace({
|
||||||
|
id: "workspace-a",
|
||||||
|
workspaceDirectory: "/repo/a",
|
||||||
|
});
|
||||||
|
const workspaceB = createWorkspace({
|
||||||
|
id: "workspace-b",
|
||||||
|
workspaceDirectory: "/repo/b",
|
||||||
|
});
|
||||||
|
initializeWorkspaces([workspaceA, workspaceB]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useResolveWorkspaceIdByCwd(SERVER_ID, "/repo/a"));
|
||||||
|
const before = result.current;
|
||||||
|
expect(before).toBe("workspace-a");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
|
||||||
|
});
|
||||||
|
expect(result.current).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useWorkspaceStatusesForBadges", () => {
|
||||||
|
it("tracks status changes without changing for no-ops or unrelated descriptor updates", () => {
|
||||||
|
const workspaceA = createWorkspace({ id: "workspace-a", status: "done" });
|
||||||
|
const workspaceB = createWorkspace({ id: "workspace-b", status: "attention" });
|
||||||
|
initializeWorkspaces([workspaceA, workspaceB]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useWorkspaceStatusesForBadges());
|
||||||
|
const before = result.current;
|
||||||
|
expect(before).toEqual(["done", "attention"]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore
|
||||||
|
.getState()
|
||||||
|
.mergeWorkspaces(SERVER_ID, [{ ...workspaceA, scripts: [...workspaceA.scripts] }]);
|
||||||
|
});
|
||||||
|
expect(result.current).toBe(before);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, name: "Renamed" }]);
|
||||||
|
});
|
||||||
|
expect(result.current).toBe(before);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceA, status: "failed" }]);
|
||||||
|
});
|
||||||
|
expect(result.current).not.toBe(before);
|
||||||
|
expect(result.current).toEqual(["failed", "attention"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import equal from "fast-deep-equal";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
|
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||||
|
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
||||||
|
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||||
|
import {
|
||||||
|
getWorkspaceExecutionAuthority,
|
||||||
|
resolveWorkspaceIdByExecutionDirectory,
|
||||||
|
resolveWorkspaceMapKeyByIdentity,
|
||||||
|
type WorkspaceExecutionAuthorityResult,
|
||||||
|
} from "@/utils/workspace-execution";
|
||||||
|
import { useSessionStore, type WorkspaceDescriptor } from "./session-store";
|
||||||
|
|
||||||
|
// These are the ONLY supported ways to read workspaces from the session store.
|
||||||
|
// Do not write raw `useSessionStore` selectors that return the workspaces Map, a session object,
|
||||||
|
// or the sessions dict — it breaks re-render isolation.
|
||||||
|
|
||||||
|
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||||
|
|
||||||
|
export interface WorkspaceStructureProject {
|
||||||
|
projectKey: string;
|
||||||
|
projectName: string;
|
||||||
|
projectKind: WorkspaceDescriptor["projectKind"];
|
||||||
|
iconWorkingDir: string;
|
||||||
|
workspaceKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceStructure {
|
||||||
|
projects: WorkspaceStructureProject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionStoreSnapshot = ReturnType<typeof useSessionStore.getState>;
|
||||||
|
|
||||||
|
const EMPTY_WORKSPACE_KEYS: string[] = [];
|
||||||
|
const EMPTY_WORKSPACE_STRUCTURE: WorkspaceStructure = { projects: [] };
|
||||||
|
|
||||||
|
function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string {
|
||||||
|
return `${serverId.trim()}::${projectKey.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareWorkspaceStructureItems(
|
||||||
|
left: { workspaceId: string; workspaceName: string },
|
||||||
|
right: { workspaceId: string; workspaceName: string },
|
||||||
|
): number {
|
||||||
|
const nameDelta = left.workspaceName.localeCompare(right.workspaceName, undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: "base",
|
||||||
|
});
|
||||||
|
if (nameDelta !== 0) {
|
||||||
|
return nameDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.workspaceId.localeCompare(right.workspaceId, undefined, {
|
||||||
|
sensitivity: "base",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareWorkspaceStructureProjects(
|
||||||
|
left: WorkspaceStructureProject,
|
||||||
|
right: WorkspaceStructureProject,
|
||||||
|
): number {
|
||||||
|
return left.projectName.localeCompare(right.projectName, undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: "base",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStoredOrdering<T>(input: {
|
||||||
|
items: T[];
|
||||||
|
storedOrder: readonly string[];
|
||||||
|
getKey: (item: T) => string;
|
||||||
|
}): T[] {
|
||||||
|
if (input.items.length <= 1 || input.storedOrder.length === 0) {
|
||||||
|
return input.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemByKey = new Map<string, T>();
|
||||||
|
for (const item of input.items) {
|
||||||
|
itemByKey.set(input.getKey(item), item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prunedOrder: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const key of input.storedOrder) {
|
||||||
|
if (!itemByKey.has(key) || seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
prunedOrder.push(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prunedOrder.length === 0) {
|
||||||
|
return input.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedSet = new Set(prunedOrder);
|
||||||
|
const ordered: T[] = [];
|
||||||
|
let orderedIndex = 0;
|
||||||
|
|
||||||
|
for (const item of input.items) {
|
||||||
|
const key = input.getKey(item);
|
||||||
|
if (!orderedSet.has(key)) {
|
||||||
|
ordered.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetKey = prunedOrder[orderedIndex] ?? key;
|
||||||
|
orderedIndex += 1;
|
||||||
|
ordered.push(itemByKey.get(targetKey) ?? item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectWorkspace(
|
||||||
|
state: SessionStoreSnapshot,
|
||||||
|
serverId: string | null,
|
||||||
|
workspaceId: string | null,
|
||||||
|
): WorkspaceDescriptor | null {
|
||||||
|
if (!serverId || !workspaceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const workspaces = state.sessions[serverId]?.workspaces;
|
||||||
|
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
||||||
|
workspaces,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
return workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspace(
|
||||||
|
serverId: string | null,
|
||||||
|
workspaceId: string | null,
|
||||||
|
): WorkspaceDescriptor | null {
|
||||||
|
return useStoreWithEqualityFn(
|
||||||
|
useSessionStore,
|
||||||
|
(state) => selectWorkspace(state, serverId, workspaceId),
|
||||||
|
Object.is,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspaceFields<T>(
|
||||||
|
serverId: string | null,
|
||||||
|
workspaceId: string | null,
|
||||||
|
project: (w: WorkspaceDescriptor) => T,
|
||||||
|
): T | null {
|
||||||
|
return useStoreWithEqualityFn(
|
||||||
|
useSessionStore,
|
||||||
|
(state) => {
|
||||||
|
const workspace = selectWorkspace(state, serverId, workspaceId);
|
||||||
|
return workspace ? project(workspace) : null;
|
||||||
|
},
|
||||||
|
equal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspaceExecutionAuthority(
|
||||||
|
serverId: string | null,
|
||||||
|
workspaceId: string | null,
|
||||||
|
): WorkspaceExecutionAuthorityResult | null {
|
||||||
|
return useStoreWithEqualityFn(
|
||||||
|
useSessionStore,
|
||||||
|
(state) => {
|
||||||
|
if (serverId === null || workspaceId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getWorkspaceExecutionAuthority({
|
||||||
|
workspaces: state.sessions[serverId]?.workspaces,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
equal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectWorkspaceStructureProjects(
|
||||||
|
state: SessionStoreSnapshot,
|
||||||
|
serverId: string | null,
|
||||||
|
): WorkspaceStructureProject[] {
|
||||||
|
if (!serverId) {
|
||||||
|
return EMPTY_WORKSPACE_STRUCTURE.projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaces = state.sessions[serverId]?.workspaces;
|
||||||
|
if (!workspaces || workspaces.size === 0) {
|
||||||
|
return EMPTY_WORKSPACE_STRUCTURE.projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byProject = new Map<
|
||||||
|
string,
|
||||||
|
WorkspaceStructureProject & {
|
||||||
|
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const workspace of workspaces.values()) {
|
||||||
|
const project =
|
||||||
|
byProject.get(workspace.projectId) ??
|
||||||
|
({
|
||||||
|
projectKey: workspace.projectId,
|
||||||
|
projectName:
|
||||||
|
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
|
||||||
|
projectKind: workspace.projectKind,
|
||||||
|
iconWorkingDir: workspace.projectRootPath,
|
||||||
|
workspaceKeys: [],
|
||||||
|
workspaces: [],
|
||||||
|
} satisfies WorkspaceStructureProject & {
|
||||||
|
workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>;
|
||||||
|
});
|
||||||
|
|
||||||
|
project.workspaces.push({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
workspaceName: workspace.name,
|
||||||
|
workspaceKey: `${serverId}:${workspace.id}`,
|
||||||
|
});
|
||||||
|
byProject.set(workspace.projectId, project);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = Array.from(byProject.values()).map(
|
||||||
|
({ workspaces: projectWorkspaces, ...project }) => {
|
||||||
|
const sortedWorkspaces = [...projectWorkspaces].sort(compareWorkspaceStructureItems);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
workspaceKeys: sortedWorkspaces.map((workspace) => workspace.workspaceId),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
projects.sort(compareWorkspaceStructureProjects);
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectWorkspaceOrderByScopeForServer(
|
||||||
|
workspaceOrderByScope: Record<string, string[]>,
|
||||||
|
serverId: string | null,
|
||||||
|
): Record<string, string[]> {
|
||||||
|
if (!serverId) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const prefix = `${serverId.trim()}::`;
|
||||||
|
const relevantOrderByScope: Record<string, string[]> = {};
|
||||||
|
for (const [scopeKey, order] of Object.entries(workspaceOrderByScope)) {
|
||||||
|
if (scopeKey.startsWith(prefix)) {
|
||||||
|
relevantOrderByScope[scopeKey] = order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return relevantOrderByScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspaceStructure(serverId: string | null): WorkspaceStructure {
|
||||||
|
const projects = useStoreWithEqualityFn(
|
||||||
|
useSessionStore,
|
||||||
|
(state) => selectWorkspaceStructureProjects(state, serverId),
|
||||||
|
equal,
|
||||||
|
);
|
||||||
|
const projectOrder = useStoreWithEqualityFn(
|
||||||
|
useSidebarOrderStore,
|
||||||
|
(state) =>
|
||||||
|
serverId
|
||||||
|
? (state.projectOrderByServerId[serverId] ?? EMPTY_WORKSPACE_KEYS)
|
||||||
|
: EMPTY_WORKSPACE_KEYS,
|
||||||
|
equal,
|
||||||
|
);
|
||||||
|
const workspaceOrderByScope = useStoreWithEqualityFn(
|
||||||
|
useSidebarOrderStore,
|
||||||
|
(state) =>
|
||||||
|
selectWorkspaceOrderByScopeForServer(state.workspaceOrderByServerAndProject, serverId),
|
||||||
|
equal,
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!serverId || projects.length === 0) {
|
||||||
|
return EMPTY_WORKSPACE_STRUCTURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedProjects = applyStoredOrdering({
|
||||||
|
items: projects.map((project) => {
|
||||||
|
const workspaceOrder =
|
||||||
|
workspaceOrderByScope[getWorkspaceOrderScopeKey(serverId, project.projectKey)] ??
|
||||||
|
EMPTY_WORKSPACE_KEYS;
|
||||||
|
const workspaceItems = project.workspaceKeys.map((workspaceId) => ({
|
||||||
|
workspaceId,
|
||||||
|
workspaceKey: `${serverId}:${workspaceId}`,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
workspaceKeys: applyStoredOrdering({
|
||||||
|
items: workspaceItems,
|
||||||
|
storedOrder: workspaceOrder,
|
||||||
|
getKey: (workspace) => workspace.workspaceKey,
|
||||||
|
}).map((workspace) => workspace.workspaceId),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
storedOrder: projectOrder,
|
||||||
|
getKey: (project) => project.projectKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { projects: orderedProjects };
|
||||||
|
}, [projectOrder, projects, serverId, workspaceOrderByScope]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspaceKeys(serverId: string | null): string[] {
|
||||||
|
return useStoreWithEqualityFn(
|
||||||
|
useSessionStore,
|
||||||
|
(state) => {
|
||||||
|
if (!serverId) {
|
||||||
|
return EMPTY_WORKSPACE_KEYS;
|
||||||
|
}
|
||||||
|
const workspaces = state.sessions[serverId]?.workspaces;
|
||||||
|
return workspaces ? Array.from(workspaces.keys()) : EMPTY_WORKSPACE_KEYS;
|
||||||
|
},
|
||||||
|
equal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRecommendedProjectPaths(serverId: string | null): string[] {
|
||||||
|
return useStoreWithEqualityFn(
|
||||||
|
useSessionStore,
|
||||||
|
(state) => {
|
||||||
|
if (!serverId) {
|
||||||
|
return EMPTY_WORKSPACE_KEYS;
|
||||||
|
}
|
||||||
|
const workspaces = state.sessions[serverId]?.workspaces;
|
||||||
|
if (!workspaces) {
|
||||||
|
return EMPTY_WORKSPACE_KEYS;
|
||||||
|
}
|
||||||
|
return Array.from(workspaces.values())
|
||||||
|
.map((workspace) => workspace.projectRootPath)
|
||||||
|
.filter((path) => path.length > 0);
|
||||||
|
},
|
||||||
|
equal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHasWorkspaces(serverId: string | null): boolean {
|
||||||
|
return useStoreWithEqualityFn(
|
||||||
|
useSessionStore,
|
||||||
|
(state) => {
|
||||||
|
if (!serverId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (state.sessions[serverId]?.workspaces?.size ?? 0) > 0;
|
||||||
|
},
|
||||||
|
Object.is,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResolveWorkspaceIdByCwd(
|
||||||
|
serverId: string | null,
|
||||||
|
cwd: string | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
return useStoreWithEqualityFn(
|
||||||
|
useSessionStore,
|
||||||
|
(state) => {
|
||||||
|
if (!serverId || !cwd) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const workspaces = state.sessions[serverId]?.workspaces;
|
||||||
|
return resolveWorkspaceIdByExecutionDirectory({
|
||||||
|
workspaces: workspaces?.values(),
|
||||||
|
workspaceDirectory: cwd,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
Object.is,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] {
|
||||||
|
return useStoreWithEqualityFn(
|
||||||
|
useSessionStore,
|
||||||
|
(state) => {
|
||||||
|
const statuses: DesktopBadgeWorkspaceStatus[] = [];
|
||||||
|
for (const session of Object.values(state.sessions)) {
|
||||||
|
for (const workspace of session.workspaces.values()) {
|
||||||
|
statuses.push(workspace.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return statuses;
|
||||||
|
},
|
||||||
|
equal,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
useSessionStore,
|
useSessionStore,
|
||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
} from "./session-store";
|
} from "./session-store";
|
||||||
|
import { patchWorkspaceScripts } from "../contexts/session-workspace-scripts";
|
||||||
|
|
||||||
function createWorkspace(
|
function createWorkspace(
|
||||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||||
@@ -32,6 +33,23 @@ afterEach(() => {
|
|||||||
useSessionStore.getState().clearSession("test-server");
|
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", () => {
|
describe("normalizeWorkspaceDescriptor", () => {
|
||||||
it("normalizes workspace scripts and invalid activity timestamps", () => {
|
it("normalizes workspace scripts and invalid activity timestamps", () => {
|
||||||
const scripts = [
|
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", () => {
|
describe("mergeWorkspaceSnapshotWithExisting", () => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import equal from "fast-deep-equal";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { subscribeWithSelector } from "zustand/middleware";
|
import { subscribeWithSelector } from "zustand/middleware";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
@@ -159,6 +160,41 @@ export function mergeWorkspaceSnapshotWithExisting(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function preserveWorkspaceDescriptorIdentity(
|
||||||
|
incoming: WorkspaceDescriptor,
|
||||||
|
existing?: WorkspaceDescriptor | null,
|
||||||
|
): WorkspaceDescriptor {
|
||||||
|
if (existing && equal(existing, incoming)) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preserveWorkspaceMapIdentity(
|
||||||
|
existing: Map<string, WorkspaceDescriptor>,
|
||||||
|
incoming: Map<string, WorkspaceDescriptor>,
|
||||||
|
): Map<string, WorkspaceDescriptor> {
|
||||||
|
if (existing === incoming) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = new Map<string, WorkspaceDescriptor>();
|
||||||
|
let changed = existing.size !== incoming.size;
|
||||||
|
const existingEntries = existing.entries();
|
||||||
|
|
||||||
|
for (const [key, workspace] of incoming) {
|
||||||
|
const existingWorkspace = existing.get(key);
|
||||||
|
const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existingWorkspace);
|
||||||
|
next.set(key, nextWorkspace);
|
||||||
|
const existingEntry = existingEntries.next().value;
|
||||||
|
if (!existingEntry || existingEntry[0] !== key || existingEntry[1] !== nextWorkspace) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? next : existing;
|
||||||
|
}
|
||||||
|
|
||||||
export type ExplorerEntryKind = "file" | "directory";
|
export type ExplorerEntryKind = "file" | "directory";
|
||||||
export type ExplorerFileKind = "text" | "image" | "binary";
|
export type ExplorerFileKind = "text" | "image" | "binary";
|
||||||
export type ExplorerEncoding = "utf-8" | "base64" | "none";
|
export type ExplorerEncoding = "utf-8" | "base64" | "none";
|
||||||
@@ -938,14 +974,18 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
}
|
}
|
||||||
const nextWorkspaces =
|
const nextWorkspaces =
|
||||||
typeof workspaces === "function" ? workspaces(session.workspaces) : workspaces;
|
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;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
sessions: {
|
sessions: {
|
||||||
...prev.sessions,
|
...prev.sessions,
|
||||||
[serverId]: { ...session, workspaces: nextWorkspaces },
|
[serverId]: { ...session, workspaces: preservedWorkspaces },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -962,13 +1002,15 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
let changed = false;
|
let changed = false;
|
||||||
for (const workspace of nextEntries) {
|
for (const workspace of nextEntries) {
|
||||||
const existing = next.get(workspace.id);
|
const existing = next.get(workspace.id);
|
||||||
if (existing === workspace) {
|
const mergedWorkspace = mergeWorkspaceSnapshotWithExisting({
|
||||||
|
incoming: workspace,
|
||||||
|
existing,
|
||||||
|
});
|
||||||
|
const nextWorkspace = preserveWorkspaceDescriptorIdentity(mergedWorkspace, existing);
|
||||||
|
if (existing === nextWorkspace) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
next.set(
|
next.set(workspace.id, nextWorkspace);
|
||||||
workspace.id,
|
|
||||||
mergeWorkspaceSnapshotWithExisting({ incoming: workspace, existing }),
|
|
||||||
);
|
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
if (!changed) {
|
if (!changed) {
|
||||||
|
|||||||
@@ -31,9 +31,6 @@ function project(overrides: Partial<SidebarProjectEntry> = {}): SidebarProjectEn
|
|||||||
projectName: "paseo",
|
projectName: "paseo",
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
iconWorkingDir: "/repo",
|
iconWorkingDir: "/repo",
|
||||||
statusBucket: "done",
|
|
||||||
activeCount: 0,
|
|
||||||
totalWorkspaces: 1,
|
|
||||||
workspaces: [workspace()],
|
workspaces: [workspace()],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,9 +33,6 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
|
|||||||
projectName: projectKey,
|
projectName: projectKey,
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "",
|
iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "",
|
||||||
statusBucket: "done",
|
|
||||||
activeCount: 0,
|
|
||||||
totalWorkspaces: workspaces.length,
|
|
||||||
workspaces,
|
workspaces,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
type WorkspaceLike = {
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
projectDisplayName?: string;
|
|
||||||
name: string;
|
|
||||||
status: string;
|
|
||||||
workspaceKind: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SidebarWorkspaceLike = {
|
|
||||||
workspaceId: string;
|
|
||||||
name: string;
|
|
||||||
statusBucket: string;
|
|
||||||
workspaceKind: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SidebarProjectLike = {
|
|
||||||
projectKey: string;
|
|
||||||
projectName: string;
|
|
||||||
statusBucket: string;
|
|
||||||
activeCount: number;
|
|
||||||
totalWorkspaces: number;
|
|
||||||
workspaces: SidebarWorkspaceLike[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function countByValue(values: Iterable<string>): Record<string, number> {
|
|
||||||
const counts = new Map<string, number>();
|
|
||||||
for (const value of values) {
|
|
||||||
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
return Object.fromEntries(counts);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function summarizeWorkspaceCollection(
|
|
||||||
workspaces: Iterable<WorkspaceLike> | null | undefined,
|
|
||||||
): {
|
|
||||||
count: number;
|
|
||||||
projectIds: string[];
|
|
||||||
statusCounts: Record<string, number>;
|
|
||||||
workspaces: Array<{
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
projectDisplayName: string | null;
|
|
||||||
name: string;
|
|
||||||
status: string;
|
|
||||||
workspaceKind: string;
|
|
||||||
}>;
|
|
||||||
} {
|
|
||||||
const entries = Array.from(workspaces ?? [], (workspace) => ({
|
|
||||||
id: workspace.id,
|
|
||||||
projectId: workspace.projectId,
|
|
||||||
projectDisplayName: workspace.projectDisplayName?.trim() || null,
|
|
||||||
name: workspace.name,
|
|
||||||
status: workspace.status,
|
|
||||||
workspaceKind: workspace.workspaceKind,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
count: entries.length,
|
|
||||||
projectIds: [...new Set(entries.map((workspace) => workspace.projectId))],
|
|
||||||
statusCounts: countByValue(entries.map((workspace) => workspace.status)),
|
|
||||||
workspaces: entries,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function summarizeSidebarProjects(
|
|
||||||
projects: Iterable<SidebarProjectLike> | null | undefined,
|
|
||||||
): {
|
|
||||||
count: number;
|
|
||||||
projectKeys: string[];
|
|
||||||
projects: Array<{
|
|
||||||
projectKey: string;
|
|
||||||
projectName: string;
|
|
||||||
statusBucket: string;
|
|
||||||
activeCount: number;
|
|
||||||
totalWorkspaces: number;
|
|
||||||
workspaces: Array<{
|
|
||||||
workspaceId: string;
|
|
||||||
name: string;
|
|
||||||
statusBucket: string;
|
|
||||||
workspaceKind: string;
|
|
||||||
}>;
|
|
||||||
}>;
|
|
||||||
} {
|
|
||||||
const entries = Array.from(projects ?? [], (project) => ({
|
|
||||||
projectKey: project.projectKey,
|
|
||||||
projectName: project.projectName,
|
|
||||||
statusBucket: project.statusBucket,
|
|
||||||
activeCount: project.activeCount,
|
|
||||||
totalWorkspaces: project.totalWorkspaces,
|
|
||||||
workspaces: project.workspaces.map((workspace) => ({
|
|
||||||
workspaceId: workspace.workspaceId,
|
|
||||||
name: workspace.name,
|
|
||||||
statusBucket: workspace.statusBucket,
|
|
||||||
workspaceKind: workspace.workspaceKind,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
count: entries.length,
|
|
||||||
projectKeys: entries.map((project) => project.projectKey),
|
|
||||||
projects: entries,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user