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 <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-10 12:58:09 -07:00
committed by GitHub
co-authored by m4air
parent 81867c8ea0
commit 4f03c23796
66 changed files with 692 additions and 526 deletions
+6 -21
View File
@@ -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
-15
View File
@@ -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
@@ -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}
</SettingsSection>
@@ -23,11 +23,11 @@ export function useRemoteRepo(
onGitRepoReady?: (repoId: string, executionHostId?: ExecutionHostId) => void | Promise<void>,
scanNestedRepos?: (
path: string,
connectionId?: string,
controls?: {
connectionId: string | undefined,
controls: {
scanId?: string
onProgress?: (scan: NestedRepoScanResult) => void
runtimeEnvironmentId?: string | null
runtimeEnvironmentId: string | null
}
) => Promise<NestedRepoScanResult | null>,
showNestedRepoReview?: (
@@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({
closeModal: vi.fn(),
repos: [] as Repo[],
worktreesByRepo: {} as Record<string, unknown[]>,
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(<ProjectAddedDialog />)
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(<ProjectAddedDialog />)
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()
})
@@ -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,
@@ -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<typeof getActiveRuntimeTarget>[0]
runtimeEnvironmentId: string | null | undefined
): RuntimeClientTarget {
const environmentId = runtimeEnvironmentId?.trim()
return environmentId
? { kind: 'environment', environmentId }
: getActiveRuntimeTarget({ ...settings, activeRuntimeEnvironmentId: null })
return runtimeTargetForOwnerEnvironment(runtimeEnvironmentId ?? null)
}
@@ -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(),
@@ -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<string>
>(() => new Set())
const pathStatusRouteOptions = useMemo(
() => ({ runtimeEnvironmentId: runtimeEnvironmentId ?? null }),
[runtimeEnvironmentId]
)
const pathStatusRouteOptions = useMemo(() => ({ runtimeEnvironmentId }), [runtimeEnvironmentId])
const pathStatusCacheKey = pathStatusRequest
? getFolderWorkspacePathStatusCacheKey(pathStatusRequest, pathStatusRouteOptions)
: null
@@ -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(
@@ -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(),
@@ -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
})
})
@@ -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<unknown>
@@ -55,7 +55,7 @@ export function useAddRepoNestedImportFlow({
projectPaths: string[]
connectionId?: string
scanId?: string
runtimeEnvironmentId?: string | null
runtimeEnvironmentId: string | null
mode: 'group' | 'separate'
}) => Promise<ProjectGroupImportResult | null>
getNestedRepoRuntimeKind: (connectionId: string | null) => NestedRepoTelemetryRuntimeKind
@@ -23,7 +23,7 @@ export function useAddRepoNestedReviewController({
reviewRuntimeEnvironmentId: string | null | undefined
cancelNestedRepoScan: (
scanId: string,
options?: { runtimeEnvironmentId?: string | null }
options: { runtimeEnvironmentId: string | null }
) => Promise<unknown>
closeModal: () => void
fetchWorktrees: (repoId: string, options?: WorktreeFetchOptions) => Promise<unknown>
@@ -33,7 +33,7 @@ export function useAddRepoNestedReviewController({
projectPaths: string[]
connectionId?: string
scanId?: string
runtimeEnvironmentId?: string | null
runtimeEnvironmentId: string | null
mode: 'group' | 'separate'
}) => Promise<ProjectGroupImportResult | null>
onGitRepoReady: (
@@ -22,7 +22,7 @@ export function useAddRepoNestedReviewState({
activeRuntimeEnvironmentId: string | null | undefined
cancelNestedRepoScan: (
scanId: string,
options?: { runtimeEnvironmentId?: string | null }
options: { runtimeEnvironmentId: string | null }
) => Promise<unknown>
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<SetStateAction<Set<string>>>
setNestedGroupName: Dispatch<SetStateAction<string>>
setNestedScanInProgress: Dispatch<SetStateAction<boolean>>
@@ -56,11 +56,9 @@ export function useAddRepoNestedReviewState({
const [nestedScanInProgress, setNestedScanInProgress] = useState(false)
const [nestedScanId, setNestedScanId] = useState<string | null>(null)
const [nestedImportScanId, setNestedImportScanId] = useState<string | null>(null)
const [nestedRuntimeEnvironmentId, setNestedRuntimeEnvironmentId] = useState<
string | null | undefined
>(undefined)
const [nestedRuntimeEnvironmentId, setNestedRuntimeEnvironmentId] = useState<string | null>(null)
const nestedScanIdRef = useRef<string | null>(null)
const nestedScanRuntimeEnvironmentIdRef = useRef<string | null | undefined>(undefined)
const nestedScanRuntimeEnvironmentIdRef = useRef<string | null>(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)
},
[]
@@ -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<NestedRepoScanResult | null>
setActiveNestedScanId: (scanId: string | null, runtimeEnvironmentId?: string | null) => void
@@ -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
@@ -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]
@@ -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
}
@@ -23,7 +23,7 @@ export type ComposerStoreActions = {
createdWithAgent?: FolderWorkspace['createdWithAgent']
pendingFirstAgentMessageRename?: boolean
},
options?: { runtimeEnvironmentId?: string | null }
options: { runtimeEnvironmentId: string | null }
) => Promise<FolderWorkspace | null>
closeModal: () => void
openSettingsPage: () => void
@@ -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)
})
)
@@ -70,7 +70,7 @@ async function seedFolderWorkspace(
.getState()
.fetchFolderWorkspacePathStatus(
{ scope: 'folder-workspace', folderWorkspaceId: folderWorkspace.id },
{ force: true }
{ force: true, runtimeEnvironmentId: null }
)
}
@@ -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)
@@ -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
}
}
@@ -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 })
@@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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 } : {})
})
}
@@ -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)
@@ -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
@@ -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
}
}
@@ -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({
@@ -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 {
@@ -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<StateCreator<AppState>>[1]
): Pick<RepoSlice, 'updateProject'> {
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 })
@@ -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
@@ -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<AppState, 'repos' | 'settings'>,
@@ -40,15 +38,6 @@ export function settingsForRepoOwner(
return state.settings
}
export function getAddRepoPathRouteSettings(
options: AddRepoPathOptions | undefined,
fallbackSettings: GlobalSettings | null
): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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
@@ -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(
@@ -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 {
+6 -7
View File
@@ -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(),
+27 -18
View File
@@ -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<void>
fetchRepos: (options: RuntimeCatalogFetchOptions) => Promise<void>
fetchReposForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise<void>
awaitLocalRepoCatalogSettlement: () => Promise<void>
fetchRuntimeEnvironmentRepos: (environmentId: string) => Promise<Repo[]>
fetchProjectGroups: (options?: RuntimeCatalogFetchOptions) => Promise<void>
fetchProjectGroups: (options: RuntimeCatalogFetchOptions) => Promise<void>
fetchProjectGroupsForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise<void>
fetchFolderWorkspaces: (options?: RuntimeCatalogFetchOptions) => Promise<void>
fetchFolderWorkspaces: (options: RuntimeCatalogFetchOptions) => Promise<void>
fetchFolderWorkspacesForAllHosts: (options?: AllHostCatalogFetchOptions) => Promise<void>
addRepo: () => Promise<Repo | null>
addRepoPath: (
@@ -192,20 +195,21 @@ export type RepoSlice = {
addNonGitFolder: (path: string, options?: AddRepoPathOptions) => Promise<Repo | null>
scanNestedRepos: (
path: string,
connectionId?: string,
controls?: NestedRepoScanControls
connectionId: string | undefined,
controls: NestedRepoScanControls
) => Promise<NestedRepoScanResult | null>
cancelNestedRepoScan: (scanId: string, options?: NestedRepoScanCancelOptions) => Promise<boolean>
cancelNestedRepoScan: (scanId: string, options: NestedRepoScanCancelOptions) => Promise<boolean>
importNestedRepos: (args: {
parentPath: string
groupName: string
projectPaths: string[]
connectionId?: string
scanId?: string
runtimeEnvironmentId?: string | null
runtimeEnvironmentId: string | null
mode: 'group' | 'separate'
}) => Promise<ProjectGroupImportResult | null>
createProjectGroup: (name: string) => Promise<ProjectGroup | null>
/** `hostId` is the host of the project that seeds the group. */
createProjectGroup: (name: string, hostId: ExecutionHostId) => Promise<ProjectGroup | null>
createFolderWorkspace: (
args: {
projectGroupId: string
@@ -218,19 +222,19 @@ export type RepoSlice = {
createdWithAgent?: FolderWorkspace['createdWithAgent']
pendingFirstAgentMessageRename?: boolean
},
options?: FolderWorkspacePathStatusRouteOptions
options: FolderWorkspacePathStatusRouteOptions
) => Promise<FolderWorkspace | null>
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<FolderWorkspacePathStatus | null>
updateFolderWorkspace: (
folderWorkspaceId: string,
@@ -255,7 +259,8 @@ export type RepoSlice = {
moveProjectToGroup: (
projectId: string,
groupId: string | null,
order?: number
order?: number,
hostId?: ExecutionHostId
) => Promise<boolean>
// 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<void>
updateProject: (projectId: string, updates: ProjectUpdate) => Promise<boolean>
updateProject: (
projectId: string,
updates: ProjectUpdate,
hostId: ExecutionHostId
) => Promise<boolean>
// 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,
+4 -10
View File
@@ -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 {
@@ -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: {
@@ -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 }
}
@@ -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<ProjectGroup, 'connectionId' | 'executionHostId'>
type ProjectGroupOwnerRecord = Pick<ProjectGroup, 'id' | 'connectionId' | 'executionHostId'>
@@ -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<ProjectGroupOwnerRoutingState, 'projectGroups'>,
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
}
@@ -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 }
}
@@ -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 }
}
@@ -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
@@ -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 }
}
@@ -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<FolderWorkspace>()
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 }
}
@@ -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 }
}
@@ -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 }
}
@@ -65,6 +65,7 @@ describe('nested import partial catalog refresh failures', () => {
parentPath: '/workspace',
groupName: 'Workspace',
projectPaths: [],
runtimeEnvironmentId: null,
mode: 'group'
})
).resolves.toEqual(result)
@@ -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 }
}
@@ -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 }
}
@@ -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',
@@ -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()
})
@@ -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.'
)
@@ -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 }
}
@@ -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 }
}
@@ -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 }
}
@@ -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 }
}
@@ -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 }
}
@@ -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 }
}
@@ -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 }
}
@@ -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 }
}
+21 -17
View File
@@ -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',
@@ -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 }
}
+2 -1
View File
@@ -288,7 +288,8 @@ export const createSettingsSlice: StateCreator<AppState, [], [], SettingsSlice>
}
// 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()