mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-13
@@ -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,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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user