From 4f03c23796b49973aada00a04257bb97ecd89ae3 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 10 Oct 2026 12:58:09 -0700 Subject: [PATCH] refactor(projects): repo, project group and folder catalog calls route by their owner (#27288) Catalog fetches, path-status probes, project and group mutations, nested scans and imports now take the owner's host explicitly instead of falling back to the Active Server setting. A new project with no picked host still goes to the default host, through defaultCreationHost. Co-authored-by: m4air --- config/focus-setting-read-baseline.txt | 27 +--- config/owner-routing-baseline.txt | 15 --- .../settings-project-section-renderer.tsx | 4 +- .../src/components/sidebar/AddRepoSteps.tsx | 6 +- .../sidebar/ProjectAddedDialog.test.tsx | 14 +- .../components/sidebar/ProjectAddedDialog.tsx | 7 +- .../sidebar/add-repo-runtime-owner.ts | 14 +- ...er-workspace-composer-path-status.test.tsx | 24 +++- .../folder-workspace-composer-path-status.ts | 7 +- .../use-worktree-context-menu-commands.ts | 15 ++- .../components/sidebar/useAddRepoCloneFlow.ts | 6 +- .../useAddRepoNestedImportFlow.test.ts | 4 +- .../sidebar/useAddRepoNestedImportFlow.ts | 6 +- .../useAddRepoNestedReviewController.ts | 4 +- .../sidebar/useAddRepoNestedReviewState.ts | 14 +- .../sidebar/useAddRepoServerPathFlow.ts | 6 +- .../src/components/sidebar/useCreateRepo.ts | 5 +- .../listing/use-folder-path-statuses.ts | 29 +++-- .../rows/use-project-group-dialogs.ts | 14 +- .../composer-state/composer-store-actions.ts | 2 +- .../ipc-events/project-catalog-ipc-bridge.ts | 8 +- .../workspace-activation-path-gate.test.ts | 2 +- .../folder-path-status-actions.ts | 16 +-- .../folder-workspace-catalog-actions.ts | 8 +- .../folder-workspace-mutations.ts | 36 ++---- .../folder-workspace-routing.ts | 37 ++++-- .../github/work-item-mutation-actions.ts | 2 +- .../nested-repository-operations.ts | 29 ++--- .../project-group-catalog-actions.ts | 8 +- .../project-groups/project-group-mutations.ts | 35 +++-- .../store/projects/project-host-routing.ts | 19 --- .../src/store/projects/project-update.ts | 7 +- .../repos/all-host-repo-catalog-actions.ts | 5 +- src/renderer/src/store/repos/owner-routing.ts | 11 -- .../src/store/repos/repo-add-actions.ts | 19 +-- .../src/store/repos/repo-catalog-actions.ts | 8 +- src/renderer/src/store/repos/repo-removal.ts | 13 +- src/renderer/src/store/repos/repo-state.ts | 45 ++++--- src/renderer/src/store/repos/repo-update.ts | 14 +- .../repos/runtime-repo-catalog-actions.ts | 4 +- ...r-workspace-owner-routed-mutations.test.ts | 18 ++- .../slices/project-group-owner-routing.ts | 36 ++---- .../src/store/slices/repos-add-races.test.ts | 11 +- .../slices/repos-all-hosts-generation.test.ts | 9 +- .../src/store/slices/repos-all-hosts.test.ts | 2 +- .../repos-catalog-merge-identity.test.ts | 53 ++++---- .../repos-catalog-notifications.test.ts | 13 +- .../repos-cross-host-refresh-identity.test.ts | 13 +- ...repos-module-lifetime-coordinators.test.ts | 11 +- ...pos-nested-import-refresh-failures.test.ts | 1 + .../repos-nested-ssh-projection.test.ts | 11 +- .../repos-project-group-create-race.test.ts | 38 ++++-- .../repos-project-groups-delete.test.ts | 5 +- ...repos-project-groups-owner-routing.test.ts | 6 +- .../store/slices/repos-project-groups.test.ts | 114 +++++++++-------- .../repos-project-host-capability.test.ts | 9 +- .../repos-project-host-lifecycle.test.ts | 9 +- .../slices/repos-project-runtime.test.ts | 121 +++++++++++++++--- .../slices/repos-refresh-identity.test.ts | 87 +++++++------ .../repos-runtime-project-groups.test.ts | 9 +- .../repos-selected-owner-routing.test.ts | 11 +- .../repos-setup-script-dismissals.test.ts | 9 +- .../store/slices/repos-stale-fetch.test.ts | 23 ++-- src/renderer/src/store/slices/repos.test.ts | 38 +++--- ...me-catalog-merge-removed-env-guard.test.ts | 9 +- src/renderer/src/store/slices/settings.ts | 3 +- 66 files changed, 692 insertions(+), 526 deletions(-) diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index e45e3582fd7..81b66780de2 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -219,7 +219,6 @@ 2 src/renderer/src/components/sidebar/WorktreeList.tsx 1 src/renderer/src/components/sidebar/WorktreeRowOpenInSubMenu.tsx 1 src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx -1 src/renderer/src/components/sidebar/add-repo-runtime-owner.ts 2 src/renderer/src/components/sidebar/delete-worktree-flow.ts 1 src/renderer/src/components/sidebar/linear-agent-skill-runtime.ts 2 src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts @@ -234,8 +233,6 @@ 1 src/renderer/src/components/sidebar/use-workspace-reference-details.ts 1 src/renderer/src/components/sidebar/use-worktree-card-controller.ts 1 src/renderer/src/components/sidebar/use-worktree-card-review-details.ts -1 src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts -1 src/renderer/src/components/sidebar/useCreateRepo.ts 2 src/renderer/src/components/sidebar/useSidebarProjectDrop.ts 1 src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts 2 src/renderer/src/components/sidebar/visible-worktrees.ts @@ -246,10 +243,8 @@ 1 src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx 1 src/renderer/src/components/sidebar/worktree-filter-visibility.ts 2 src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts -2 src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts 1 src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts 1 src/renderer/src/components/sidebar/worktree-list/rows/ProjectGroupDialogs.tsx -1 src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx 1 src/renderer/src/components/sidebar/worktree-visibility-host-target.ts 1 src/renderer/src/components/skills/SkillsPage.tsx 7 src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx @@ -484,29 +479,20 @@ 2 src/renderer/src/runtime/web-session-tabs-sync/global-session-subscription.ts 1 src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts 3 src/renderer/src/runtime/web-session-tabs-sync/use-web-session-tabs-sync.ts -2 src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts -2 src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts -6 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts -1 src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts +1 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts 1 src/renderer/src/store/github/repository-routing.ts 2 src/renderer/src/store/github/visible-hosted-review-refresh-targets.ts 2 src/renderer/src/store/github/work-item-aggregate-actions.ts 3 src/renderer/src/store/github/work-item-fetch-actions.ts 2 src/renderer/src/store/github/work-item-routing.ts 9 src/renderer/src/store/index.ts -6 src/renderer/src/store/project-groups/nested-repository-operations.ts -2 src/renderer/src/store/project-groups/project-group-catalog-actions.ts -10 src/renderer/src/store/project-groups/project-group-mutations.ts -1 src/renderer/src/store/projects/project-host-routing.ts -1 src/renderer/src/store/projects/project-update.ts -2 src/renderer/src/store/repos/all-host-repo-catalog-actions.ts +3 src/renderer/src/store/project-groups/project-group-mutations.ts +1 src/renderer/src/store/repos/all-host-repo-catalog-actions.ts 1 src/renderer/src/store/repos/converted-ssh-browser-pages.ts 2 src/renderer/src/store/repos/owner-routing.ts 2 src/renderer/src/store/repos/repo-add-actions.ts -2 src/renderer/src/store/repos/repo-catalog-actions.ts -2 src/renderer/src/store/repos/repo-removal.ts -3 src/renderer/src/store/repos/repo-update.ts -2 src/renderer/src/store/repos/runtime-repo-catalog-actions.ts +1 src/renderer/src/store/repos/repo-update.ts +1 src/renderer/src/store/repos/runtime-repo-catalog-actions.ts 2 src/renderer/src/store/repos/safe-auto-fork-sync.ts 1 src/renderer/src/store/slices/browser-slice-test-harness.ts 1 src/renderer/src/store/slices/browser/browser-close-actions.ts @@ -556,10 +542,9 @@ 9 src/renderer/src/store/slices/linear/linear-slice-status-actions.ts 6 src/renderer/src/store/slices/linear/linear-team-actions.ts 2 src/renderer/src/store/slices/preflight.ts -1 src/renderer/src/store/slices/project-group-owner-routing.ts 1 src/renderer/src/store/slices/repo-host-identity.ts 1 src/renderer/src/store/slices/repo-reorder-host-split.ts -10 src/renderer/src/store/slices/repos.ts +4 src/renderer/src/store/slices/repos.ts 1 src/renderer/src/store/slices/runtime-status.ts 2 src/renderer/src/store/slices/settings-owner-hydration-publication.ts 5 src/renderer/src/store/slices/settings.ts diff --git a/config/owner-routing-baseline.txt b/config/owner-routing-baseline.txt index 05f10d68f0d..15568aaff91 100644 --- a/config/owner-routing-baseline.txt +++ b/config/owner-routing-baseline.txt @@ -9,7 +9,6 @@ 1 src/renderer/src/components/settings/RepositoryGitHubAccountSection.tsx 1 src/renderer/src/components/settings/RepositoryIconPicker.tsx 1 src/renderer/src/components/setup-guide/setup-guide-progress-readiness.ts -1 src/renderer/src/components/sidebar/add-repo-runtime-owner.ts 1 src/renderer/src/hooks/composer-state/provider-runtime-sync.ts 1 src/renderer/src/hooks/composer-state/quick-creation-execution.ts 1 src/renderer/src/lib/github-pr-start-point.ts @@ -22,20 +21,6 @@ 9 src/renderer/src/runtime/runtime-git-working-tree-client.ts 3 src/renderer/src/runtime/runtime-hooks-client.ts 3 src/renderer/src/runtime/runtime-repo-client.ts -1 src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts -2 src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts -3 src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts -1 src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts -6 src/renderer/src/store/project-groups/nested-repository-operations.ts -2 src/renderer/src/store/project-groups/project-group-catalog-actions.ts -4 src/renderer/src/store/project-groups/project-group-mutations.ts -1 src/renderer/src/store/projects/project-host-routing.ts -1 src/renderer/src/store/repos/all-host-repo-catalog-actions.ts -2 src/renderer/src/store/repos/repo-add-actions.ts -2 src/renderer/src/store/repos/repo-catalog-actions.ts -1 src/renderer/src/store/repos/repo-removal.ts -1 src/renderer/src/store/repos/repo-update.ts -1 src/renderer/src/store/repos/runtime-repo-catalog-actions.ts 1 src/renderer/src/store/slices/diff-comment-persistence.ts 1 src/renderer/src/store/slices/preflight.ts 1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts diff --git a/src/renderer/src/components/settings/settings-project-section-renderer.tsx b/src/renderer/src/components/settings/settings-project-section-renderer.tsx index 40aeecf37a7..d6ad190b5e4 100644 --- a/src/renderer/src/components/settings/settings-project-section-renderer.tsx +++ b/src/renderer/src/components/settings/settings-project-section-renderer.tsx @@ -72,7 +72,9 @@ export function renderProjectSettingsSections(context: SettingsRenderContext): R wslAvailable={terminal.windowsTerminalCapabilities.wslAvailable} wslDistros={terminal.windowsTerminalCapabilities.wslDistros} wslCapabilitiesLoading={terminal.windowsTerminalCapabilities.isLoading} - updateProject={model.updateProject} + updateProject={(projectId, updates) => + model.updateProject(projectId, updates, getRepoExecutionHostId(repo)) + } /> ) : null} diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.tsx b/src/renderer/src/components/sidebar/AddRepoSteps.tsx index 248bcd87058..c17386073de 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.tsx +++ b/src/renderer/src/components/sidebar/AddRepoSteps.tsx @@ -23,11 +23,11 @@ export function useRemoteRepo( onGitRepoReady?: (repoId: string, executionHostId?: ExecutionHostId) => void | Promise, scanNestedRepos?: ( path: string, - connectionId?: string, - controls?: { + connectionId: string | undefined, + controls: { scanId?: string onProgress?: (scan: NestedRepoScanResult) => void - runtimeEnvironmentId?: string | null + runtimeEnvironmentId: string | null } ) => Promise, showNestedRepoReview?: ( diff --git a/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx b/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx index 8a9651aa6a8..b7df5f79ad8 100644 --- a/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx +++ b/src/renderer/src/components/sidebar/ProjectAddedDialog.test.tsx @@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({ closeModal: vi.fn(), repos: [] as Repo[], worktreesByRepo: {} as Record, - fetchRepos: vi.fn(), + fetchReposForAllHosts: vi.fn(), fetchWorktrees: vi.fn(), setHideDefaultBranchWorkspace: vi.fn() }, @@ -68,7 +68,7 @@ describe('ProjectAddedDialog', () => { mocks.state.modalData = { repoId: 'repo-1' } mocks.state.repos = [makeRepo()] mocks.state.worktreesByRepo = {} - mocks.state.fetchRepos.mockResolvedValue(undefined) + mocks.state.fetchReposForAllHosts.mockResolvedValue(undefined) mocks.state.fetchWorktrees.mockResolvedValue(true) }) @@ -166,7 +166,7 @@ describe('ProjectAddedDialog', () => { it('refreshes repos before closing a stale repo id', async () => { mocks.state.repos = [] - mocks.state.fetchRepos.mockImplementation(async () => { + mocks.state.fetchReposForAllHosts.mockImplementation(async () => { mocks.state.repos = [] }) const { default: ProjectAddedDialog } = await import('./ProjectAddedDialog') @@ -174,13 +174,13 @@ describe('ProjectAddedDialog', () => { renderToStaticMarkup() await flushPromises() - expect(mocks.state.fetchRepos).toHaveBeenCalledTimes(1) + expect(mocks.state.fetchReposForAllHosts).toHaveBeenCalledTimes(1) expect(mocks.state.closeModal).toHaveBeenCalledTimes(1) }) - it('waits for repo hydration and does not close when fetchRepos supplies the repo', async () => { + it('waits for repo hydration and does not close when fetchReposForAllHosts supplies the repo', async () => { mocks.state.repos = [] - mocks.state.fetchRepos.mockImplementation(async () => { + mocks.state.fetchReposForAllHosts.mockImplementation(async () => { mocks.state.repos = [makeRepo()] }) const { default: ProjectAddedDialog } = await import('./ProjectAddedDialog') @@ -188,7 +188,7 @@ describe('ProjectAddedDialog', () => { renderToStaticMarkup() await flushPromises() - expect(mocks.state.fetchRepos).toHaveBeenCalledTimes(1) + expect(mocks.state.fetchReposForAllHosts).toHaveBeenCalledTimes(1) expect(mocks.state.closeModal).not.toHaveBeenCalled() expect(mocks.finishProjectAddWithDefaultCheckout).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx b/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx index 6532a39cae8..25f10c73925 100644 --- a/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx +++ b/src/renderer/src/components/sidebar/ProjectAddedDialog.tsx @@ -14,7 +14,7 @@ export default function ProjectAddedDialog(): null { const modalData = useAppStore((s) => s.modalData as ProjectAddedModalData) const closeModal = useAppStore((s) => s.closeModal) const repos = useAppStore((s) => s.repos) - const fetchRepos = useAppStore((s) => s.fetchRepos) + const fetchReposForAllHosts = useAppStore((s) => s.fetchReposForAllHosts) const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) const handoffRunRef = useRef(0) @@ -47,7 +47,8 @@ export default function ProjectAddedDialog(): null { pendingRepoHydrationRef.current = repoId let cancelled = false void (async () => { - await fetchRepos() + // Why: no row yet names the new project's host, so look on every host. + await fetchReposForAllHosts() if (cancelled) { return } @@ -112,7 +113,7 @@ export default function ProjectAddedDialog(): null { }, [ activeModal, closeModal, - fetchRepos, + fetchReposForAllHosts, fetchWorktrees, repo, repoId, diff --git a/src/renderer/src/components/sidebar/add-repo-runtime-owner.ts b/src/renderer/src/components/sidebar/add-repo-runtime-owner.ts index 9aa46115355..c929eed87ae 100644 --- a/src/renderer/src/components/sidebar/add-repo-runtime-owner.ts +++ b/src/renderer/src/components/sidebar/add-repo-runtime-owner.ts @@ -4,7 +4,10 @@ import { toSshExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' -import { getActiveRuntimeTarget, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' export type CapturedRuntimeOwner = string | null | undefined @@ -35,12 +38,9 @@ export function worktreeRefreshOptions( } } +/** The host the Add Project dialog picked; no picked server means this app. */ export function resolveAddRepoRuntimeTarget( - runtimeEnvironmentId: string | null | undefined, - settings: Parameters[0] + runtimeEnvironmentId: string | null | undefined ): RuntimeClientTarget { - const environmentId = runtimeEnvironmentId?.trim() - return environmentId - ? { kind: 'environment', environmentId } - : getActiveRuntimeTarget({ ...settings, activeRuntimeEnvironmentId: null }) + return runtimeTargetForOwnerEnvironment(runtimeEnvironmentId ?? null) } diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx b/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx index 56665ee9306..959f447928c 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.test.tsx @@ -28,7 +28,7 @@ let root: Root | null = null let container: HTMLDivElement | null = null function HookProbe(): null { - const result = useFolderWorkspaceComposerPathStatus(projectGroup, true) + const result = useFolderWorkspaceComposerPathStatus(projectGroup, true, null) ;( globalThis as { __folderWorkspaceComposerPathStatusResult?: typeof result } ).__folderWorkspaceComposerPathStatusResult = result @@ -53,7 +53,9 @@ describe('useFolderWorkspaceComposerPathStatus', () => { vi.useFakeTimers() vi.setSystemTime(20_000) const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - const cacheKey = useAppStore.getState().getFolderWorkspacePathStatusCacheKey(request) + const cacheKey = useAppStore + .getState() + .getFolderWorkspacePathStatusCacheKey(request, { runtimeEnvironmentId: null }) const fetchFolderWorkspacePathStatus = vi.fn().mockResolvedValue(null) useAppStore.setState({ projectGroups: [projectGroup], @@ -112,7 +114,10 @@ describe('useFolderWorkspaceComposerPathStatus', () => { } ).__folderWorkspaceComposerPathStatusResult?.pathStatusProjectError ).toContain('/workspace/platform') - expect(fetchFolderWorkspacePathStatus).toHaveBeenCalledWith(request, { force: true }) + expect(fetchFolderWorkspacePathStatus).toHaveBeenCalledWith(request, { + force: true, + runtimeEnvironmentId: null + }) }) it('unblocks creation when the first path status check settles without cache', async () => { @@ -151,14 +156,19 @@ describe('useFolderWorkspaceComposerPathStatus', () => { } ).__folderWorkspaceComposerPathStatusResult?.pathStatusBlocksCreate ).toBe(false) - expect(fetchFolderWorkspacePathStatus).toHaveBeenCalledWith(request, { force: true }) + expect(fetchFolderWorkspacePathStatus).toHaveBeenCalledWith(request, { + force: true, + runtimeEnvironmentId: null + }) }) it('does not block creation for an unavailable path status', () => { vi.useFakeTimers() vi.setSystemTime(20_000) const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - const cacheKey = useAppStore.getState().getFolderWorkspacePathStatusCacheKey(request) + const cacheKey = useAppStore + .getState() + .getFolderWorkspacePathStatusCacheKey(request, { runtimeEnvironmentId: null }) useAppStore.setState({ projectGroups: [projectGroup], fetchFolderWorkspacePathStatus: vi.fn().mockResolvedValue(null), @@ -195,7 +205,9 @@ describe('useFolderWorkspaceComposerPathStatus', () => { vi.useFakeTimers() vi.setSystemTime(20_000) const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - const cacheKey = useAppStore.getState().getFolderWorkspacePathStatusCacheKey(request) + const cacheKey = useAppStore + .getState() + .getFolderWorkspacePathStatusCacheKey(request, { runtimeEnvironmentId: null }) useAppStore.setState({ projectGroups: [projectGroup], fetchFolderWorkspacePathStatus: vi.fn(), diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.ts index f014c915f3e..2fb9f3dc6f2 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-path-status.ts @@ -12,7 +12,7 @@ import type { ProjectGroup } from '../../../../shared/project-group-types' export function useFolderWorkspaceComposerPathStatus( projectGroup: ProjectGroup | null, open: boolean, - runtimeEnvironmentId?: string | null + runtimeEnvironmentId: string | null ): { pathStatusBlocksCreate: boolean pathStatusProjectError: string | null @@ -48,10 +48,7 @@ export function useFolderWorkspaceComposerPathStatus( const [completedPathStatusRefreshKeys, setCompletedPathStatusRefreshKeys] = useState< ReadonlySet >(() => new Set()) - const pathStatusRouteOptions = useMemo( - () => ({ runtimeEnvironmentId: runtimeEnvironmentId ?? null }), - [runtimeEnvironmentId] - ) + const pathStatusRouteOptions = useMemo(() => ({ runtimeEnvironmentId }), [runtimeEnvironmentId]) const pathStatusCacheKey = pathStatusRequest ? getFolderWorkspacePathStatusCacheKey(pathStatusRequest, pathStatusRouteOptions) : null diff --git a/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts b/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts index 30920343680..b63725b5e8e 100644 --- a/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts +++ b/src/renderer/src/components/sidebar/use-worktree-context-menu-commands.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react' import type { useAppStore } from '@/store' import type { Repo } from '../../../../shared/repo-types' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' import type { WorkspaceStatusDefinition, Worktree } from '../../../../shared/worktree/types' import { createWorktreeContextMenuDeleteIntent, @@ -74,9 +75,10 @@ export function useWorktreeContextMenuCommands(args: { if (!args.repo) { return } - const group = await args.createProjectGroup(name) + const hostId = getRepoExecutionHostId(args.repo) + const group = await args.createProjectGroup(name, hostId) if (group) { - await args.moveProjectToGroup(args.repo.id, group.id) + await args.moveProjectToGroup(args.repo.id, group.id, undefined, hostId) } }, [args] @@ -86,13 +88,18 @@ export function useWorktreeContextMenuCommands(args: { if (!args.repo || args.repo.projectGroupId === groupId) { return } - void args.moveProjectToGroup(args.repo.id, groupId) + void args.moveProjectToGroup( + args.repo.id, + groupId, + undefined, + getRepoExecutionHostId(args.repo) + ) }, [args] ) const handleRemoveProjectFromGroup = useCallback(() => { if (args.repo) { - void args.moveProjectToGroup(args.repo.id, null) + void args.moveProjectToGroup(args.repo.id, null, undefined, getRepoExecutionHostId(args.repo)) } }, [args]) const handleAssignWorkspaceStatus = useCallback( diff --git a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts index a81a3eb3be6..ef07127bc09 100644 --- a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' import { toast } from 'sonner' -import { useAppStore } from '@/store' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events' import type { Repo } from '../../../../shared/repo-types' @@ -132,10 +131,7 @@ export function useAddRepoCloneFlow({ setCloneError(null) setCloneProgress(null) try { - const target = resolveAddRepoRuntimeTarget( - activeRuntimeEnvironmentId, - useAppStore.getState().settings - ) + const target = resolveAddRepoRuntimeTarget(activeRuntimeEnvironmentId) const repo = sshTargetId?.trim() ? await window.api.repos.cloneRemote({ connectionId: sshTargetId.trim(), diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts index b099e4c6ce8..c5a03d6ca39 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts @@ -81,6 +81,7 @@ function useTestAddRepoNestedImportFlow( nestedConnectionId: null, nestedGroupName: 'platform', nestedImportScanId: 'scan-1', + nestedRuntimeEnvironmentId: null, activeRuntimeEnvironmentId: null, closeModal: mocks.state.closeModal, fetchWorktrees: vi.fn(), @@ -195,7 +196,8 @@ describe('useAddRepoNestedImportFlow open folder fallback', () => { expect(mocks.state.closeModal).toHaveBeenCalledTimes(1) expect(mocks.state.openModal).toHaveBeenCalledWith('confirm-non-git-folder', { folderPath: '/workspace/platform', - connectionId: 'ssh-builder' + connectionId: 'ssh-builder', + runtimeEnvironmentId: null }) }) diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts index 2a347d0abb1..5b340d949d9 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts @@ -16,7 +16,7 @@ import type { } from '../../../../shared/project-group-types' import type { WorktreeFetchOptions } from '@/store/slices/worktree-helpers' import { translate } from '@/i18n/i18n' -import { worktreeRefreshOptions, type CapturedRuntimeOwner } from './add-repo-runtime-owner' +import { worktreeRefreshOptions } from './add-repo-runtime-owner' import { completeNestedFolderOpen } from './complete-nested-folder-open' import { defaultProjectGroupNameForPath } from './add-repo-dialog-types' import type { ExecutionHostId } from '../../../../shared/execution-host' @@ -45,7 +45,7 @@ export function useAddRepoNestedImportFlow({ nestedConnectionId: string | null nestedGroupName: string nestedImportScanId: string | null - nestedRuntimeEnvironmentId?: CapturedRuntimeOwner + nestedRuntimeEnvironmentId: string | null activeRuntimeEnvironmentId: string | null | undefined closeModal: () => void fetchWorktrees: (repoId: string, options?: WorktreeFetchOptions) => Promise @@ -55,7 +55,7 @@ export function useAddRepoNestedImportFlow({ projectPaths: string[] connectionId?: string scanId?: string - runtimeEnvironmentId?: string | null + runtimeEnvironmentId: string | null mode: 'group' | 'separate' }) => Promise getNestedRepoRuntimeKind: (connectionId: string | null) => NestedRepoTelemetryRuntimeKind diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedReviewController.ts b/src/renderer/src/components/sidebar/useAddRepoNestedReviewController.ts index f015883762b..761062d3b38 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedReviewController.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedReviewController.ts @@ -23,7 +23,7 @@ export function useAddRepoNestedReviewController({ reviewRuntimeEnvironmentId: string | null | undefined cancelNestedRepoScan: ( scanId: string, - options?: { runtimeEnvironmentId?: string | null } + options: { runtimeEnvironmentId: string | null } ) => Promise closeModal: () => void fetchWorktrees: (repoId: string, options?: WorktreeFetchOptions) => Promise @@ -33,7 +33,7 @@ export function useAddRepoNestedReviewController({ projectPaths: string[] connectionId?: string scanId?: string - runtimeEnvironmentId?: string | null + runtimeEnvironmentId: string | null mode: 'group' | 'separate' }) => Promise onGitRepoReady: ( diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedReviewState.ts b/src/renderer/src/components/sidebar/useAddRepoNestedReviewState.ts index 8e44b5a4076..a86538eae59 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedReviewState.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedReviewState.ts @@ -22,7 +22,7 @@ export function useAddRepoNestedReviewState({ activeRuntimeEnvironmentId: string | null | undefined cancelNestedRepoScan: ( scanId: string, - options?: { runtimeEnvironmentId?: string | null } + options: { runtimeEnvironmentId: string | null } ) => Promise setStep: (step: AddRepoDialogStep) => void }): { @@ -35,7 +35,7 @@ export function useAddRepoNestedReviewState({ nestedScanInProgress: boolean nestedScanId: string | null nestedImportScanId: string | null - nestedRuntimeEnvironmentId: string | null | undefined + nestedRuntimeEnvironmentId: string | null setNestedSelectedPaths: Dispatch>> setNestedGroupName: Dispatch> setNestedScanInProgress: Dispatch> @@ -56,11 +56,9 @@ export function useAddRepoNestedReviewState({ const [nestedScanInProgress, setNestedScanInProgress] = useState(false) const [nestedScanId, setNestedScanId] = useState(null) const [nestedImportScanId, setNestedImportScanId] = useState(null) - const [nestedRuntimeEnvironmentId, setNestedRuntimeEnvironmentId] = useState< - string | null | undefined - >(undefined) + const [nestedRuntimeEnvironmentId, setNestedRuntimeEnvironmentId] = useState(null) const nestedScanIdRef = useRef(null) - const nestedScanRuntimeEnvironmentIdRef = useRef(undefined) + const nestedScanRuntimeEnvironmentIdRef = useRef(null) const getNestedRepoRuntimeKind = useCallback( (connectionId: string | null): NestedRepoTelemetryRuntimeKind => { @@ -91,9 +89,9 @@ export function useAddRepoNestedReviewState({ ) const setActiveNestedScanId = useCallback( - (scanId: string | null, runtimeEnvironmentId?: string | null): void => { + (scanId: string | null, runtimeEnvironmentId: string | null = null): void => { nestedScanIdRef.current = scanId - nestedScanRuntimeEnvironmentIdRef.current = scanId ? runtimeEnvironmentId : undefined + nestedScanRuntimeEnvironmentIdRef.current = scanId ? runtimeEnvironmentId : null setNestedScanId(scanId) }, [] diff --git a/src/renderer/src/components/sidebar/useAddRepoServerPathFlow.ts b/src/renderer/src/components/sidebar/useAddRepoServerPathFlow.ts index 9810be00be8..666e975a792 100644 --- a/src/renderer/src/components/sidebar/useAddRepoServerPathFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoServerPathFlow.ts @@ -50,11 +50,11 @@ export function useAddRepoServerPathFlow({ getNestedRepoRuntimeKind: (connectionId: string | null) => NestedRepoTelemetryRuntimeKind scanNestedRepos: ( path: string, - connectionId?: string, - controls?: { + connectionId: string | undefined, + controls: { scanId?: string onProgress?: (scan: NestedRepoScanResult) => void - runtimeEnvironmentId?: string | null + runtimeEnvironmentId: string | null } ) => Promise setActiveNestedScanId: (scanId: string | null, runtimeEnvironmentId?: string | null) => void diff --git a/src/renderer/src/components/sidebar/useCreateRepo.ts b/src/renderer/src/components/sidebar/useCreateRepo.ts index f73b3e2790a..feef6782c03 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.ts @@ -100,10 +100,7 @@ export function useCreateRepo( setIsCreating(true) setCreateError(null) try { - const target = resolveAddRepoRuntimeTarget( - options.runtimeEnvironmentId, - useAppStore.getState().settings - ) + const target = resolveAddRepoRuntimeTarget(options.runtimeEnvironmentId) // Why: Create Project is intentionally Git-only; non-Git folders use the // existing add-folder flows instead of this path. const createKind = 'git' as const diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts index 825c9726898..0ca94755a0e 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts @@ -24,15 +24,13 @@ export function useFolderWorkspacePathStatusRows(args: { folderWorkspacePathStatuses, fetchFolderWorkspacePathStatus, getFolderWorkspacePathStatusCacheKey, - getFreshFolderWorkspacePathStatus, - activeRuntimeEnvironmentId + getFreshFolderWorkspacePathStatus } = useAppStore( useShallow((s) => ({ folderWorkspacePathStatuses: s.folderWorkspacePathStatuses, fetchFolderWorkspacePathStatus: s.fetchFolderWorkspacePathStatus, getFolderWorkspacePathStatusCacheKey: s.getFolderWorkspacePathStatusCacheKey, - getFreshFolderWorkspacePathStatus: s.getFreshFolderWorkspacePathStatus, - activeRuntimeEnvironmentId: s.settings?.activeRuntimeEnvironmentId ?? null + getFreshFolderWorkspacePathStatus: s.getFreshFolderWorkspacePathStatus })) ) const folderPathStatusRepoMembershipKey = useMemo( @@ -78,26 +76,28 @@ export function useFolderWorkspacePathStatusRows(args: { string, { request: FolderPathStatusRequest - options?: { runtimeEnvironmentId: string | null } + options: { runtimeEnvironmentId: string | null } } >() - for (const group of projectGroups) { - if (group.parentPath) { - const request = { scope: 'project-group' as const, projectGroupId: group.id } - const options = getFolderPathStatusRouteOptions(request) + // Why: a request whose group row has not landed has no owner to ask, so it waits. + const addRequest = (request: FolderPathStatusRequest): void => { + const options = getFolderPathStatusRouteOptions(request) + if (options) { requests.set(getFolderWorkspacePathStatusCacheKey(request, options), { request, options }) } } + for (const group of projectGroups) { + if (group.parentPath) { + addRequest({ scope: 'project-group' as const, projectGroupId: group.id }) + } + } for (const workspace of folderWorkspaces) { - const request = { scope: 'folder-workspace' as const, folderWorkspaceId: workspace.id } - const options = getFolderPathStatusRouteOptions(request) - requests.set(getFolderWorkspacePathStatusCacheKey(request, options), { request, options }) + addRequest({ scope: 'folder-workspace' as const, folderWorkspaceId: workspace.id }) } for (const { request, options } of requests.values()) { void fetchFolderWorkspacePathStatus(request, { force: true, ...options }) } }, [ - activeRuntimeEnvironmentId, fetchFolderWorkspacePathStatus, folderPathStatusRepoMembershipKey, folderPathStatusSshConnectionKey, @@ -109,6 +109,9 @@ export function useFolderWorkspacePathStatusRows(args: { const getCachedFolderWorkspacePathStatus = useCallback( (request: FolderPathStatusRequest) => { const options = getFolderPathStatusRouteOptions(request) + if (!options) { + return null + } const cacheKey = getFolderWorkspacePathStatusCacheKey(request, options) // Why: don't let an expired negative status keep folder workspaces disabled while a refresh is in flight. void folderWorkspacePathStatuses[cacheKey] diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts index fb027095064..c9c3a87b2ac 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts +++ b/src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts @@ -5,7 +5,10 @@ import { translate } from '@/i18n/i18n' import { selectProjectGroupRemovalTargets } from '@/store/slices/project-group-removal-targets' import type { ProjectGroup } from '../../../../../../shared/project-group-types' import type { Repo } from '../../../../../../shared/repo-types' -import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import { + getRepoExecutionHostId, + type ExecutionHostId +} from '../../../../../../shared/execution-host' export type ProjectGroupNameDialogState = | { type: 'create-from-repo'; repo: Repo } @@ -86,14 +89,14 @@ export function useProjectGroupDialogs(args: { if (repo.projectGroupId === groupId) { return } - void moveProjectToGroup(repo.id, groupId) + void moveProjectToGroup(repo.id, groupId, undefined, getRepoExecutionHostId(repo)) }, [moveProjectToGroup] ) const handleRemoveProjectFromGroup = useCallback( (repo: Repo) => { - void moveProjectToGroup(repo.id, null) + void moveProjectToGroup(repo.id, null, undefined, getRepoExecutionHostId(repo)) }, [moveProjectToGroup] ) @@ -111,9 +114,10 @@ export function useProjectGroupDialogs(args: { return } if (nameDialog.type === 'create-from-repo') { - const group = await createProjectGroup(name) + const hostId = getRepoExecutionHostId(nameDialog.repo) + const group = await createProjectGroup(name, hostId) if (group) { - await moveProjectToGroup(nameDialog.repo.id, group.id) + await moveProjectToGroup(nameDialog.repo.id, group.id, undefined, hostId) } return } diff --git a/src/renderer/src/hooks/composer-state/composer-store-actions.ts b/src/renderer/src/hooks/composer-state/composer-store-actions.ts index 71edb7e419c..6ff5bdc86e3 100644 --- a/src/renderer/src/hooks/composer-state/composer-store-actions.ts +++ b/src/renderer/src/hooks/composer-state/composer-store-actions.ts @@ -23,7 +23,7 @@ export type ComposerStoreActions = { createdWithAgent?: FolderWorkspace['createdWithAgent'] pendingFirstAgentMessageRename?: boolean }, - options?: { runtimeEnvironmentId?: string | null } + options: { runtimeEnvironmentId: string | null } ) => Promise closeModal: () => void openSettingsPage: () => void diff --git a/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts index 4264c786b86..2194f109923 100644 --- a/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/project-catalog-ipc-bridge.ts @@ -23,9 +23,11 @@ export function registerProjectCatalogIpcBridge( })() return } - void state.fetchProjectGroups() - void state.fetchFolderWorkspaces() - void state.fetchRepos().then(remountTerminalTabsAwaitingHostHydration) + // Why: this event is this app's own catalog changing, whichever host is the default. + const localOwner = { runtimeEnvironmentId: null } + void state.fetchProjectGroups(localOwner) + void state.fetchFolderWorkspaces(localOwner) + void state.fetchRepos(localOwner).then(remountTerminalTabsAwaitingHostHydration) }) ) diff --git a/src/renderer/src/lib/workspace-activation-path-gate.test.ts b/src/renderer/src/lib/workspace-activation-path-gate.test.ts index 2c8a836e05c..7ede5eb2a1d 100644 --- a/src/renderer/src/lib/workspace-activation-path-gate.test.ts +++ b/src/renderer/src/lib/workspace-activation-path-gate.test.ts @@ -70,7 +70,7 @@ async function seedFolderWorkspace( .getState() .fetchFolderWorkspacePathStatus( { scope: 'folder-workspace', folderWorkspaceId: folderWorkspace.id }, - { force: true } + { force: true, runtimeEnvironmentId: null } ) } diff --git a/src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts b/src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts index b2f795c36f4..2c8be71fd64 100644 --- a/src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts +++ b/src/renderer/src/store/folder-workspaces/folder-path-status-actions.ts @@ -1,7 +1,8 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { FolderWorkspacePathStatus } from '../../../../shared/folder-workspace-path-status' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '../../runtime/runtime-client-target' import type { RepoSlice } from '../repos/repo-state' import { getFolderWorkspacePathStatusRequestSnapshotForRead, @@ -9,9 +10,8 @@ import { getFreshFolderWorkspacePathStatusFromCache } from './folder-path-status' import { - getFolderWorkspacePathStatusRouteSettings, getFolderWorkspacePathStatusScopeKey, - getRuntimeTargetCachePrefix + getPathStatusOwnerCachePrefix } from './folder-workspace-routing' export function createFolderPathStatusActions( @@ -25,9 +25,7 @@ export function createFolderPathStatusActions( > { return { getFolderWorkspacePathStatusCacheKey: (request, options) => - `${getRuntimeTargetCachePrefix( - getFolderWorkspacePathStatusRouteSettings(options, get().settings) - )}:${getFolderWorkspacePathStatusScopeKey(request)}`, + `${getPathStatusOwnerCachePrefix(options)}:${getFolderWorkspacePathStatusScopeKey(request)}`, getFreshFolderWorkspacePathStatus: (request, options) => { const state = get() @@ -45,13 +43,11 @@ export function createFolderPathStatusActions( entry: cached, requestSnapshot }) - if (!options?.force && freshCachedStatus) { + if (!options.force && freshCachedStatus) { return freshCachedStatus } try { - const target = getActiveRuntimeTarget( - getFolderWorkspacePathStatusRouteSettings(options, get().settings) - ) + const target = runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId) const status = target.kind === 'local' ? await window.api.folderWorkspaces.getPathStatus(request) diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts index e5dfff1081c..dbef2e06592 100644 --- a/src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts @@ -1,6 +1,6 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import { getActiveRuntimeTarget, settingsForRuntimeOwner } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '../../runtime/runtime-client-target' import type { FetchedFolderWorkspaceCatalog } from './folder-workspace-catalog' import type { HostCatalogFence } from '../host-catalog-fencing' import type { RepoSlice } from '../repos/repo-state' @@ -24,9 +24,7 @@ export function createFolderWorkspaceCatalogActions( fetchFolderWorkspaces: async (options) => { try { const folderWorkspaceUpdates = getFolderWorkspaceUpdateCoordinator(get) - const target = getActiveRuntimeTarget( - settingsForRuntimeOwner(get().settings, options?.runtimeEnvironmentId) - ) + const target = runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId) const fence = claimHostCatalogFence(get, 'folder-workspaces', target) const catalog = await fetchFolderWorkspaceCatalogForTarget(target, get().projectGroups) if (!isHostCatalogFenceCurrent(get, fence)) { @@ -58,7 +56,7 @@ export function createFolderWorkspaceCatalogActions( }) } catch (err) { console.error('Failed to fetch folder workspaces:', err) - if (options?.throwOnError) { + if (options.throwOnError) { throw err } } diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts index e80047064cf..fb55f9b71b3 100644 --- a/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts @@ -6,23 +6,22 @@ import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { assertRuntimeEnvironmentCapability, - callRuntimeRpc, - getActiveRuntimeTarget + callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '../../runtime/runtime-client-target' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { formatFolderWorkspaceCreateError } from '../../lib/folder-workspace-path-status' import { findFolderWorkspaceOwner, - getExecutionHostIdForFolderWorkspace, - getRuntimeEnvironmentIdForFolderWorkspace + getExecutionHostIdForFolderWorkspace } from '@/lib/folder-workspace-runtime-owner' import { FolderWorkspaceUpdateCoordinator } from '../slices/folder-workspace-update-coordinator' import type { FolderWorkspaceUpdates, RepoSlice } from '../repos/repo-state' import { getRuntimeTargetHostId } from '../runtime-target-host' import { adoptFromEndpoint } from '../adopt-from-endpoint' import { + folderWorkspaceOwnerTarget, folderWorkspaceUpdateInvalidatesPathStatus, - getFolderWorkspacePathStatusRouteSettings, mergeFolderWorkspaceUpdateResponse } from './folder-workspace-routing' import { @@ -66,10 +65,8 @@ export function createFolderWorkspaceMutationActions( return { createFolderWorkspace: async (args, options) => { try { - // Why: a new folder has no owner yet, so creation follows the caller-selected path-status host. - const target = getActiveRuntimeTarget( - getFolderWorkspacePathStatusRouteSettings(options, get().settings) - ) + // Why: a new folder has no row yet, so creation goes to the host the caller picked. + const target = runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId) await assertWorkspaceAttachmentWriteCapability(target, args) if ( target.kind === 'environment' && @@ -123,13 +120,11 @@ export function createFolderWorkspaceMutationActions( if (!existingWorkspace) { return false } - const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderWorkspace( - state, - folderWorkspaceId, - executionHostId - ) // Why: owner-scoped mutations must not follow whichever runtime happens to be focused. - const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId }) + const target = folderWorkspaceOwnerTarget(state, folderWorkspaceId, executionHostId) + if (!target) { + return false + } const ownerHostId = executionHostId ?? getRuntimeTargetHostId(target) const sourceWorkspace = state.folderWorkspaces.find( (workspace) => @@ -259,19 +254,16 @@ export function createFolderWorkspaceMutationActions( folderWorkspaceId, executionHostId ) - const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderWorkspace( - state, - folderWorkspaceId, - executionHostId - ) + const target = folderWorkspaceOwnerTarget(state, folderWorkspaceId, executionHostId) + if (!target) { + return false + } const workspaceKey = folderWorkspaceKey(folderWorkspaceId) // Why before the host call: its announcement can start a refresh that drops these tabs. const chatDraftKeys = captureWorkspaceChatDraftKeys(state, [ { workspaceId: workspaceKey, executionHostId: ownerHostId } ]) try { - // Why: deletion targets the folder's owner; focus may be on a different host. - const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId }) const deleted = target.kind === 'local' ? await window.api.folderWorkspaces.delete({ folderWorkspaceId }) diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts index b5151bb65cf..b2351a7c768 100644 --- a/src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-routing.ts @@ -1,7 +1,13 @@ import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { FolderWorkspacePathStatusRequest } from '../../../../shared/folder-workspace-path-status' -import { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { + runtimeTargetForOwnerEnvironment, + type RuntimeClientTarget +} from '../../runtime/runtime-client-target' +import { runtimeTargetForWorkspaceOwner } from '@/lib/resolve-owner' +import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import type { FolderWorkspacePathStatusRouteOptions } from '../repos/repo-state' import type { FolderWorkspaceUpdateField } from './folder-workspace-mutations' @@ -17,22 +23,13 @@ export function getFolderWorkspacePathStatusScopeKey( return `folder-workspace:${request.folderWorkspaceId}` } -export function getRuntimeTargetCachePrefix( - settings: Pick | null | undefined +export function getPathStatusOwnerCachePrefix( + owner: FolderWorkspacePathStatusRouteOptions ): string { - const target = getActiveRuntimeTarget(settings) + const target = runtimeTargetForOwnerEnvironment(owner.runtimeEnvironmentId) return target.kind === 'local' ? 'local' : `environment:${target.environmentId}` } -export function getFolderWorkspacePathStatusRouteSettings( - options: FolderWorkspacePathStatusRouteOptions | undefined, - fallbackSettings: GlobalSettings | null -): Pick | null | undefined { - return options && 'runtimeEnvironmentId' in options - ? { activeRuntimeEnvironmentId: options.runtimeEnvironmentId ?? null } - : fallbackSettings -} - export function folderWorkspaceUpdateInvalidatesPathStatus( fields: readonly FolderWorkspaceUpdateField[] ): boolean { @@ -66,3 +63,15 @@ export function mergeFolderWorkspaceUpdateResponse( next.updatedAt = Math.max(current.updatedAt, updated.updatedAt) return next } + +/** The folder workspace's owner, or `null` when its rows name none or disagree. */ +export function folderWorkspaceOwnerTarget( + state: WorktreeOperationRouteState, + folderWorkspaceId: string, + executionHostId: ExecutionHostId | null | undefined +): RuntimeClientTarget | null { + return runtimeTargetForWorkspaceOwner(state, { + workspaceId: folderWorkspaceKey(folderWorkspaceId), + ...(executionHostId ? { hostId: executionHostId } : {}) + }) +} diff --git a/src/renderer/src/store/github/work-item-mutation-actions.ts b/src/renderer/src/store/github/work-item-mutation-actions.ts index a6883927c04..486d5ffba60 100644 --- a/src/renderer/src/store/github/work-item-mutation-actions.ts +++ b/src/renderer/src/store/github/work-item-mutation-actions.ts @@ -95,7 +95,7 @@ export const createWorkItemMutationActions = ( } ) // Why: the optimistic patch may now disagree with disk; resync rather than leave a lie on screen. - void get().fetchRepos() + void get().fetchReposForAllHosts() } // Why: clear inflight dedupe BEFORE bumping the nonce so the re-triggered fetch can't collapse onto a pre-flip in-flight entry. clearInflightWorkItemsForRepo(repoId, repoPath) diff --git a/src/renderer/src/store/project-groups/nested-repository-operations.ts b/src/renderer/src/store/project-groups/nested-repository-operations.ts index 111d359a4bc..dcb8f4a7624 100644 --- a/src/renderer/src/store/project-groups/nested-repository-operations.ts +++ b/src/renderer/src/store/project-groups/nested-repository-operations.ts @@ -5,11 +5,8 @@ import type { NestedRepoScanResult, ProjectGroupImportResult } from '../../../../shared/project-group-types' -import { - callRuntimeRpc, - getActiveRuntimeTarget, - settingsForRuntimeOwner -} from '../../runtime/runtime-rpc-client' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '../../runtime/runtime-client-target' import { translate } from '@/i18n/i18n' import type { RepoSlice } from '../repos/repo-state' @@ -30,12 +27,11 @@ export function createNestedRepositoryActions( return { scanNestedRepos: async (path, connectionId, controls) => { try { - const target = getActiveRuntimeTarget( - settingsForRuntimeOwner(get().settings, controls?.runtimeEnvironmentId) - ) + // Why: Add Project scans on the host the user picked; there is no row yet. + const target = runtimeTargetForOwnerEnvironment(controls.runtimeEnvironmentId) if (target.kind === 'local') { const unsubscribe = - controls?.scanId && controls.onProgress + controls.scanId && controls.onProgress ? window.api.projectGroups.onNestedScanProgress(({ scanId, scan }) => { if (scanId === controls.scanId) { controls.onProgress?.(normalizeNestedRepoScanResult(scan)) @@ -47,7 +43,7 @@ export function createNestedRepositoryActions( await window.api.projectGroups.scanNested({ path, connectionId, - scanId: controls?.scanId + scanId: controls.scanId }) ) } finally { @@ -71,9 +67,7 @@ export function createNestedRepositoryActions( cancelNestedRepoScan: async (scanId, options) => { try { - const target = getActiveRuntimeTarget( - settingsForRuntimeOwner(get().settings, options?.runtimeEnvironmentId) - ) + const target = runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId) if (target.kind !== 'local') { return false } @@ -86,9 +80,7 @@ export function createNestedRepositoryActions( importNestedRepos: async (args) => { try { - const target = getActiveRuntimeTarget( - settingsForRuntimeOwner(get().settings, args.runtimeEnvironmentId) - ) + const target = runtimeTargetForOwnerEnvironment(args.runtimeEnvironmentId) const result = target.kind === 'local' ? await window.api.projectGroups.importNested(args) @@ -104,10 +96,7 @@ export function createNestedRepositoryActions( }, { timeoutMs: 60_000 } ) - const catalogOptions = - 'runtimeEnvironmentId' in args - ? { runtimeEnvironmentId: args.runtimeEnvironmentId } - : undefined + const catalogOptions = { runtimeEnvironmentId: args.runtimeEnvironmentId } await get().fetchProjectGroups(catalogOptions) await get().fetchFolderWorkspaces(catalogOptions) await (args.runtimeEnvironmentId diff --git a/src/renderer/src/store/project-groups/project-group-catalog-actions.ts b/src/renderer/src/store/project-groups/project-group-catalog-actions.ts index 39de358e99c..e7a314b6b77 100644 --- a/src/renderer/src/store/project-groups/project-group-catalog-actions.ts +++ b/src/renderer/src/store/project-groups/project-group-catalog-actions.ts @@ -1,6 +1,6 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import { getActiveRuntimeTarget, settingsForRuntimeOwner } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '../../runtime/runtime-client-target' import type { FetchedProjectGroupCatalog } from './project-group-catalog' import type { HostCatalogFence } from '../host-catalog-fencing' import type { RepoSlice } from '../repos/repo-state' @@ -19,9 +19,7 @@ export function createProjectGroupCatalogActions( return { fetchProjectGroups: async (options) => { try { - const target = getActiveRuntimeTarget( - settingsForRuntimeOwner(get().settings, options?.runtimeEnvironmentId) - ) + const target = runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId) const fence = claimHostCatalogFence(get, 'project-groups', target) const catalog = await fetchProjectGroupCatalogForTarget(target) if (!isHostCatalogFenceCurrent(get, fence)) { @@ -42,7 +40,7 @@ export function createProjectGroupCatalogActions( }) } catch (err) { console.error('Failed to fetch project groups:', err) - if (options?.throwOnError) { + if (options.throwOnError) { throw err } } diff --git a/src/renderer/src/store/project-groups/project-group-mutations.ts b/src/renderer/src/store/project-groups/project-group-mutations.ts index f5fd46d7e5e..d25a367cd5b 100644 --- a/src/renderer/src/store/project-groups/project-group-mutations.ts +++ b/src/renderer/src/store/project-groups/project-group-mutations.ts @@ -7,15 +7,15 @@ import { getProjectGroupHostId, projectGroupMatchesOwnerHost, resolveProjectGroupOwnerHostId, - settingsForProjectGroupOwner + runtimeTargetForProjectGroupOwner } from '../slices/project-group-owner-routing' import { findRepoForHost, repoMatchesHostIdentity } from '../slices/repo-host-identity' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerHostId } from '../../runtime/runtime-client-target' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import type { ProjectRemovalFailure, RepoSlice } from '../repos/repo-state' import { mergeProjectCompatibilityForHostRepoChange } from '../repos/repo-catalog-identity' import { applyProjectGroupDeleteCascade } from './project-group-removal-state' -import { settingsForRepoOwner } from '../repos/owner-routing' import { adoptFromEndpoint } from '../adopt-from-endpoint' export function createProjectGroupMutationActions( @@ -30,9 +30,13 @@ export function createProjectGroupMutationActions( | 'moveProjectToGroup' > { return { - createProjectGroup: async (name) => { + createProjectGroup: async (name, hostId) => { try { - const target = getActiveRuntimeTarget(get().settings) + // Why: a new group lands on the host of the project that seeds it, not the focused one. + const target = runtimeTargetForOwnerHostId(hostId) + if (!target) { + return null + } const group = target.kind === 'local' ? await window.api.projectGroups.create({ @@ -75,9 +79,10 @@ export function createProjectGroupMutationActions( try { // Why: the sidebar lists groups from every host, so the mutation follows the group's owner, not the focused host. const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options?.hostId) - const target = getActiveRuntimeTarget( - settingsForProjectGroupOwner(get(), groupId, options?.hostId) - ) + const target = runtimeTargetForProjectGroupOwner(get(), groupId, options?.hostId) + if (!target) { + return false + } const updated = target.kind === 'local' ? await window.api.projectGroups.update({ groupId, updates }) @@ -110,9 +115,10 @@ export function createProjectGroupMutationActions( try { // Why: deletion targets the group's owner host (see updateProjectGroup); focus may be elsewhere. const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options?.hostId) - const target = getActiveRuntimeTarget( - settingsForProjectGroupOwner(get(), groupId, options?.hostId) - ) + const target = runtimeTargetForProjectGroupOwner(get(), groupId, options?.hostId) + if (!target) { + return false + } const deleted = target.kind === 'local' ? await window.api.projectGroups.delete({ groupId }) @@ -228,12 +234,13 @@ export function createProjectGroupMutationActions( } }, - moveProjectToGroup: async (projectId, groupId, order) => { + moveProjectToGroup: async (projectId, groupId, order, hostId) => { try { - if (!findRepoForHost(get().repos, projectId, { settings: get().settings })) { + const repo = findRepoForHost(get().repos, projectId, { settings: get().settings, hostId }) + const target = repo ? runtimeTargetForOwnerHostId(getRepoExecutionHostId(repo)) : null + if (!target) { return false } - const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) const moved = target.kind === 'local' ? await window.api.projectGroups.moveProject({ diff --git a/src/renderer/src/store/projects/project-host-routing.ts b/src/renderer/src/store/projects/project-host-routing.ts index 2db4a0bc64c..cb1626cedc0 100644 --- a/src/renderer/src/store/projects/project-host-routing.ts +++ b/src/renderer/src/store/projects/project-host-routing.ts @@ -1,4 +1,3 @@ -import type { AppState } from '../types' import type { Project, ProjectHostSetup, @@ -21,10 +20,8 @@ import { parseExecutionHostId } from '../../../../shared/execution-host' import { assertRuntimeEnvironmentCapability, callRuntimeRpc, - getActiveRuntimeTarget, type RuntimeClientTarget } from '../../runtime/runtime-rpc-client' -import { getRuntimeTargetHostId } from '../runtime-target-host' import { adoptFromEndpoint } from '../adopt-from-endpoint' export function getProjectSetupRuntimeTarget( @@ -36,22 +33,6 @@ export function getProjectSetupRuntimeTarget( : { kind: 'local' } } -export function getProjectUpdateRuntimeTarget( - state: AppState, - projectId: string -): RuntimeClientTarget { - const target = getActiveRuntimeTarget(state.settings) - if (target.kind !== 'environment') { - return target - } - const runtimeHostId = getRuntimeTargetHostId(target) - return state.projectHostSetups.some( - (setup) => setup.projectId === projectId && setup.hostId === runtimeHostId - ) - ? target - : { kind: 'local' } -} - function normalizeProjectCatalogProjection( projection: ProjectHostSetupProjection ): ProjectHostSetupProjection { diff --git a/src/renderer/src/store/projects/project-update.ts b/src/renderer/src/store/projects/project-update.ts index 75c65f24ee3..7bdb858efd7 100644 --- a/src/renderer/src/store/projects/project-update.ts +++ b/src/renderer/src/store/projects/project-update.ts @@ -4,7 +4,7 @@ import type { Project } from '../../../../shared/project-types' import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import { notifyInstalledAgentSkillsChanged } from '@/hooks/installed-agent-skill-discovery' import type { RepoSlice } from '../repos/repo-state' -import { getProjectUpdateRuntimeTarget } from './project-host-routing' +import { getProjectSetupRuntimeTarget } from './project-host-routing' import { mergeUpdatedProjectCompatibilityProject } from './project-compatibility-core' import { normalizeProjectRow } from '../../../../shared/project-catalog-row-normalization' @@ -13,9 +13,10 @@ export function createProjectUpdateActions( get: Parameters>[1] ): Pick { return { - updateProject: async (projectId, updates) => { + updateProject: async (projectId, updates, hostId) => { try { - const target = getProjectUpdateRuntimeTarget(get(), projectId) + // Why: the caller names the host whose project row it edits; focus never picks it. + const target = getProjectSetupRuntimeTarget(hostId) const updatedProject = target.kind === 'local' ? await window.api.projects.update({ projectId, updates }) diff --git a/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts index 38ffb530439..91013b98e35 100644 --- a/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts @@ -5,7 +5,6 @@ import { applyManualRepoOrder } from '../../../../shared/manual-repo-order' import { retainValidFilterRepoIds } from '../slices/repo-filter-selection' import { readRuntimeWorktreeVisibilitySnapshot } from '../slices/worktree-visibility-owner-settings' import { getRepoHostIdentity } from '../slices/repo-host-identity' -import { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { isRemovedRuntimeHostId } from '../slices/stale-runtime-host-rows' @@ -176,8 +175,8 @@ export function createAllHostRepoCatalogActions( ...state.worktreeVisibilityDefaultsByHost, [hostId]: visibilityDefaults }, - ...(getActiveRuntimeTarget(state.settings).kind === 'environment' && - state.settings?.activeRuntimeEnvironmentId === environment.id + // Why: the settings page mirrors only the default host's source-defaults support. + ...(state.settings?.activeRuntimeEnvironmentId === environment.id ? { worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: visibilitySnapshot.sourceDefaultsSupported ? environment.id : null diff --git a/src/renderer/src/store/repos/owner-routing.ts b/src/renderer/src/store/repos/owner-routing.ts index 52ce1d87f3d..215a6a00531 100644 --- a/src/renderer/src/store/repos/owner-routing.ts +++ b/src/renderer/src/store/repos/owner-routing.ts @@ -1,5 +1,4 @@ import type { AppState } from '../types' -import type { GlobalSettings } from '../../../../shared/global-settings-types' import { FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { FolderWorkspacePathStatus } from '../../../../shared/folder-workspace-path-status' import { findRepoForHost } from '../slices/repo-host-identity' @@ -11,7 +10,6 @@ import type { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' import { translate } from '@/i18n/i18n' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' import type { ExecutionHostId } from '../../../../shared/execution-host' -import type { AddRepoPathOptions } from './repo-state' export function settingsForRepoOwner( state: Pick, @@ -40,15 +38,6 @@ export function settingsForRepoOwner( return state.settings } -export function getAddRepoPathRouteSettings( - options: AddRepoPathOptions | undefined, - fallbackSettings: GlobalSettings | null -): Pick | null | undefined { - return options && 'runtimeEnvironmentId' in options - ? { activeRuntimeEnvironmentId: options.runtimeEnvironmentId ?? null } - : fallbackSettings -} - export function getRuntimeEnvironmentDisplayName(state: AppState, environmentId: string): string { const environment = state.runtimeEnvironments.find((entry) => entry.id === environmentId) return environment?.name || environmentId diff --git a/src/renderer/src/store/repos/repo-add-actions.ts b/src/renderer/src/store/repos/repo-add-actions.ts index 5629a55b040..67d73578194 100644 --- a/src/renderer/src/store/repos/repo-add-actions.ts +++ b/src/renderer/src/store/repos/repo-add-actions.ts @@ -4,7 +4,9 @@ import type { AppState } from '../types' import type { Repo } from '../../../../shared/repo-types' import { isGitRepoKind } from '../../../../shared/repo-kind' import { getRepoHostIdentity } from '../slices/repo-host-identity' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '../../runtime/runtime-client-target' +import { defaultCreationHost } from '@/lib/default-creation-host' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' import { translate } from '@/i18n/i18n' import { @@ -14,11 +16,7 @@ import { } from '../../../../shared/execution-host' import type { RepoSlice } from './repo-state' import { ERROR_TOAST_DURATION } from './repo-state' -import { - fetchRuntimeAddProjectPathStatus, - getAddRepoPathRouteSettings, - getRuntimeEnvironmentDisplayName -} from './owner-routing' +import { fetchRuntimeAddProjectPathStatus, getRuntimeEnvironmentDisplayName } from './owner-routing' import { adoptFromEndpoint } from '../adopt-from-endpoint' import { mergeProjectCompatibilityForHostRepoChange } from './repo-catalog-identity' import { warnIfProjectKnownInAnotherProfile } from '../projects/project-profile-presence' @@ -31,7 +29,11 @@ export function createRepoAddActions( return { addRepoPath: async (path, kind = 'git', options) => { try { - const target = getActiveRuntimeTarget(getAddRepoPathRouteSettings(options, get().settings)) + // Why: a new project has no row yet; without a picked host it goes to the default host. + const target = + options && 'runtimeEnvironmentId' in options + ? runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId ?? null) + : defaultCreationHost(get().settings) const displayName = options?.displayName?.trim() || undefined let repo: Repo try { @@ -144,7 +146,8 @@ export function createRepoAddActions( }, addRepo: async () => { - const target = getActiveRuntimeTarget(get().settings) + // Why: the OS folder picker adds a new project on the default host; there is no row yet. + const target = defaultCreationHost(get().settings) if (target.kind !== 'local') { // Why: OS folder pickers return client-local paths; remote environments need an explicit host path (Add Project dialog). toast.error( diff --git a/src/renderer/src/store/repos/repo-catalog-actions.ts b/src/renderer/src/store/repos/repo-catalog-actions.ts index e9ff0e484f7..61ff2b5010d 100644 --- a/src/renderer/src/store/repos/repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/repo-catalog-actions.ts @@ -9,7 +9,7 @@ import { reconcileReadoptedSshRepoRows } from '../slices/superseded-ssh-repo-rows' import { getRepoHostIdentity } from '../slices/repo-host-identity' -import { getActiveRuntimeTarget, settingsForRuntimeOwner } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerEnvironment } from '../../runtime/runtime-client-target' import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { isRemovedRuntimeHostId } from '../slices/stale-runtime-host-rows' @@ -88,9 +88,7 @@ export function createRepoCatalogActions( }), fetchRepos: async (options) => { - const target = getActiveRuntimeTarget( - settingsForRuntimeOwner(get().settings, options?.runtimeEnvironmentId) - ) + const target = runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId) const settleLocalCatalog: (outcome: LocalRepoCatalogFetchOutcome) => void = target.kind === 'local' ? startLocalRepoCatalogFetch(get) : () => undefined let localCatalogOutcome: LocalRepoCatalogFetchOutcome = { status: 'fulfilled' } @@ -156,7 +154,7 @@ export function createRepoCatalogActions( } catch (err) { localCatalogOutcome = { status: 'rejected', reason: err } console.error('Failed to fetch repos:', err) - if (options?.throwOnError) { + if (options.throwOnError) { throw err } } finally { diff --git a/src/renderer/src/store/repos/repo-removal.ts b/src/renderer/src/store/repos/repo-removal.ts index e1dde15d7cf..3695dbbe51a 100644 --- a/src/renderer/src/store/repos/repo-removal.ts +++ b/src/renderer/src/store/repos/repo-removal.ts @@ -6,11 +6,8 @@ import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import { getWorktreeIdFromVisitKey, getWorktreeVisitKey } from '@/lib/worktree-visit-recency' import { omitSparsePresetsForRepos } from '../slices/sparse-presets' import { repoMatchesHostIdentity } from '../slices/repo-host-identity' -import { - callRuntimeRpc, - getActiveRuntimeTarget, - hasRuntimeRpcErrorCode -} from '../../runtime/runtime-rpc-client' +import { callRuntimeRpc, hasRuntimeRpcErrorCode } from '../../runtime/runtime-rpc-client' +import { runtimeTargetForOwnerHostId } from '../../runtime/runtime-client-target' import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' import { translate } from '@/i18n/i18n' import { @@ -22,7 +19,6 @@ import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime import type { RepoSlice } from './repo-state' import { ERROR_TOAST_DURATION } from './repo-state' import { mergeProjectCompatibilityForHostRepoChange } from './repo-catalog-identity' -import { settingsForRepoOwner } from './owner-routing' import { captureWorkspaceChatDraftKeys, deleteWorkspaceChatDrafts @@ -109,7 +105,10 @@ export function createRepoRemovalActions( } } // Why: derive the target from the owner row's host so an SSH host removal never routes repo.rm to the focused runtime. - const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId, ownerHostId)) + const target = runtimeTargetForOwnerHostId(ownerHostId) + if (!target) { + throw new Error('The project host is unresolved. Refresh and retry.') + } // Why before the host call: its announcement can start a listing refresh that drops these tabs. const chatDraftKeys = captureWorkspaceChatDraftKeys( get(), diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index 003ac6bdcf2..2809893b889 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -89,11 +89,12 @@ export type FolderWorkspaceUpdates = Partial< export type NestedRepoScanControls = { scanId?: string onProgress?: (scan: NestedRepoScanResult) => void - runtimeEnvironmentId?: string | null + /** The host chosen in Add Project: a server's environment id, or `null` for this app. */ + runtimeEnvironmentId: string | null } export type NestedRepoScanCancelOptions = { - runtimeEnvironmentId?: string | null + runtimeEnvironmentId: string | null } export type FolderWorkspacePathStatusCacheEntry = { @@ -133,7 +134,8 @@ export type DeleteProjectGroupWithContainedProjectsResult = failedProjectRemovals: [] } -export type FolderWorkspacePathStatusRouteOptions = { runtimeEnvironmentId?: string | null } +/** The owner of the folder being probed: its server's environment id, or `null` for this app. */ +export type FolderWorkspacePathStatusRouteOptions = { runtimeEnvironmentId: string | null } export type AddRepoPathOptions = { runtimeEnvironmentId?: string | null @@ -142,7 +144,8 @@ export type AddRepoPathOptions = { } export type RuntimeCatalogFetchOptions = { - runtimeEnvironmentId?: string | null + /** Whose catalog to fetch: a server's environment id, or `null` for this app's own. */ + runtimeEnvironmentId: string | null /** Conversion must retain its source rows until every destination catalog loads. */ throwOnError?: boolean } @@ -162,13 +165,13 @@ export type RepoSlice = { reposFetchGeneration: number pendingSshRepoReadoptions: readonly SshRepoReadoption[] recordSshRepoReadoptions: (readoptions: readonly SshRepoReadoption[]) => void - fetchRepos: (options?: RuntimeCatalogFetchOptions) => Promise + fetchRepos: (options: RuntimeCatalogFetchOptions) => Promise fetchReposForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise awaitLocalRepoCatalogSettlement: () => Promise fetchRuntimeEnvironmentRepos: (environmentId: string) => Promise - fetchProjectGroups: (options?: RuntimeCatalogFetchOptions) => Promise + fetchProjectGroups: (options: RuntimeCatalogFetchOptions) => Promise fetchProjectGroupsForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise - fetchFolderWorkspaces: (options?: RuntimeCatalogFetchOptions) => Promise + fetchFolderWorkspaces: (options: RuntimeCatalogFetchOptions) => Promise fetchFolderWorkspacesForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise addRepo: () => Promise addRepoPath: ( @@ -192,20 +195,21 @@ export type RepoSlice = { addNonGitFolder: (path: string, options?: AddRepoPathOptions) => Promise scanNestedRepos: ( path: string, - connectionId?: string, - controls?: NestedRepoScanControls + connectionId: string | undefined, + controls: NestedRepoScanControls ) => Promise - cancelNestedRepoScan: (scanId: string, options?: NestedRepoScanCancelOptions) => Promise + cancelNestedRepoScan: (scanId: string, options: NestedRepoScanCancelOptions) => Promise importNestedRepos: (args: { parentPath: string groupName: string projectPaths: string[] connectionId?: string scanId?: string - runtimeEnvironmentId?: string | null + runtimeEnvironmentId: string | null mode: 'group' | 'separate' }) => Promise - createProjectGroup: (name: string) => Promise + /** `hostId` is the host of the project that seeds the group. */ + createProjectGroup: (name: string, hostId: ExecutionHostId) => Promise createFolderWorkspace: ( args: { projectGroupId: string @@ -218,19 +222,19 @@ export type RepoSlice = { createdWithAgent?: FolderWorkspace['createdWithAgent'] pendingFirstAgentMessageRename?: boolean }, - options?: FolderWorkspacePathStatusRouteOptions + options: FolderWorkspacePathStatusRouteOptions ) => Promise getFolderWorkspacePathStatusCacheKey: ( request: FolderWorkspacePathStatusRequest, - options?: FolderWorkspacePathStatusRouteOptions + options: FolderWorkspacePathStatusRouteOptions ) => string getFreshFolderWorkspacePathStatus: ( request: FolderWorkspacePathStatusRequest, - options?: FolderWorkspacePathStatusRouteOptions + options: FolderWorkspacePathStatusRouteOptions ) => FolderWorkspacePathStatus | null fetchFolderWorkspacePathStatus: ( request: FolderWorkspacePathStatusRequest, - options?: { force?: boolean } & FolderWorkspacePathStatusRouteOptions + options: { force?: boolean } & FolderWorkspacePathStatusRouteOptions ) => Promise updateFolderWorkspace: ( folderWorkspaceId: string, @@ -255,7 +259,8 @@ export type RepoSlice = { moveProjectToGroup: ( projectId: string, groupId: string | null, - order?: number + order?: number, + hostId?: ExecutionHostId ) => Promise // options.hostId is required: a bare id can resolve to another host's row (#13071). Removes nothing when that host has no row. // options.errorFeedback defaults to 'silent' so bulk/background callers keep their own aggregate reporting. @@ -263,7 +268,11 @@ export type RepoSlice = { projectId: string, options: { hostId: ExecutionHostId; errorFeedback?: 'toast' | 'silent' } ) => Promise - updateProject: (projectId: string, updates: ProjectUpdate) => Promise + updateProject: ( + projectId: string, + updates: ProjectUpdate, + hostId: ExecutionHostId + ) => Promise // options.hostId targets a specific host's row + RPC target when the id exists on multiple hosts; else the focused host is assumed. updateRepo: ( projectId: string, diff --git a/src/renderer/src/store/repos/repo-update.ts b/src/renderer/src/store/repos/repo-update.ts index 175dcfe827d..7b3fa975697 100644 --- a/src/renderer/src/store/repos/repo-update.ts +++ b/src/renderer/src/store/repos/repo-update.ts @@ -9,16 +9,14 @@ import { getRepoHostIdentityForParts, repoMatchesHostIdentity } from '../slices/repo-host-identity' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { normalizeCustomWorktreeVisibilitySources, normalizeWorktreeVisibilitySourcePreferences } from '../../../../shared/worktree/visibility-sources' import type { RepoSlice, RepoUpdate } from './repo-state' -import { settingsForRepoOwner } from './owner-routing' import { adoptFromEndpoint } from '../adopt-from-endpoint' -import { getRuntimeTargetHostId } from '../runtime-target-host' import { getProjectSetupRuntimeTarget } from '../projects/project-host-routing' import { mergeProjectCompatibilityForHostRepoChange } from './repo-catalog-identity' @@ -113,13 +111,9 @@ export function createRepoUpdateActions( const ownerHasExplicitHost = Boolean( options?.hostId || ownerRepo.executionHostId?.trim() || ownerRepo.connectionId?.trim() ) - const explicitOwnerHostId = getRepoExecutionHostId(ownerRepo) - const ownerTarget = ownerHasExplicitHost - ? getProjectSetupRuntimeTarget(explicitOwnerHostId) - : getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId)) - const ownerHostId = ownerHasExplicitHost - ? explicitOwnerHostId - : getRuntimeTargetHostId(ownerTarget) + // Why: a row with no stamp is this app's own; ingest stamps every server row. + const ownerHostId = getRepoExecutionHostId(ownerRepo) + const ownerTarget = getProjectSetupRuntimeTarget(ownerHostId) const updateChainKey = getRepoHostIdentityForParts(projectId, ownerHostId) const applyRepoUpdate = async () => { try { diff --git a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts index efc4da84d8c..107c5f8526f 100644 --- a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts @@ -6,7 +6,6 @@ import { applyManualRepoOrder } from '../../../../shared/manual-repo-order' import { retainValidFilterRepoIds } from '../slices/repo-filter-selection' import { readRuntimeWorktreeVisibilitySnapshot } from '../slices/worktree-visibility-owner-settings' import { getRepoHostIdentity } from '../slices/repo-host-identity' -import { getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' import { filterSetupScriptPromptDismissalsToValidRepos } from '@/lib/setup-script-prompt' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { isRemovedRuntimeHostId } from '../slices/stale-runtime-host-rows' @@ -88,8 +87,7 @@ export function createRuntimeRepoCatalogActions( } }), ...(visibilityDefaults !== undefined && s.settings - ? getActiveRuntimeTarget(s.settings).kind === 'environment' && - s.settings.activeRuntimeEnvironmentId === environmentId + ? s.settings.activeRuntimeEnvironmentId === environmentId ? visibilityDefaults ? { settings: { diff --git a/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts b/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts index 6ba88708736..4c0ec128e22 100644 --- a/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts +++ b/src/renderer/src/store/slices/folder-workspace-owner-routed-mutations.test.ts @@ -308,7 +308,7 @@ describe('folder workspace owner-routed mutations', () => { const pendingUpdate = store .getState() .updateFolderWorkspace(folderWorkspace.id, { isUnread: true }) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) resolveUpdate({ ...folderWorkspace, isUnread: true, updatedAt: 2 }) await pendingUpdate @@ -335,7 +335,7 @@ describe('folder workspace owner-routed mutations', () => { const pendingUpdate = store .getState() .updateFolderWorkspace(folderWorkspace.id, { isUnread: true }) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) resolveUpdate({ ...folderWorkspace, isUnread: true, updatedAt: 2 }) await pendingUpdate @@ -619,8 +619,20 @@ describe('folder workspace owner-routed mutations', () => { settings: createGlobalSettingsFixture({ activeRuntimeEnvironmentId: 'env-old' }) }) await expect( - store.getState().createFolderWorkspace({ projectGroupId: projectGroup.id, linkedItems: [] }) + store + .getState() + .createFolderWorkspace( + { projectGroupId: projectGroup.id, linkedItems: [] }, + { runtimeEnvironmentId: 'env-old' } + ) ).rejects.toThrow('Update the remote runtime') expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/project-group-owner-routing.ts b/src/renderer/src/store/slices/project-group-owner-routing.ts index 67d3a793bba..101b65d5373 100644 --- a/src/renderer/src/store/slices/project-group-owner-routing.ts +++ b/src/renderer/src/store/slices/project-group-owner-routing.ts @@ -7,6 +7,10 @@ import { type ExecutionHostId } from '../../../../shared/execution-host' import { findIndexedProjectGroupOwner } from '@/lib/worktree-runtime-owner-index' +import { + runtimeTargetForOwnerHostId, + type RuntimeClientTarget +} from '@/runtime/runtime-client-target' type ProjectGroupHostParts = Pick type ProjectGroupOwnerRecord = Pick @@ -60,29 +64,15 @@ export function resolveProjectGroupOwnerHostId( return getProjectGroupHostId(owner) } -// Why: the sidebar lists groups from every host, so mutations must route to the row's owner -// instead of whichever host currently has focus. Mirrors settingsForRepoOwner. -export function settingsForProjectGroupOwner( - state: ProjectGroupOwnerRoutingState, +/** + * Transport to the group's owner, so a mutation never follows the focused host; `null` when no + * row names the group. Direct-SSH groups live in this app's catalog and ride its IPC. + */ +export function runtimeTargetForProjectGroupOwner( + state: Pick, groupId: string, hostId?: ExecutionHostId -): RoutingSettings { - const ownerHostId = resolveProjectGroupOwnerHostId(state, groupId, hostId) - if (!ownerHostId) { - return state.settings - } - const parsed = parseExecutionHostId(ownerHostId) - if (parsed?.kind === 'runtime') { - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: parsed.environmentId } - : { activeRuntimeEnvironmentId: parsed.environmentId } - } - // Why: direct-SSH groups live in the local main process catalog, so they route through window.api. - if ( - (parsed?.kind === 'local' || parsed?.kind === 'ssh') && - state.settings?.activeRuntimeEnvironmentId - ) { - return { ...state.settings, activeRuntimeEnvironmentId: null } - } - return state.settings +): RuntimeClientTarget | null { + const owner = findIndexedProjectGroupOwner(state.projectGroups, groupId, hostId) + return owner ? runtimeTargetForOwnerHostId(hostId ?? getProjectGroupHostId(owner)) : null } diff --git a/src/renderer/src/store/slices/repos-add-races.test.ts b/src/renderer/src/store/slices/repos-add-races.test.ts index 363e41e5bad..c707d91a24a 100644 --- a/src/renderer/src/store/slices/repos-add-races.test.ts +++ b/src/renderer/src/store/slices/repos-add-races.test.ts @@ -55,7 +55,7 @@ describe('repo add/catalog races', () => { reposAdd.mockResolvedValue({ repo: localRepo }) const store = createTestStore() - const pendingCatalog = store.getState().fetchRepos() + const pendingCatalog = store.getState().fetchRepos(catalogOwner(store)) await store.getState().addRepoPath(localRepo.path) expect(store.getState().repos).toHaveLength(1) @@ -72,10 +72,17 @@ describe('repo add/catalog races', () => { const store = createTestStore() const pendingAdd = store.getState().addRepoPath(localRepo.path) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) resolveAdd({ repo: localRepo }) await pendingAdd expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts b/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts index 2d6e9dfb1b0..38d0ab353b3 100644 --- a/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts +++ b/src/renderer/src/store/slices/repos-all-hosts-generation.test.ts @@ -124,7 +124,7 @@ describe('fetchReposForAllHosts generation', () => { store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) - const remoteRefresh = store.getState().fetchRepos() + const remoteRefresh = store.getState().fetchRepos(catalogOwner(store)) let remoteSettled = false void remoteRefresh.then(() => { remoteSettled = true @@ -525,3 +525,10 @@ describe('fetchReposForAllHosts generation', () => { expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo', 'remote-repo']) }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-all-hosts.test.ts b/src/renderer/src/store/slices/repos-all-hosts.test.ts index 17c9227aa5e..736b013a61f 100644 --- a/src/renderer/src/store/slices/repos-all-hosts.test.ts +++ b/src/renderer/src/store/slices/repos-all-hosts.test.ts @@ -437,7 +437,7 @@ describe('fetchReposForAllHosts', () => { const store = createTestStore() await store.getState().fetchReposForAllHosts() - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) expect( store diff --git a/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts b/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts index 037ed963ec7..44a482de0d0 100644 --- a/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts +++ b/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts @@ -112,11 +112,11 @@ describe('catalog merge referential stability', () => { it('keeps the same projectGroups array when a refetch changes nothing', async () => { const store = createTestStore() - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) const first = store.getState().projectGroups expect(first).toEqual([{ ...projectGroup, executionHostId: 'local' }]) - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) expect(store.getState().projectGroups).toBe(first) }) @@ -125,12 +125,12 @@ describe('catalog merge referential stability', () => { const store = createTestStore() store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) const first = store.getState().folderWorkspaces expect(first).toHaveLength(1) const firstEntry = first[0] - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) expect(store.getState().folderWorkspaces).toBe(first) expect(store.getState().folderWorkspaces[0]).toBe(firstEntry) @@ -139,14 +139,14 @@ describe('catalog merge referential stability', () => { it('appends new entries and replaces changed ones while keeping order', async () => { const store = createTestStore() - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) const first = store.getState().projectGroups projectGroupsList.mockImplementation(async () => [ clone({ ...projectGroup, name: 'Renamed' }), clone(secondProjectGroup) ]) - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) const merged = store.getState().projectGroups expect(merged).not.toBe(first) @@ -160,14 +160,14 @@ describe('catalog merge referential stability', () => { const store = createTestStore() store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) const firstEntry = store.getState().folderWorkspaces[0] folderWorkspacesList.mockImplementation(async () => [ clone(secondFolderWorkspace), clone(folderWorkspace) ]) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) const merged = store.getState().folderWorkspaces expect(merged.map((workspace) => workspace.id)).toEqual(['folder-1', 'folder-2']) @@ -180,7 +180,7 @@ describe('catalog merge referential stability', () => { store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) projectGroupsList.mockImplementation(async () => [clone(secondProjectGroup)]) - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) expect(store.getState().projectGroups).toEqual([ { ...secondProjectGroup, executionHostId: 'local' } @@ -191,7 +191,7 @@ describe('catalog merge referential stability', () => { const store = createTestStore() store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) const first = store.getState().folderWorkspaces folderWorkspacesList.mockImplementation(async () => [ @@ -200,7 +200,7 @@ describe('catalog merge referential stability', () => { linkedTask: { ...folderWorkspace.linkedTask!, title: 'Issue 7 renamed' } }) ]) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) expect(store.getState().folderWorkspaces).not.toBe(first) expect(store.getState().folderWorkspaces[0]?.linkedTask?.title).toBe('Issue 7 renamed') @@ -212,24 +212,24 @@ describe('catalog merge referential stability', () => { describe('folder path status cache retention across catalog fetches', () => { it('keeps cached path statuses when a project-group refetch changes nothing', async () => { const store = createTestStore() - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) expect(store.getState().folderWorkspacePathStatuses).toBe(cachedPathStatuses) }) it('clears cached path statuses when the project-group catalog changes', async () => { const store = createTestStore() - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) projectGroupsList.mockImplementation(async () => [ clone(projectGroup), clone(secondProjectGroup) ]) - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) expect(store.getState().folderWorkspacePathStatuses).toEqual({}) }) @@ -237,10 +237,10 @@ describe('folder path status cache retention across catalog fetches', () => { it('keeps cached path statuses when a folder-workspace refetch changes nothing', async () => { const store = createTestStore() store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) expect(store.getState().folderWorkspacePathStatuses).toBe(cachedPathStatuses) }) @@ -248,35 +248,42 @@ describe('folder path status cache retention across catalog fetches', () => { it('clears cached path statuses when a folder workspace path changes', async () => { const store = createTestStore() store.setState({ projectGroups: [{ ...projectGroup, executionHostId: 'local' }] }) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) folderWorkspacesList.mockImplementation(async () => [ clone({ ...folderWorkspace, folderPath: '/parent/folder-1-renamed' }) ]) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) expect(store.getState().folderWorkspacePathStatuses).toEqual({}) }) it('keeps cached path statuses when a repo refetch changes nothing', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().folderWorkspacePathStatuses).toBe(cachedPathStatuses) }) it('clears cached path statuses when the repo catalog changes', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) store.setState({ folderWorkspacePathStatuses: cachedPathStatuses }) reposList.mockImplementation(async () => [clone({ ...repo, path: '/repo-1-moved' })]) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().folderWorkspacePathStatuses).toEqual({}) }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-catalog-notifications.test.ts b/src/renderer/src/store/slices/repos-catalog-notifications.test.ts index 88fbd339ec3..cb06b6c788c 100644 --- a/src/renderer/src/store/slices/repos-catalog-notifications.test.ts +++ b/src/renderer/src/store/slices/repos-catalog-notifications.test.ts @@ -43,7 +43,7 @@ const cases: CatalogCase[] = [ { label: 'selected groups', catalog: 'projectGroups', - refresh: (s) => s.getState().fetchProjectGroups() + refresh: (s) => s.getState().fetchProjectGroups(catalogOwner(s)) }, { label: 'all-host local groups', @@ -53,7 +53,7 @@ const cases: CatalogCase[] = [ { label: 'selected folders', catalog: 'folderWorkspaces', - refresh: (s) => s.getState().fetchFolderWorkspaces() + refresh: (s) => s.getState().fetchFolderWorkspaces(catalogOwner(s)) }, { label: 'all-host local folders', @@ -188,7 +188,7 @@ it('accepts a newer update after an equal catalog response', async () => { const pending = Promise.withResolvers() folderUpdate.mockReturnValueOnce(pending.promise) const update = store.getState().updateFolderWorkspace(folder.id, { isUnread: true }) - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) pending.resolve({ ...folder, isUnread: true, updatedAt: 4 }) await update expect(store.getState().folderWorkspaces[0]?.isUnread).toBe(true) @@ -227,3 +227,10 @@ it('does not publish an all-host refresh whose restored owners need no cleanup', initial.restoredRuntimeHostIdByWorkspaceSessionKey ) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts b/src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts index 9169384b55e..3bc88df2c76 100644 --- a/src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts +++ b/src/renderer/src/store/slices/repos-cross-host-refresh-identity.test.ts @@ -142,7 +142,7 @@ describe('cross-host project refresh identity', () => { const afterAllHosts = sharedProject(store)?.sourceRepoIds expect(afterAllHosts).toHaveLength(2) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const afterLocal = sharedProject(store)?.sourceRepoIds await store.getState().fetchRuntimeEnvironmentRepos('env-1') @@ -158,13 +158,13 @@ describe('cross-host project refresh identity', () => { await store.getState().fetchReposForAllHosts() // Why: the first local pass lands before the runtime catalog exists, so settle onto the // two-host fixed point before measuring identity. - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const settled = sharedProject(store) await store.getState().fetchRuntimeEnvironmentRepos('env-1') expect(sharedProject(store)).toBe(settled) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(sharedProject(store)).toBe(settled) }) @@ -180,3 +180,10 @@ describe('cross-host project refresh identity', () => { expect(sharedProject(store)).toBe(settled) }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-module-lifetime-coordinators.test.ts b/src/renderer/src/store/slices/repos-module-lifetime-coordinators.test.ts index 2f75e8c0744..17e6a132463 100644 --- a/src/renderer/src/store/slices/repos-module-lifetime-coordinators.test.ts +++ b/src/renderer/src/store/slices/repos-module-lifetime-coordinators.test.ts @@ -98,11 +98,18 @@ describe('repo module-lifetime coordinators', () => { const firstStore = createTestStore() const secondStore = createTestStore() - await firstStore.getState().fetchRepos() - await secondStore.getState().fetchRepos() + await firstStore.getState().fetchRepos(catalogOwner(firstStore)) + await secondStore.getState().fetchRepos(catalogOwner(secondStore)) expect(syncFork).toHaveBeenCalledTimes(1) resolveSync() await sync }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-nested-import-refresh-failures.test.ts b/src/renderer/src/store/slices/repos-nested-import-refresh-failures.test.ts index e6fdd0b82e8..0e58e5c6baf 100644 --- a/src/renderer/src/store/slices/repos-nested-import-refresh-failures.test.ts +++ b/src/renderer/src/store/slices/repos-nested-import-refresh-failures.test.ts @@ -65,6 +65,7 @@ describe('nested import partial catalog refresh failures', () => { parentPath: '/workspace', groupName: 'Workspace', projectPaths: [], + runtimeEnvironmentId: null, mode: 'group' }) ).resolves.toEqual(result) diff --git a/src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts b/src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts index 9ced00f07fc..8016f354648 100644 --- a/src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts +++ b/src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts @@ -72,7 +72,7 @@ it('preserves distinct SSH execution setups behind the same runtime owner', asyn const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const setups = store.getState().projectHostSetups expect(setups).toHaveLength(2) @@ -145,7 +145,7 @@ it('prefers an authoritative paired-runtime setup over its repo-derived fallback const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projectHostSetups).toEqual([ { @@ -157,3 +157,10 @@ it('prefers an authoritative paired-runtime setup over its repo-derived fallback } ]) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-project-group-create-race.test.ts b/src/renderer/src/store/slices/repos-project-group-create-race.test.ts index 02025001385..44250355679 100644 --- a/src/renderer/src/store/slices/repos-project-group-create-race.test.ts +++ b/src/renderer/src/store/slices/repos-project-group-create-race.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { ExecutionHostId } from '../../../../shared/execution-host' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' import { createCompatibleRuntimeStatusResponseIfNeeded, @@ -67,16 +68,18 @@ function setup(runtimeEnvironmentId: string | null) { settings: { ...getDefaultSettings('/test'), activeRuntimeEnvironmentId: runtimeEnvironmentId }, projectGroups: [otherHostGroup] }) - const ownerHostId = runtimeEnvironmentId ? `runtime:${runtimeEnvironmentId}` : 'local' + const ownerHostId: ExecutionHostId = runtimeEnvironmentId + ? `runtime:${runtimeEnvironmentId}` + : 'local' return { store, created, createStarted, ownerHostId } } describe.each([null, 'env-1'])('project group creation on host %s', (runtimeEnvironmentId) => { it('keeps the refreshed row without notifying subscribers when refresh finishes first', async () => { const { store, created, createStarted, ownerHostId } = setup(runtimeEnvironmentId) - const pendingCreate = store.getState().createProjectGroup('Platform') + const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) await createStarted.promise - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) const refreshedState = store.getState() const listener = vi.fn() const unsubscribe = store.subscribe(listener) @@ -99,26 +102,26 @@ describe.each([null, 'env-1'])('project group creation on host %s', (runtimeEnvi it('inserts beside another host with the same ID, then accepts the later refresh', async () => { const { store, created, ownerHostId } = setup(runtimeEnvironmentId) - const pendingCreate = store.getState().createProjectGroup('Platform') + const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) created.resolve(projectGroup) await pendingCreate expect(store.getState().projectGroups).toEqual([ otherHostGroup, { ...projectGroup, executionHostId: ownerHostId } ]) - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) expect(store.getState().projectGroups).toEqual([ otherHostGroup, { ...refreshedGroup, executionHostId: ownerHostId } ]) const groups = store.getState().projectGroups - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) expect(store.getState().projectGroups).toBe(groups) }) it('keeps the original owner when the focused host changes during creation', async () => { const { store, created, createStarted, ownerHostId } = setup(runtimeEnvironmentId) - const pendingCreate = store.getState().createProjectGroup('Platform') + const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) await createStarted.promise store.setState({ settings: { ...getDefaultSettings('/test'), activeRuntimeEnvironmentId: 'other' } @@ -133,11 +136,11 @@ describe.each([null, 'env-1'])('project group creation on host %s', (runtimeEnvi }) it('does not roll back a successful refresh if the create response fails', async () => { - const { store, created, createStarted } = setup(runtimeEnvironmentId) + const { store, created, createStarted, ownerHostId } = setup(runtimeEnvironmentId) vi.spyOn(console, 'error').mockImplementation(() => {}) - const pendingCreate = store.getState().createProjectGroup('Platform') + const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) await createStarted.promise - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(catalogOwner(store)) const refreshedState = store.getState() created.reject(new Error('Create response lost')) await expect(pendingCreate).resolves.toBeNull() @@ -146,21 +149,21 @@ describe.each([null, 'env-1'])('project group creation on host %s', (runtimeEnvi }) it('recognizes an unstamped local group without conflating an SSH catalog row', async () => { - const { store, created } = setup(null) + const { store, created, ownerHostId } = setup(null) const sshGroup = { ...projectGroup, connectionId: 'server' } store.setState({ projectGroups: [sshGroup, refreshedGroup] }) const state = store.getState() - const pendingCreate = state.createProjectGroup('Platform') + const pendingCreate = state.createProjectGroup('Platform', ownerHostId) created.resolve(projectGroup) await pendingCreate expect(store.getState()).toBe(state) }) it('does not suppress a local group whose ID matches a direct SSH group', async () => { - const { store, created } = setup(null) + const { store, created, ownerHostId } = setup(null) const sshGroup = { ...projectGroup, connectionId: 'server' } store.setState({ projectGroups: [sshGroup] }) - const pendingCreate = store.getState().createProjectGroup('Platform') + const pendingCreate = store.getState().createProjectGroup('Platform', ownerHostId) created.resolve(projectGroup) await pendingCreate expect(store.getState().projectGroups).toEqual([ @@ -168,3 +171,10 @@ it('does not suppress a local group whose ID matches a direct SSH group', async { ...projectGroup, executionHostId: 'local' } ]) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-project-groups-delete.test.ts b/src/renderer/src/store/slices/repos-project-groups-delete.test.ts index 5e793f03a05..e56eabee30e 100644 --- a/src/renderer/src/store/slices/repos-project-groups-delete.test.ts +++ b/src/renderer/src/store/slices/repos-project-groups-delete.test.ts @@ -115,17 +115,18 @@ describe('project group deletion store routing', () => { result: { deleted: false }, _meta: { runtimeId: 'runtime-remote' } }) + const serverGroup = { ...projectGroup, executionHostId: 'runtime:env-1' as const } const groupedRepo = { ...remoteRepo, projectGroupId: projectGroup.id } const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - projectGroups: [projectGroup], + projectGroups: [serverGroup], repos: [groupedRepo] }) await expect(store.getState().deleteProjectGroup(projectGroup.id)).resolves.toBe(false) - expect(store.getState().projectGroups).toEqual([projectGroup]) + expect(store.getState().projectGroups).toEqual([serverGroup]) expect(store.getState().repos).toEqual([groupedRepo]) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', diff --git a/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts b/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts index b18fd5743dd..94711fdc113 100644 --- a/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts +++ b/src/renderer/src/store/slices/repos-project-groups-owner-routing.test.ts @@ -155,7 +155,7 @@ describe('project group mutations route to the owning host', () => { expect(store.getState().projectGroups).toMatchObject([{ executionHostId: 'local' }]) }) - it('keeps the focused host when a colliding id has no unambiguous owner', async () => { + it('refuses, rather than following focus, when a colliding id has no unambiguous owner', async () => { projectGroupsDelete.mockResolvedValue(true) const store = createTestStore() store.setState({ @@ -166,9 +166,9 @@ describe('project group mutations route to the owning host', () => { ] }) - await expect(store.getState().deleteProjectGroup(folderScanGroup.id)).resolves.toBe(true) + await expect(store.getState().deleteProjectGroup(folderScanGroup.id)).resolves.toBe(false) - expect(projectGroupsDelete).toHaveBeenCalledWith({ groupId: folderScanGroup.id }) + expect(projectGroupsDelete).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/store/slices/repos-project-groups.test.ts b/src/renderer/src/store/slices/repos-project-groups.test.ts index 281fbde53e8..e4565e8e91c 100644 --- a/src/renderer/src/store/slices/repos-project-groups.test.ts +++ b/src/renderer/src/store/slices/repos-project-groups.test.ts @@ -12,6 +12,8 @@ import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rp import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import type { SshConnectionState } from '../../../../shared/ssh-types' +const LOCAL_OWNER = { runtimeEnvironmentId: null } + const remoteRepo: Repo = { id: 'remote-repo', path: '/remote', @@ -121,7 +123,7 @@ describe('project group store routing', () => { projectGroupsCreate.mockResolvedValue(projectGroup) const store = createTestStore() - await expect(store.getState().createProjectGroup('Platform')).resolves.toEqual({ + await expect(store.getState().createProjectGroup('Platform', 'local')).resolves.toEqual({ ...projectGroup, executionHostId: 'local' }) @@ -139,7 +141,7 @@ describe('project group store routing', () => { projectGroupsList.mockResolvedValue([folderGroup]) const store = createTestStore() - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups(LOCAL_OWNER) expect(store.getState().projectGroups).toEqual([{ ...folderGroup, executionHostId: 'local' }]) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() @@ -160,7 +162,7 @@ describe('project group store routing', () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups({ runtimeEnvironmentId: 'env-1' }) expect(store.getState().projectGroups).toEqual([ { ...folderGroup, executionHostId: 'runtime:env-1' } @@ -178,7 +180,7 @@ describe('project group store routing', () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await store.getState().fetchProjectGroups() + await store.getState().fetchProjectGroups({ runtimeEnvironmentId: 'env-1' }) expect(store.getState().projectGroups).toEqual([ { ...folderGroup, executionHostId: 'runtime:env-1' } @@ -215,9 +217,11 @@ describe('project group store routing', () => { }) ).resolves.toEqual({ path: '/workspace/platform', exists: true }) - expect(store.getState().getFolderWorkspacePathStatusCacheKey(request)).toBe( - `environment:wrong-env:project-group:${folderGroup.id}` - ) + expect( + store + .getState() + .getFolderWorkspacePathStatusCacheKey(request, { runtimeEnvironmentId: 'wrong-env' }) + ).toBe(`environment:wrong-env:project-group:${folderGroup.id}`) expect( store .getState() @@ -342,11 +346,12 @@ describe('project group store routing', () => { const store = createTestStore() await expect( - store.getState().createFolderWorkspace({ - projectGroupId: projectGroup.id, - name: 'Refund fix', - linkedTask - }) + store + .getState() + .createFolderWorkspace( + { projectGroupId: projectGroup.id, name: 'Refund fix', linkedTask }, + LOCAL_OWNER + ) ).resolves.toEqual({ ...folderWorkspace, executionHostId: 'local' }) await expect( store.getState().updateFolderWorkspace(folderWorkspace.id, { comment: 'Ready' }) @@ -380,20 +385,24 @@ describe('project group store routing', () => { store.setState({ projectGroups: [folderGroup] }) await expect( - store.getState().fetchFolderWorkspacePathStatus({ - scope: 'project-group', - projectGroupId: folderGroup.id - }) + store + .getState() + .fetchFolderWorkspacePathStatus( + { scope: 'project-group', projectGroupId: folderGroup.id }, + LOCAL_OWNER + ) ).resolves.toEqual({ path: '/workspace/platform', exists: false, reason: 'missing' }) - const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey({ - scope: 'project-group', - projectGroupId: folderGroup.id - }) + const cacheKey = store + .getState() + .getFolderWorkspacePathStatusCacheKey( + { scope: 'project-group', projectGroupId: folderGroup.id }, + LOCAL_OWNER + ) expect(store.getState().folderWorkspacePathStatuses[cacheKey]?.status).toEqual({ path: '/workspace/platform', exists: false, @@ -415,7 +424,7 @@ describe('project group store routing', () => { projectGroups: [{ ...projectGroup, parentPath: '/workspace/old-platform' }] }) const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - const statusPromise = store.getState().fetchFolderWorkspacePathStatus(request) + const statusPromise = store.getState().fetchFolderWorkspacePathStatus(request, LOCAL_OWNER) store.setState({ projectGroups: [{ ...projectGroup, parentPath: '/workspace/new-platform' }] @@ -423,7 +432,7 @@ describe('project group store routing', () => { resolveStatus({ path: '/workspace/old-platform', exists: true }) await statusPromise - const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request) + const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request, LOCAL_OWNER) expect(store.getState().folderWorkspacePathStatuses[cacheKey]).toBeUndefined() }) @@ -441,7 +450,7 @@ describe('project group store routing', () => { repos: [{ ...remoteRepo, id: 'local-repo', path: '/workspace/platform/api' }] }) const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - const statusPromise = store.getState().fetchFolderWorkspacePathStatus(request) + const statusPromise = store.getState().fetchFolderWorkspacePathStatus(request, LOCAL_OWNER) store.setState({ repos: [ @@ -456,7 +465,7 @@ describe('project group store routing', () => { resolveStatus({ path: '/workspace/platform', exists: true }) await statusPromise - const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request) + const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request, LOCAL_OWNER) expect(store.getState().folderWorkspacePathStatuses[cacheKey]).toBeUndefined() }) @@ -468,16 +477,16 @@ describe('project group store routing', () => { projectGroups: [{ ...projectGroup, parentPath: '/workspace/platform' }] }) const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - await store.getState().fetchFolderWorkspacePathStatus(request) + await store.getState().fetchFolderWorkspacePathStatus(request, LOCAL_OWNER) - expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toEqual({ + expect(store.getState().getFreshFolderWorkspacePathStatus(request, LOCAL_OWNER)).toEqual({ path: '/workspace/platform', exists: true }) vi.setSystemTime(Date.now() + 10_001) - expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toBeNull() + expect(store.getState().getFreshFolderWorkspacePathStatus(request, LOCAL_OWNER)).toBeNull() } finally { vi.useRealTimers() } @@ -492,9 +501,9 @@ describe('project group store routing', () => { sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('connected')]]) }) const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - await store.getState().fetchFolderWorkspacePathStatus(request) + await store.getState().fetchFolderWorkspacePathStatus(request, LOCAL_OWNER) - expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toEqual({ + expect(store.getState().getFreshFolderWorkspacePathStatus(request, LOCAL_OWNER)).toEqual({ path: '/workspace/platform', exists: true }) @@ -503,7 +512,7 @@ describe('project group store routing', () => { sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('disconnected')]]) }) - expect(store.getState().getFreshFolderWorkspacePathStatus(request)).toBeNull() + expect(store.getState().getFreshFolderWorkspacePathStatus(request, LOCAL_OWNER)).toBeNull() }) it('ignores stale folder path status responses after SSH connection state changes', async () => { @@ -522,14 +531,16 @@ describe('project group store routing', () => { sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('connected')]]) }) const request = { scope: 'project-group' as const, projectGroupId: projectGroup.id } - const connectedStatusPromise = store.getState().fetchFolderWorkspacePathStatus(request) + const connectedStatusPromise = store + .getState() + .fetchFolderWorkspacePathStatus(request, LOCAL_OWNER) store.setState({ sshConnectionStates: new Map([['ssh-1', makeSshConnectionState('disconnected')]]) }) const disconnectedStatusPromise = store .getState() - .fetchFolderWorkspacePathStatus(request, { force: true }) + .fetchFolderWorkspacePathStatus(request, { force: true, ...LOCAL_OWNER }) resolvers[1]?.({ path: '/workspace/platform', @@ -540,7 +551,7 @@ describe('project group store routing', () => { resolvers[0]?.({ path: '/workspace/platform', exists: true }) await connectedStatusPromise - const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request) + const cacheKey = store.getState().getFolderWorkspacePathStatusCacheKey(request, LOCAL_OWNER) expect(store.getState().folderWorkspacePathStatuses[cacheKey]?.status).toEqual({ path: '/workspace/platform', exists: false, @@ -685,21 +696,16 @@ describe('project group store routing', () => { reposList.mockResolvedValue([importedRepo]) const store = createTestStore() - await expect( - store.getState().importNestedRepos({ - parentPath: '/platform', - groupName: 'Platform', - projectPaths: [importedRepo.path], - mode: 'group' - }) - ).resolves.toEqual(result) - - expect(projectGroupsImportNested).toHaveBeenCalledWith({ + const importArgs = { parentPath: '/platform', groupName: 'Platform', projectPaths: [importedRepo.path], - mode: 'group' - }) + mode: 'group' as const, + ...LOCAL_OWNER + } + await expect(store.getState().importNestedRepos(importArgs)).resolves.toEqual(result) + + expect(projectGroupsImportNested).toHaveBeenCalledWith(importArgs) expect(projectGroupsList).toHaveBeenCalled() expect(folderWorkspacesList).toHaveBeenCalled() expect(reposList).toHaveBeenCalled() @@ -736,6 +742,7 @@ describe('project group store routing', () => { await expect( store.getState().scanNestedRepos('/platform', undefined, { + ...LOCAL_OWNER, scanId: 'scan-1', onProgress: progressCallback }) @@ -759,6 +766,7 @@ describe('project group store routing', () => { await expect( store.getState().scanNestedRepos('/platform', undefined, { + ...LOCAL_OWNER, scanId: 'scan-1', onProgress: vi.fn() }) @@ -771,7 +779,7 @@ describe('project group store routing', () => { projectGroupsCancelNestedScan.mockResolvedValue(true) const store = createTestStore() - await expect(store.getState().cancelNestedRepoScan('scan-1')).resolves.toBe(true) + await expect(store.getState().cancelNestedRepoScan('scan-1', LOCAL_OWNER)).resolves.toBe(true) expect(projectGroupsCancelNestedScan).toHaveBeenCalledWith({ scanId: 'scan-1' }) }) @@ -780,7 +788,9 @@ describe('project group store routing', () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await expect(store.getState().cancelNestedRepoScan('scan-1')).resolves.toBe(false) + await expect( + store.getState().cancelNestedRepoScan('scan-1', { runtimeEnvironmentId: 'env-1' }) + ).resolves.toBe(false) expect(projectGroupsCancelNestedScan).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).not.toHaveBeenCalled() @@ -854,10 +864,12 @@ describe('project group store routing', () => { const store = createTestStore() await expect( - store.getState().createFolderWorkspace({ - projectGroupId: projectGroup.id, - name: 'Broken folder' - }) + store + .getState() + .createFolderWorkspace( + { projectGroupId: projectGroup.id, name: 'Broken folder' }, + LOCAL_OWNER + ) ).rejects.toThrow( 'Folder not found. Orca cannot find /srv/app. Remove and re-import the folder.' ) diff --git a/src/renderer/src/store/slices/repos-project-host-capability.test.ts b/src/renderer/src/store/slices/repos-project-host-capability.test.ts index 2bed7180315..c8689034cde 100644 --- a/src/renderer/src/store/slices/repos-project-host-capability.test.ts +++ b/src/renderer/src/store/slices/repos-project-host-capability.test.ts @@ -76,7 +76,7 @@ describe('repo slice project-host setup runtime capability', () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projectHostSetups).toEqual([ expect.objectContaining({ id: 'remote-repo', hostId: 'runtime:env-1' }) @@ -145,3 +145,10 @@ describe('repo slice project-host setup runtime capability', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts b/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts index 8ce6455754a..b88c67d5fef 100644 --- a/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts +++ b/src/renderer/src/store/slices/repos-project-host-lifecycle.test.ts @@ -271,7 +271,7 @@ describe('repo slice project host setup lifecycle', () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projectHostSetups).toEqual( expect.arrayContaining([ @@ -284,3 +284,10 @@ describe('repo slice project host setup lifecycle', () => { ) }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-project-runtime.test.ts b/src/renderer/src/store/slices/repos-project-runtime.test.ts index 6df79a4af58..b59fa4c47a9 100644 --- a/src/renderer/src/store/slices/repos-project-runtime.test.ts +++ b/src/renderer/src/store/slices/repos-project-runtime.test.ts @@ -92,9 +92,13 @@ describe('repo slice project runtime updates', () => { isRefreshingAgents: true }) - await store.getState().updateProject(project.id, { - localWindowsRuntimePreference: { kind: 'windows-host' } - }) + await store.getState().updateProject( + project.id, + { + localWindowsRuntimePreference: { kind: 'windows-host' } + }, + 'local' + ) expect(store.getState().detectedAgentIds).toBeNull() expect(store.getState().isDetectingAgents).toBe(false) @@ -128,7 +132,7 @@ describe('repo slice project runtime updates', () => { reposList.mockResolvedValue([localRepo]) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects).toEqual([project]) expect(store.getState().projectHostSetups).toEqual([setup]) @@ -172,7 +176,7 @@ describe('repo slice project runtime updates', () => { const store = createTestStore() store.setState({ projects: [staleProject], projectHostSetups: [setup] }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() }) @@ -193,9 +197,13 @@ describe('repo slice project runtime updates', () => { const store = createTestStore() store.setState({ projects: [project] }) - await store.getState().updateProject(project.id, { - localWindowsRuntimePreference: { kind: 'windows-host' } - }) + await store.getState().updateProject( + project.id, + { + localWindowsRuntimePreference: { kind: 'windows-host' } + }, + 'local' + ) expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ kind: 'windows-host' @@ -207,7 +215,55 @@ describe('repo slice project runtime updates', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) - it('updates remote project runtime preferences through the active runtime', async () => { + it('updates a local project on this computer even while a server with a setup is the default host', async () => { + const project: Project = { + id: 'project-1', + displayName: 'Project', + badgeColor: '#000', + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 + } + projectsUpdate.mockResolvedValue({ + ...project, + localWindowsRuntimePreference: { kind: 'windows-host' } + }) + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + projects: [project], + projectHostSetups: [ + { + id: 'setup-1', + projectId: project.id, + hostId: 'runtime:env-1', + repoId: 'remote-repo', + path: '/srv/repo', + displayName: 'Remote', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1 + } + ] + }) + + await store + .getState() + .updateProject( + project.id, + { localWindowsRuntimePreference: { kind: 'windows-host' } }, + 'local' + ) + + expect(projectsUpdate).toHaveBeenCalledWith({ + projectId: project.id, + updates: { localWindowsRuntimePreference: { kind: 'windows-host' } } + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('updates remote project runtime preferences through the owning runtime', async () => { const project: Project = { id: 'project-1', displayName: 'Project', @@ -247,9 +303,13 @@ describe('repo slice project runtime updates', () => { ] }) - await store.getState().updateProject(project.id, { - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - }) + await store.getState().updateProject( + project.id, + { + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }, + 'runtime:env-1' + ) expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ kind: 'wsl', @@ -288,9 +348,13 @@ describe('repo slice project runtime updates', () => { const store = createTestStore() store.setState({ projects: [project] }) - await store.getState().updateProject(project.id, { - localWindowsRuntimePreference: { kind: 'windows-host' } - }) + await store.getState().updateProject( + project.id, + { + localWindowsRuntimePreference: { kind: 'windows-host' } + }, + 'local' + ) expect(store.getState().projects[0]?.createdAt).toBe(100) expect(store.getState().projects[0]?.updatedAt).toBe(100) @@ -314,9 +378,13 @@ describe('repo slice project runtime updates', () => { const store = createTestStore() store.setState({ projects: [project] }) - await store.getState().updateProject(project.id, { - localWindowsRuntimePreference: { kind: 'windows-host' } - }) + await store.getState().updateProject( + project.id, + { + localWindowsRuntimePreference: { kind: 'windows-host' } + }, + 'local' + ) expect(store.getState().projects[0]?.sourceRepoIds).toEqual(['local-repo', 'remote-repo']) expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ @@ -345,11 +413,22 @@ describe('repo slice project runtime updates', () => { const store = createTestStore() store.setState({ projects: [project] }) - await store.getState().updateProject(project.id, { - localWindowsRuntimePreference: undefined - }) + await store.getState().updateProject( + project.id, + { + localWindowsRuntimePreference: undefined + }, + 'local' + ) expect(store.getState().projects[0]?.sourceRepoIds).toEqual(['local-repo', 'remote-repo']) expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-refresh-identity.test.ts b/src/renderer/src/store/slices/repos-refresh-identity.test.ts index 78cc36eb0e7..0bb06dddcd5 100644 --- a/src/renderer/src/store/slices/repos-refresh-identity.test.ts +++ b/src/renderer/src/store/slices/repos-refresh-identity.test.ts @@ -82,13 +82,13 @@ beforeEach(() => { describe('repo catalog refresh identity', () => { it('keeps the projects and host setups arrays and entries across a no-op refetch', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const projects = store.getState().projects const setups = store.getState().projectHostSetups expect(projects).toHaveLength(1) expect(setups).toHaveLength(1) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects).toBe(projects) expect(store.getState().projects[0]).toBe(projects[0]) @@ -98,11 +98,11 @@ describe('repo catalog refresh identity', () => { it('lets a nested hookSettings change through', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const setups = store.getState().projectHostSetups mockRepos({ ...repo, hookSettings: { mode: 'override', scripts: { setup: '', archive: '' } } }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projectHostSetups).not.toBe(setups) expect(store.getState().projectHostSetups[0]?.hookSettings?.mode).toBe('override') @@ -110,11 +110,11 @@ describe('repo catalog refresh identity', () => { it('lets a displayName change through on projects', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const projects = store.getState().projects mockRepos({ ...repo, displayName: 'Renamed' }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects).not.toBe(projects) expect(store.getState().projects[0]?.displayName).toBe('Renamed') @@ -122,11 +122,11 @@ describe('repo catalog refresh identity', () => { it('lets an array-field change through', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const repos = store.getState().repos mockRepos({ ...repo, symlinkPaths: ['.env'] }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().repos).not.toBe(repos) expect(store.getState().repos[0]?.symlinkPaths).toEqual(['.env']) @@ -134,14 +134,14 @@ describe('repo catalog refresh identity', () => { it('lets a nested gitRemoteIdentity change through', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const projects = store.getState().projects mockRepos({ ...repo, gitRemoteIdentity: { ...repo.gitRemoteIdentity!, remoteName: 'upstream' } }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects).not.toBe(projects) expect(store.getState().projects[0]?.gitRemoteIdentity?.remoteName).toBe('upstream') @@ -149,7 +149,7 @@ describe('repo catalog refresh identity', () => { it('treats clearing localWindowsRuntimePreference as a change', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const projectId = store.getState().projects[0]!.id const withPreference: Project = { ...store.getState().projects[0]!, @@ -157,7 +157,7 @@ describe('repo catalog refresh identity', () => { } store.setState({ projects: [withPreference] }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) // Why: a local-host refresh is authoritative; an absent key must not read as unchanged. expect(store.getState().projects[0]).not.toBe(withPreference) @@ -168,12 +168,12 @@ describe('repo catalog refresh identity', () => { it('reuses an unchanged setup element while a sibling changes', async () => { mockRepos(repo, secondRepo) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const setups = store.getState().projectHostSetups expect(setups).toHaveLength(2) mockRepos(repo, { ...secondRepo, displayName: 'Repo 2 renamed' }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const next = store.getState().projectHostSetups expect(next).not.toBe(setups) @@ -183,14 +183,14 @@ describe('repo catalog refresh identity', () => { it('does not reuse a setup that moved to a different execution host', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const setups = store.getState().projectHostSetups expect(setups[0]?.hostId).toBe('local') // Why: the repo-derived fallback sets setup.id = repo.id, so the same id on a second host is // the case that would silently splice the wrong host's routing metadata into the row. mockRepos({ ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const next = store.getState().projectHostSetups expect(next[0]).not.toBe(setups[0]) @@ -204,12 +204,12 @@ describe('repo catalog refresh identity', () => { const sshRepo: Repo = { ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' } mockRepos(repo, sshRepo) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const setups = store.getState().projectHostSetups expect(setups).toHaveLength(2) expect(new Set(setups.map((setup) => setup.id)).size).toBe(1) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projectHostSetups).toBe(setups) expect(store.getState().projectHostSetups[0]).toBe(setups[0]) @@ -219,11 +219,11 @@ describe('repo catalog refresh identity', () => { it('drops a project and its setup when its repo disappears', async () => { mockRepos(repo, secondRepo) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const projects = store.getState().projects mockRepos(repo) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects).not.toBe(projects) expect(store.getState().projects).toHaveLength(1) @@ -237,7 +237,7 @@ describe('repo catalog refresh identity', () => { const sshRepo: Repo = { ...secondRepo, executionHostId: 'ssh:host-a', connectionId: 'host-a' } mockRepos(repo, sshRepo) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const sshOwned: Project = { id: 'ssh-owned', displayName: 'SSH Owned', @@ -251,7 +251,7 @@ describe('repo catalog refresh identity', () => { projectHostSetups: [] }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects.map((project) => project.id)).toContain('ssh-owned') }) @@ -262,7 +262,7 @@ describe('repo catalog refresh identity', () => { const sshRepo: Repo = { ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' } mockRepos(repo, sshRepo) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const dualHostOwned: Project = { id: 'dual-host-owned', displayName: 'Dual Host Owned', @@ -276,18 +276,18 @@ describe('repo catalog refresh identity', () => { projectHostSetups: [] }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects.map((project) => project.id)).toContain('dual-host-owned') }) it('adds a project and its setup when a repo appears', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const projects = store.getState().projects mockRepos(repo, secondRepo) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects).not.toBe(projects) expect(store.getState().projects).toHaveLength(2) @@ -297,7 +297,7 @@ describe('repo catalog refresh identity', () => { it('keeps catalog identity when repo.addedAt is 0', async () => { mockRepos({ ...repo, addedAt: 0 }) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const projects = store.getState().projects const setups = store.getState().projectHostSetups expect(projects).toHaveLength(1) @@ -305,7 +305,7 @@ describe('repo catalog refresh identity', () => { expect(projects[0]?.createdAt).toBe(0) expect(setups[0]?.createdAt).toBe(0) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects).toBe(projects) expect(store.getState().projects[0]).toBe(projects[0]) @@ -316,7 +316,7 @@ describe('repo catalog refresh identity', () => { it('keeps catalog identity when repo.addedAt is omitted', async () => { mockRepos(omitAddedAt(repo)) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const projects = store.getState().projects const setups = store.getState().projectHostSetups expect(projects).toHaveLength(1) @@ -324,7 +324,7 @@ describe('repo catalog refresh identity', () => { expect(projects[0]?.createdAt).toBe(0) expect(setups[0]?.createdAt).toBe(0) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projects).toBe(projects) expect(store.getState().projects[0]).toBe(projects[0]) @@ -335,7 +335,7 @@ describe('repo catalog refresh identity', () => { it('lets a nested hookSettings change through when repo.addedAt is 0', async () => { mockRepos({ ...repo, addedAt: 0 }) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const setups = store.getState().projectHostSetups mockRepos({ @@ -343,7 +343,7 @@ describe('repo catalog refresh identity', () => { addedAt: 0, hookSettings: { mode: 'override', scripts: { setup: '', archive: '' } } }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().projectHostSetups).not.toBe(setups) expect(store.getState().projectHostSetups[0]).not.toBe(setups[0]) @@ -357,7 +357,7 @@ describe('repo filter identity across catalog refreshes', () => { store.setState({ filterRepoIds: [repo.id] }) const first = store.getState().filterRepoIds - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) // Why: App.tsx at the root and five sidebar consumers select this array by identity. expect(store.getState().filterRepoIds).toBe(first) @@ -367,7 +367,7 @@ describe('repo filter identity across catalog refreshes', () => { const store = createTestStore() store.setState({ filterRepoIds: [repo.id, 'gone'] }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().filterRepoIds).toEqual([repo.id]) }) @@ -378,7 +378,7 @@ describe('repo filter identity across catalog refreshes', () => { store.setState({ filterRepoIds: [repo.id, secondRepo.id] }) const first = store.getState().filterRepoIds - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().filterRepoIds).toEqual([repo.id]) expect(store.getState().filterRepoIds).not.toBe(first) @@ -391,7 +391,7 @@ describe('repo filter identity across catalog refreshes', () => { // Why: the filter is keyed on repo id, not host identity — a rehomed repo is not pruned. mockRepos({ ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().filterRepoIds).toBe(first) }) @@ -406,7 +406,7 @@ describe('setup-script dismissal identity across catalog refreshes', () => { }) const first = store.getState().setupScriptPromptDismissedRepoIds - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) // Why: SetupScriptPromptCard Object.is-subscribes to this array. A no-op // catalog refresh must not allocate just because the helper rebuilt next. @@ -417,7 +417,7 @@ describe('setup-script dismissal identity across catalog refreshes', () => { describe('SSH readoption catalog identity', () => { it('keeps projects and host setups across a no-op recordSshRepoReadoptions([])', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const before = store.getState() const projects = before.projects const setups = before.projectHostSetups @@ -438,7 +438,7 @@ describe('SSH readoption catalog identity', () => { it('keeps catalog identity for a pending-only readoption while pending updates', async () => { const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const projects = store.getState().projects const setups = store.getState().projectHostSetups const readoption = { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: [repo.id] } @@ -465,7 +465,7 @@ describe('SSH readoption catalog identity', () => { } mockRepos(oldHostRepo, newHostRepo) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) const setups = store.getState().projectHostSetups expect(setups).toHaveLength(2) const oldSetup = setups.find((setup) => setup.hostId === 'ssh:ssh-old') @@ -489,3 +489,10 @@ describe('SSH readoption catalog identity', () => { expect(store.getState().pendingSshRepoReadoptions).toEqual([]) }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-runtime-project-groups.test.ts b/src/renderer/src/store/slices/repos-runtime-project-groups.test.ts index c01ccf818b7..1473c633053 100644 --- a/src/renderer/src/store/slices/repos-runtime-project-groups.test.ts +++ b/src/renderer/src/store/slices/repos-runtime-project-groups.test.ts @@ -61,7 +61,7 @@ describe('repo slice runtime project groups', () => { repos: [localOrca] }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().repos).toEqual([ localOrca, @@ -73,3 +73,10 @@ describe('repo slice runtime project groups', () => { ]) }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-selected-owner-routing.test.ts b/src/renderer/src/store/slices/repos-selected-owner-routing.test.ts index 37922e968d8..f1d2aa804c8 100644 --- a/src/renderer/src/store/slices/repos-selected-owner-routing.test.ts +++ b/src/renderer/src/store/slices/repos-selected-owner-routing.test.ts @@ -186,8 +186,8 @@ describe('selected Add Project owner routing', () => { projectGroupsList.mockResolvedValue([localGroup]) folderWorkspacesList.mockResolvedValue([localFolder]) store.setState({ settings: { activeRuntimeEnvironmentId: null } as never }) - await store.getState().fetchProjectGroups() - await store.getState().fetchFolderWorkspaces() + await store.getState().fetchProjectGroups(catalogOwner(store)) + await store.getState().fetchFolderWorkspaces(catalogOwner(store)) expect(store.getState().projectGroups).toEqual([ localGroup, @@ -536,3 +536,10 @@ describe('selected Add Project owner routing', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-setup-script-dismissals.test.ts b/src/renderer/src/store/slices/repos-setup-script-dismissals.test.ts index 6ddcdf99f9f..08f3709a3e4 100644 --- a/src/renderer/src/store/slices/repos-setup-script-dismissals.test.ts +++ b/src/renderer/src/store/slices/repos-setup-script-dismissals.test.ts @@ -38,10 +38,17 @@ describe('repo setup script prompt dismissals', () => { ] }) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().setupScriptPromptDismissedRepoIds).toEqual([ getSetupScriptPromptDismissalKey(localIdentity) ]) }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos-stale-fetch.test.ts b/src/renderer/src/store/slices/repos-stale-fetch.test.ts index 8993c823ec0..38e38d78cb9 100644 --- a/src/renderer/src/store/slices/repos-stale-fetch.test.ts +++ b/src/renderer/src/store/slices/repos-stale-fetch.test.ts @@ -82,8 +82,8 @@ describe('repos slice stale-fetch race (#7020)', () => { // post-removal state (remoteRepo gone) and resolves first. reposList.mockReturnValueOnce(stalePromise).mockResolvedValueOnce([localRepo]) - const stale = store.getState().fetchRepos() - const fresh = store.getState().fetchRepos() + const stale = store.getState().fetchRepos(catalogOwner(store)) + const fresh = store.getState().fetchRepos(catalogOwner(store)) await fresh expect(store.getState().repos.map((repo) => repo.id)).toEqual(['local-repo']) @@ -105,8 +105,8 @@ describe('repos slice stale-fetch race (#7020)', () => { // the stale one, which must be dropped rather than resurrect remoteRepo. reposList.mockReturnValueOnce(stalePromise).mockRejectedValueOnce(new Error('boom')) - const stale = store.getState().fetchRepos() - await store.getState().fetchRepos() + const stale = store.getState().fetchRepos(catalogOwner(store)) + await store.getState().fetchRepos(catalogOwner(store)) resolveStale([localRepo, remoteRepo]) await stale @@ -119,7 +119,7 @@ describe('repos slice stale-fetch race (#7020)', () => { const store = createTestStore() store.setState({ repos: [localRepo] }) - const pending = store.getState().fetchRepos() + const pending = store.getState().fetchRepos(catalogOwner(store)) await store .getState() .removeProject(localRepo.id, { hostId: getRepoExecutionHostId(localRepo) }) @@ -145,7 +145,7 @@ describe('repos slice stale-fetch race (#7020)', () => { const store = createTestStore() const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) - const refresh = store.getState().fetchRepos() + const refresh = store.getState().fetchRepos(catalogOwner(store)) resolveStartup([localRepo]) await startup @@ -184,7 +184,7 @@ describe('repos slice stale-fetch race (#7020)', () => { await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) await store.getState().awaitLocalRepoCatalogSettlement() - const refresh = store.getState().fetchRepos() + const refresh = store.getState().fetchRepos(catalogOwner(store)) let hydrated = false const hydration = (async () => { await store.getState().awaitLocalRepoCatalogSettlement() @@ -210,7 +210,7 @@ describe('repos slice stale-fetch race (#7020)', () => { const store = createTestStore() const startup = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) - const refresh = store.getState().fetchRepos() + const refresh = store.getState().fetchRepos(catalogOwner(store)) resolveStartup([localRepo]) await Promise.all([startup, refresh]) @@ -224,3 +224,10 @@ describe('repos slice stale-fetch race (#7020)', () => { expect(store.getState().pendingReconnectPtyIdByTabId).toEqual({}) }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index 4c9a2897201..a1ecb4863a2 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -37,13 +37,15 @@ vi.mock('sonner', () => ({ installReposRuntimeRoutingHarness() const ownerOf = (repo: Repo) => ({ hostId: getRepoExecutionHostId(repo) }) +// Ingest stamps every server row; an unstamped row is this app's own. +const serverRepo: Repo = { ...remoteRepo, executionHostId: 'runtime:env-1' } describe('repo slice runtime routing', () => { it('fetches repos from local IPC when no remote environment is active', async () => { reposList.mockResolvedValue([localRepo]) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) expect(store.getState().projects).toEqual([ @@ -73,10 +75,10 @@ describe('repo slice runtime routing', () => { }) reposList.mockImplementation(async () => [hydrated()]) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) const reposRef = store.getState().repos - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) // Why: identity-keyed renderer memos (repo lookup index, selectors) rebuild on a new array. expect(store.getState().repos).toBe(reposRef) @@ -86,11 +88,11 @@ describe('repo slice runtime routing', () => { it('replaces the repos array identity when a refetch adds a repo', async () => { reposList.mockResolvedValue([localRepo]) const store = createTestStore() - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) const reposRef = store.getState().repos reposList.mockResolvedValue([localRepo, { ...localRepo, id: 'second', path: '/second' }]) - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: null }) expect(store.getState().repos).not.toBe(reposRef) expect(store.getState().repos).toHaveLength(2) @@ -110,7 +112,7 @@ describe('repo slice runtime routing', () => { filterRepoIds: ['remote-repo', 'stale-repo'] }) - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: 'env-1' }) expect(store.getState().repos).toEqual([{ ...remoteRepo, executionHostId: 'runtime:env-1' }]) expect(store.getState().projects).toEqual([ @@ -140,14 +142,14 @@ describe('repo slice runtime routing', () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) - await store.getState().fetchRepos() + await store.getState().fetchRepos({ runtimeEnvironmentId: 'env-1' }) expect(store.getState().repos).toEqual([ { ...remoteRepo, connectionId: 'ssh-1', executionHostId: 'runtime:env-1' } ]) }) - it('updates repos through the active remote runtime environment', async () => { + it('updates repos through their owning runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-2', ok: true, @@ -157,7 +159,7 @@ describe('repo slice runtime routing', () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - repos: [remoteRepo] + repos: [serverRepo] }) await store.getState().updateRepo(remoteRepo.id, { displayName: 'Renamed' }) @@ -647,7 +649,7 @@ describe('repo slice runtime routing', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) - it('removes repos through the active remote runtime environment', async () => { + it('removes repos through their owning runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-3', ok: true, @@ -657,11 +659,11 @@ describe('repo slice runtime routing', () => { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - repos: [remoteRepo], + repos: [serverRepo], activeRepoId: remoteRepo.id }) - await store.getState().removeProject(remoteRepo.id, ownerOf(remoteRepo)) + await store.getState().removeProject(remoteRepo.id, ownerOf(serverRepo)) expect(store.getState().repos).toEqual([]) expect(store.getState().activeRepoId).toBeNull() @@ -726,7 +728,7 @@ describe('repo slice runtime routing', () => { const localWorktreeId = `${localRepo.id}::/local/wt` store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - repos: [remoteRepo, localRepo], + repos: [serverRepo, localRepo], activeRepoId: remoteRepo.id, lastVisitedAtByWorktreeId: { [remoteWorktreeId]: 100, @@ -734,7 +736,7 @@ describe('repo slice runtime routing', () => { } }) - await store.getState().removeProject(remoteRepo.id, ownerOf(remoteRepo)) + await store.getState().removeProject(remoteRepo.id, ownerOf(serverRepo)) expect(store.getState().repos).toEqual([localRepo]) expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ [localWorktreeId]: 200 }) @@ -784,9 +786,11 @@ describe('repo slice runtime routing', () => { const worktreeId = `${remoteRepo.id}::/remote/wt` store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - repos: [remoteRepo], + repos: [serverRepo], worktreesByRepo: { - [remoteRepo.id]: [makeWorktree({ id: worktreeId, repoId: remoteRepo.id })] + [remoteRepo.id]: [ + makeWorktree({ id: worktreeId, repoId: remoteRepo.id, hostId: 'runtime:env-1' }) + ] }, tabsByWorktree: { [worktreeId]: [{ id: 'tab-1', worktreeId } as never] @@ -796,7 +800,7 @@ describe('repo slice runtime routing', () => { } }) - await store.getState().removeProject(remoteRepo.id, ownerOf(remoteRepo)) + await store.getState().removeProject(remoteRepo.id, ownerOf(serverRepo)) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', diff --git a/src/renderer/src/store/slices/runtime-catalog-merge-removed-env-guard.test.ts b/src/renderer/src/store/slices/runtime-catalog-merge-removed-env-guard.test.ts index 76a28ae2634..d21158a7060 100644 --- a/src/renderer/src/store/slices/runtime-catalog-merge-removed-env-guard.test.ts +++ b/src/renderer/src/store/slices/runtime-catalog-merge-removed-env-guard.test.ts @@ -110,8 +110,15 @@ describe('environment-catalog merge guard drops catalogs for a removed env', () repo('localStamped', { executionHostId: 'runtime:some-client' }) ]) - await store.getState().fetchRepos() + await store.getState().fetchRepos(catalogOwner(store)) expect(store.getState().repos.map((r) => r.id)).toContain('localStamped') }) }) + +// The catalog these tests read is the one the focused host would have shown. +function catalogOwner(store: { + getState: () => { settings: { activeRuntimeEnvironmentId?: string | null } | null } +}): { runtimeEnvironmentId: string | null } { + return { runtimeEnvironmentId: store.getState().settings?.activeRuntimeEnvironmentId ?? null } +} diff --git a/src/renderer/src/store/slices/settings.ts b/src/renderer/src/store/slices/settings.ts index 3f645aec4e0..12835039f67 100644 --- a/src/renderer/src/store/slices/settings.ts +++ b/src/renderer/src/store/slices/settings.ts @@ -288,7 +288,8 @@ export const createSettingsSlice: StateCreator } // Why: hydration is host-merged by downstream slices. Switching focus // should add/update the selected host without discarding other hosts. - await get().fetchRepos() + // Why: hydrate the newly chosen default host's catalog; other hosts keep theirs. + await get().fetchRepos({ runtimeEnvironmentId: nextId }) await get().fetchAllWorktrees() await get().fetchWorktreeLineage() await get().fetchBrowserSessionProfiles()