diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx index ec6294c0839..2287e72bb79 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerProjectSection.tsx @@ -6,6 +6,7 @@ import ProjectCombobox from '@/components/new-workspace/ProjectCombobox' import RunTargetCombobox from '@/components/new-workspace/RunTargetCombobox' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' +import { getSelectedNewWorkspaceProjectOptionId } from '@/lib/new-workspace-project-options' import type { EphemeralVmRecipeOption, NeedsProjectHostOption, @@ -119,7 +120,12 @@ export function NewWorkspaceComposerProjectSection({
{ + originalApi = Object.getOwnPropertyDescriptor(window, 'api') + Object.defineProperty(window, 'api', { + configurable: true, + value: { + preflight: { + detectAgents: vi.fn().mockResolvedValue([]) + }, + gh: { + repoSlug: vi.fn().mockResolvedValue(null) + }, + worktrees: { + listRetiredNames: vi + .fn() + .mockResolvedValue(EMPTY_RETIRED_NAME_REGISTRY) + } + } + }) +}) + +afterEach(() => { + cleanup() + useAppStore.setState(originalState, true) + if (originalApi) { + Object.defineProperty(window, 'api', originalApi) + } else { + Reflect.deleteProperty(window, 'api') + } +}) + +function renderComposerFor(repo: Repo) { + const repos = [mainClone, reviewClone] + const projection = projectHostSetupProjectionFromRepos(repos) + useAppStore.setState({ + settings: null, + repos, + projects: [...projection.projects], + projectHostSetups: [...projection.setups], + projectGroups: [], + newWorkspaceDraft: null, + worktreesByRepo: {}, + sparsePresetsByRepo: {}, + workspaceHostScope: 'all', + detectedAgentIds: [] + }) + return renderHook(() => { + const target = useComposerTargetState( + { + persistDraft: false, + initialRepoId: repo.id, + initialWorkspaceTarget: getRepoWorkspaceCreationTargetHint(repo, projection.setups) + }, + decisions + ) + const external = useComposerExternalSync(target) + const source = useComposerSourceState(target, external) + return { ...target, source } + }) +} + +function checkoutOptionId( + options: ReturnType< + typeof renderComposerFor + >['result']['current']['runtimeTargetSelection']['projectOptions'], + repoId: string +): string { + const option = options.find( + (entry) => entry.kind === 'project' && entry.checkout?.repoId === repoId + ) + if (!option) { + throw new Error(`Expected a picker row for ${repoId}`) + } + return option.id +} + +describe('same-host checkouts of one remote', () => { + it('opens on the checkout it was started from and lists both checkouts', () => { + const { result } = renderComposerFor(reviewClone) + const selection = result.current.runtimeTargetSelection + + expect(selection.selectedRepo).toBe(reviewClone) + expect(selection.projectOptions.map((option) => option.displayName)).toEqual([ + 'orca', + 'orca-review' + ]) + expect(selection.projectHostSetupOptions).toMatchObject([ + { kind: 'ready', repoId: reviewClone.id, path: reviewClone.path } + ]) + }) + + it('switches between the checkouts from the project picker', () => { + const { result } = renderComposerFor(mainClone) + + act(() => + result.current.source.projectTargetActions.handleProjectChange( + checkoutOptionId(result.current.runtimeTargetSelection.projectOptions, reviewClone.id) + ) + ) + expect(result.current.runtimeTargetSelection.selectedRepo).toBe(reviewClone) + expect(result.current.runtimeTargetSelection.projectHostSetupOptions).toMatchObject([ + { kind: 'ready', repoId: reviewClone.id } + ]) + + act(() => + result.current.source.projectTargetActions.handleProjectChange( + checkoutOptionId(result.current.runtimeTargetSelection.projectOptions, mainClone.id) + ) + ) + expect(result.current.runtimeTargetSelection.selectedRepo).toBe(mainClone) + }) +}) diff --git a/src/renderer/src/hooks/composer-state/composer-source-state.ts b/src/renderer/src/hooks/composer-state/composer-source-state.ts index 5780dae0f9b..0f7c515ae38 100644 --- a/src/renderer/src/hooks/composer-state/composer-source-state.ts +++ b/src/renderer/src/hooks/composer-state/composer-source-state.ts @@ -96,6 +96,7 @@ export function useComposerSourceState( linkedWorkItem: target.sourceContextState.linkedWorkItem, projectGroups: target.composerTargetStore.projectGroups, projectHostSetups: target.composerTargetStore.projectHostSetups, + projectOptions: target.runtimeTargetSelection.projectOptions, projects: target.composerTargetStore.projects, repos: target.composerTargetStore.repos, setBaseBranch: target.workspaceIdentityState.setBaseBranch, @@ -115,6 +116,10 @@ export function useComposerSourceState( setReuseEligibleBranch: target.workspaceIdentityState.setReuseEligibleBranch, setReuseSelectedBranch: target.workspaceIdentityState.setReuseSelectedBranch, setSelectedProjectGroupId: target.initialTargetState.setSelectedProjectGroupId, + setSelectedProjectHostSetupOverrideHostId: + target.initialTargetState.setSelectedProjectHostSetupOverrideHostId, + setSelectedProjectHostSetupOverrideId: + target.initialTargetState.setSelectedProjectHostSetupOverrideId, setSparseDirectories: target.asyncComposerState.setSparseDirectories, setSparseEnabled: target.asyncComposerState.setSparseEnabled, setSparseSelectedPresetId: target.asyncComposerState.setSparseSelectedPresetId, diff --git a/src/renderer/src/hooks/composer-state/composer-target-state.jira-mapping.test.tsx b/src/renderer/src/hooks/composer-state/composer-target-state.jira-mapping.test.tsx index c163ae97179..fb78a27ffaf 100644 --- a/src/renderer/src/hooks/composer-state/composer-target-state.jira-mapping.test.tsx +++ b/src/renderer/src/hooks/composer-state/composer-target-state.jira-mapping.test.tsx @@ -158,7 +158,7 @@ function renderUnavailableMappedComposer( includeSetupHint?: boolean emptyRepoOverride?: boolean missingSshSetup?: boolean - canonicalSameHostSetup?: boolean + sameHostSiblingCheckout?: boolean } = {} ) { const mappedRepo: Repo = { @@ -166,11 +166,11 @@ function renderUnavailableMappedComposer( id: options.repeatedId ? currentRepo.id : 'mapped', path: '/mapped', connectionId: 'builder', - ...(options.canonicalSameHostSetup ? { upstream: { owner: 'fixture', repo: 'app' } } : {}) + ...(options.sameHostSiblingCheckout ? { upstream: { owner: 'fixture', repo: 'app' } } : {}) } - const canonicalRepo: Repo = { ...mappedRepo, id: 'canonical', path: '/canonical' } - const repos = options.canonicalSameHostSetup - ? [currentRepo, canonicalRepo, mappedRepo] + const siblingRepo: Repo = { ...mappedRepo, id: 'sibling', path: '/sibling' } + const repos = options.sameHostSiblingCheckout + ? [currentRepo, siblingRepo, mappedRepo] : [currentRepo, mappedRepo] const projection = projectHostSetupProjectionFromRepos(repos) const initialWorkspaceTarget: WorkspaceCreationTargetHint = { @@ -216,7 +216,7 @@ function renderUnavailableMappedComposer( const source = useComposerSourceState(target, external) return { ...target, source } }) - return { ...hook, mappedRepo, canonicalRepo } + return { ...hook, mappedRepo } } describe('unavailable mapped Jira destinations', () => { @@ -299,20 +299,20 @@ describe('unavailable mapped Jira destinations', () => { expect(result.current.initialTargetState.repoId).toBe(currentRepo.id) }) - it('aligns the repo identity with the canonical same-host setup after recovery', () => { - const { result, canonicalRepo } = renderUnavailableMappedComposer({ - canonicalSameHostSetup: true + it('keeps the mapped checkout, not an older same-host sibling, after recovery', () => { + const { result, mappedRepo } = renderUnavailableMappedComposer({ + sameHostSiblingCheckout: true }) expect(result.current.runtimeTargetSelection.selectedWorkspaceTarget.status).toBe('unavailable') act(() => useAppStore.setState({ sshTargetLabels: new Map([['builder', 'Builder']]) })) expect(result.current.runtimeTargetSelection.selectedWorkspaceTarget).toMatchObject({ status: 'ready', - target: { repo: canonicalRepo, hostId: 'ssh:builder' } + target: { repo: mappedRepo, hostId: 'ssh:builder' } }) - expect(result.current.runtimeTargetSelection.selectedRepo).toBe(canonicalRepo) - expect(result.current.initialTargetState.repoId).toBe(canonicalRepo.id) - expect(result.current.runtimeTargetSelection.repoIdRef.current).toBe(canonicalRepo.id) + expect(result.current.runtimeTargetSelection.selectedRepo).toBe(mappedRepo) + expect(result.current.initialTargetState.repoId).toBe(mappedRepo.id) + expect(result.current.runtimeTargetSelection.repoIdRef.current).toBe(mappedRepo.id) expect(result.current.sourceContextState.taskSourceContext).toEqual(jiraSource) expect(result.current.sourceContextState.linkedWorkItem?.jiraIdentifier).toBe('APP-1') }) diff --git a/src/renderer/src/hooks/composer-state/project-target-actions.ts b/src/renderer/src/hooks/composer-state/project-target-actions.ts index 21d435fb903..7d8bab08ba4 100644 --- a/src/renderer/src/hooks/composer-state/project-target-actions.ts +++ b/src/renderer/src/hooks/composer-state/project-target-actions.ts @@ -13,6 +13,7 @@ type ProjectTargetActionsInput = Pick< | 'linkedWorkItem' | 'projectGroups' | 'projectHostSetups' + | 'projectOptions' | 'projects' | 'repos' | 'setBaseBranch' @@ -31,6 +32,8 @@ type ProjectTargetActionsInput = Pick< | 'setReuseEligibleBranch' | 'setReuseSelectedBranch' | 'setSelectedProjectGroupId' + | 'setSelectedProjectHostSetupOverrideHostId' + | 'setSelectedProjectHostSetupOverrideId' | 'setSparseDirectories' | 'setSparseEnabled' | 'setSparseSelectedPresetId' @@ -44,6 +47,7 @@ import { useCallback } from 'react' import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../../shared/new-workspace/workspace-source' import { getProjectGroupIdFromNewWorkspaceOptionId, + getProjectRowHostSetups, findActionableFolderProjectGroup } from '@/lib/new-workspace-project-options' import { translate } from '@/i18n/i18n' @@ -60,6 +64,7 @@ export function useProjectTargetActions(input: ProjectTargetActionsInput) { linkedWorkItem, projectGroups, projectHostSetups, + projectOptions, projects, repos, setBaseBranch, @@ -78,6 +83,8 @@ export function useProjectTargetActions(input: ProjectTargetActionsInput) { setReuseEligibleBranch, setReuseSelectedBranch, setSelectedProjectGroupId, + setSelectedProjectHostSetupOverrideHostId, + setSelectedProjectHostSetupOverrideId, setSparseDirectories, setSparseEnabled, setSparseSelectedPresetId, @@ -188,14 +195,23 @@ export function useProjectTargetActions(input: ProjectTargetActionsInput) { } setSelectedProjectGroupId(null) + const option = projectOptions.find((candidate) => candidate.id === projectId) + const checkout = option?.kind === 'project' ? option.checkout : undefined + if (checkout) { + handleRepoChange(checkout.repoId, { forceResetStartFrom: isProjectGroupTarget }) + // After handleRepoChange, which clears the override; a same-host sibling checkout needs the exact setup. + setSelectedProjectHostSetupOverrideId(checkout.projectHostSetupId) + setSelectedProjectHostSetupOverrideHostId(checkout.hostId) + return + } const preferredHostId = selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target.hostId : null // Prefer focusedHostScope rather than hard-pin hostId, so projects available only on another host remain selectable. const nextRepoId = resolveWorkspaceCreationRepoId({ eligibleRepos, projects, - projectHostSetups, - projectId, + projectHostSetups: getProjectRowHostSetups(projectOptions, projectHostSetups), + projectId: option?.kind === 'project' ? option.projectId : projectId, focusedHostScope: preferredHostId ?? workspaceHostScope, actionableHostIds }) @@ -212,12 +228,15 @@ export function useProjectTargetActions(input: ProjectTargetActionsInput) { isProjectGroupTarget, projectGroups, projectHostSetups, + projectOptions, projects, repos, selectedWorkspaceTarget, workspaceHostScope, initialProjectGroupAppliedRef, setSelectedProjectGroupId, + setSelectedProjectHostSetupOverrideHostId, + setSelectedProjectHostSetupOverrideId, setProjectError ] ) diff --git a/src/renderer/src/hooks/composer-state/runtime-target-selection.ts b/src/renderer/src/hooks/composer-state/runtime-target-selection.ts index 6304f8faa72..f383b856586 100644 --- a/src/renderer/src/hooks/composer-state/runtime-target-selection.ts +++ b/src/renderer/src/hooks/composer-state/runtime-target-selection.ts @@ -214,9 +214,11 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe projectId: selectedRepoProjectId, projectHostSetups, eligibleRepos, - hosts: hostOptions + hosts: hostOptions, + selectedSetup: + selectedWorkspaceTarget.status === 'ready' ? selectedWorkspaceTarget.target : null }), - [eligibleRepos, hostOptions, projectHostSetups, selectedRepoProjectId] + [eligibleRepos, hostOptions, projectHostSetups, selectedRepoProjectId, selectedWorkspaceTarget] ) const projectOptions = useMemo( diff --git a/src/renderer/src/lib/new-workspace-project-checkout-options.test.ts b/src/renderer/src/lib/new-workspace-project-checkout-options.test.ts new file mode 100644 index 00000000000..bf4c2f34ac1 --- /dev/null +++ b/src/renderer/src/lib/new-workspace-project-checkout-options.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { + buildNewWorkspaceProjectOptions, + getProjectRowHostSetups, + getSelectedNewWorkspaceProjectOptionId +} from './new-workspace-project-options' +import type { Project, ProjectHostSetup } from '../../../shared/project-types' +import type { Repo } from '../../../shared/repo-types' + +function repo(id: string, overrides: Partial = {}): Repo { + return { + id, + path: `/tmp/${id}`, + displayName: id, + badgeColor: '#111111', + addedAt: 1, + upstream: { owner: 'stablyai', repo: 'orca' }, + ...overrides + } +} + +const project: Project = { + id: 'github:stablyai/orca', + displayName: 'orca', + badgeColor: '#111111', + providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }, + sourceRepoIds: [], + createdAt: 1, + updatedAt: 1 +} + +function setup( + overrides: Pick & Partial +): ProjectHostSetup { + return { + projectId: project.id, + hostId: 'local', + path: `/code/${overrides.repoId}`, + displayName: overrides.repoId, + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +describe('new-workspace project checkout rows', () => { + it('lists each same-host checkout and keeps the project row for its other hosts', () => { + const projectHostSetups = [ + setup({ id: 'main-setup', repoId: 'main', path: '/code/orca' }), + setup({ id: 'review-setup', repoId: 'review', path: '/code/orca-review' }), + setup({ id: 'ssh-setup', hostId: 'ssh:builder', repoId: 'ssh-repo' }) + ] + const options = buildNewWorkspaceProjectOptions({ + projects: [project], + projectHostSetups, + eligibleRepos: [ + repo('main', { displayName: 'orca' }), + repo('review', { displayName: 'orca-review' }), + repo('ssh-repo', { connectionId: 'builder' }) + ] + }) + + expect(options.map((option) => [option.displayName, option.detail])).toEqual([ + ['orca', '/code/orca'], + ['orca', 'stablyai/orca'], + ['orca-review', '/code/orca-review'] + ]) + expect(getProjectRowHostSetups(options, projectHostSetups).map((entry) => entry.id)).toEqual([ + 'ssh-setup' + ]) + expect( + getSelectedNewWorkspaceProjectOptionId(options, project.id, 'review-setup', 'local') + ).toBe(options[2].id) + expect( + getSelectedNewWorkspaceProjectOptionId(options, project.id, 'ssh-setup', 'ssh:builder') + ).toBe(project.id) + }) + + it('names the host on a remote checkout so it does not read as the local one', () => { + // Same repo names, paths and setup ids on both machines; without the host the rows are identical. + const projectHostSetups = [ + setup({ id: 'a', repoId: 'local-a', path: '/code/orca' }), + setup({ id: 'b', repoId: 'local-b', path: '/code/orca-b' }), + setup({ id: 'a', hostId: 'ssh:builder', repoId: 'ssh-a', path: '/code/orca' }), + setup({ id: 'b', hostId: 'ssh:builder', repoId: 'ssh-b', path: '/code/orca-b' }) + ] + const options = buildNewWorkspaceProjectOptions({ + projects: [project], + projectHostSetups, + eligibleRepos: [ + repo('local-a', { displayName: 'orca' }), + repo('local-b', { displayName: 'orca-b' }), + repo('ssh-a', { displayName: 'orca', connectionId: 'builder' }), + repo('ssh-b', { displayName: 'orca-b', connectionId: 'builder' }) + ], + hosts: [ + { id: 'local', label: 'This Mac' }, + { id: 'ssh:builder', label: 'Builder' } + ] + }) + + expect(options.map((option) => [option.displayName, option.detail])).toEqual([ + ['orca', '/code/orca'], + ['orca', 'Builder · /code/orca'], + ['orca-b', '/code/orca-b'], + ['orca-b', 'Builder · /code/orca-b'] + ]) + expect(new Set(options.map((option) => option.id)).size).toBe(4) + expect(getSelectedNewWorkspaceProjectOptionId(options, project.id, 'a', 'ssh:builder')).toBe( + options[1].id + ) + }) +}) diff --git a/src/renderer/src/lib/new-workspace-project-options.ts b/src/renderer/src/lib/new-workspace-project-options.ts index 2aa187211f8..4d051c24815 100644 --- a/src/renderer/src/lib/new-workspace-project-options.ts +++ b/src/renderer/src/lib/new-workspace-project-options.ts @@ -4,13 +4,30 @@ import type { Project, ProjectHostSetup } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' -import { getRepoExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { + getExecutionHostLabel, + getRepoExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId +} from '../../../shared/execution-host' +import { + buildProjectGroupingIndex, + isCheckoutScopedProjectSetup +} from '../../../shared/project-section-grouping' import { getDuplicateProjectDetailsById, type ProjectSetupDirectory } from './new-workspace-duplicate-project-details' export const NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX = 'project-group:' +const NEW_WORKSPACE_PROJECT_CHECKOUT_OPTION_PREFIX = 'project-checkout:' + +/** One of several same-host checkouts of a project; the sidebar shows each as its own section. */ +export type NewWorkspaceProjectCheckout = { + projectHostSetupId: string + hostId: ExecutionHostId + repoId: string +} export type NewWorkspaceProjectOption = | { @@ -20,6 +37,7 @@ export type NewWorkspaceProjectOption = displayName: string badgeColor: string detail: string + checkout?: NewWorkspaceProjectCheckout } | { kind: 'project-group' @@ -85,20 +103,27 @@ export function buildNewWorkspaceProjectOptions( ): NewWorkspaceProjectOption[] { const { eligibleRepos } = input const { projects, projectHostSetups } = getProjectModel(input) - const eligibleRepoIds = new Set(eligibleRepos.map((repo) => repo.id)) + const eligibleRepoById = new Map(eligibleRepos.map((repo) => [repo.id, repo])) const hostLabelById = new Map((input.hosts ?? []).map((host) => [host.id, host.label])) const liveHostIds = input.hosts ? new Set(input.hosts.map((host) => host.id)) : null + const groupingIndex = buildProjectGroupingIndex({ projects, projectHostSetups }) const readySetupCountsByProjectId = new Map() const setupDirectoriesByProjectId = new Map() + const checkoutSetups: ProjectHostSetup[] = [] for (const setup of projectHostSetups) { if ( setup.setupState !== 'ready' || - !eligibleRepoIds.has(setup.repoId) || + !eligibleRepoById.has(setup.repoId) || (liveHostIds && !liveHostIds.has(setup.hostId)) ) { continue } + // Why: same-host checkouts of one remote share a project id; each stays selectable, as in the sidebar. + if (groupingIndex && isCheckoutScopedProjectSetup(setup, groupingIndex)) { + checkoutSetups.push(setup) + continue + } readySetupCountsByProjectId.set( setup.projectId, (readySetupCountsByProjectId.get(setup.projectId) ?? 0) + 1 @@ -126,12 +151,86 @@ export function buildNewWorkspaceProjectOptions( hostLabelById ) - return options - .map(({ detailSource: _detailSource, ...option }) => { + const projectById = new Map(projects.map((project) => [project.id, project])) + const checkoutOptions = checkoutSetups.flatMap((setup) => { + const project = projectById.get(setup.projectId) + return project + ? [ + { + kind: 'project' as const, + id: getProjectCheckoutOptionId(setup), + projectId: project.id, + displayName: eligibleRepoById.get(setup.repoId)?.displayName || setup.displayName, + badgeColor: project.badgeColor, + detail: getCheckoutDetail(setup, hostLabelById), + checkout: { projectHostSetupId: setup.id, hostId: setup.hostId, repoId: setup.repoId } + } + ] + : [] + }) + + return [ + ...options.map(({ detailSource: _detailSource, ...option }) => { const directoryDetail = duplicateProjectDetailsById.get(option.id) return directoryDetail ? { ...option, detail: directoryDetail } : option - }) - .sort((a, b) => a.displayName.localeCompare(b.displayName) || a.detail.localeCompare(b.detail)) + }), + ...checkoutOptions + ].sort((a, b) => a.displayName.localeCompare(b.displayName) || a.detail.localeCompare(b.detail)) +} + +// Why: a remote host's checkouts can share a name and path with this Mac's, so name the host. +function getCheckoutDetail( + setup: Pick, + hostLabelById: ReadonlyMap +): string { + if (setup.hostId === LOCAL_EXECUTION_HOST_ID) { + return setup.path + } + const hostLabel = hostLabelById.get(setup.hostId)?.trim() || getExecutionHostLabel(setup.hostId) + return `${hostLabel} · ${setup.path}` +} + +// Setup ids can repeat across hosts, so a checkout is keyed by both. +function getCheckoutKey(projectHostSetupId: string, hostId: ExecutionHostId): string { + return JSON.stringify([hostId, projectHostSetupId]) +} + +function getProjectCheckoutOptionId(setup: Pick): string { + return `${NEW_WORKSPACE_PROJECT_CHECKOUT_OPTION_PREFIX}${getCheckoutKey(setup.id, setup.hostId)}` +} + +/** The picker row for the composer's resolved setup: its checkout row when it has one, else its project row. */ +export function getSelectedNewWorkspaceProjectOptionId( + options: readonly NewWorkspaceProjectOption[], + selectedProjectId: string | null, + projectHostSetupId: string | null | undefined, + hostId: ExecutionHostId | null | undefined +): string | null { + const checkoutOption = options.find( + (option) => + option.kind === 'project' && + option.checkout !== undefined && + option.checkout.projectHostSetupId === projectHostSetupId && + option.checkout.hostId === hostId + ) + return checkoutOption?.id ?? selectedProjectId +} + +/** Setups a project row stands for; its checkout rows own the rest. */ +export function getProjectRowHostSetups( + options: readonly NewWorkspaceProjectOption[], + projectHostSetups: readonly ProjectHostSetup[] +): readonly ProjectHostSetup[] { + const checkoutKeys = new Set( + options.flatMap((option) => + option.kind === 'project' && option.checkout + ? [getCheckoutKey(option.checkout.projectHostSetupId, option.checkout.hostId)] + : [] + ) + ) + return checkoutKeys.size === 0 + ? projectHostSetups + : projectHostSetups.filter((setup) => !checkoutKeys.has(getCheckoutKey(setup.id, setup.hostId))) } function getProjectGroupOptionId(projectGroupId: string): string { diff --git a/src/renderer/src/lib/project-host-setup-options.ts b/src/renderer/src/lib/project-host-setup-options.ts index 80d1eeaf1a1..ea365099ce5 100644 --- a/src/renderer/src/lib/project-host-setup-options.ts +++ b/src/renderer/src/lib/project-host-setup-options.ts @@ -16,7 +16,10 @@ import { import type { ProjectHostSetup } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' import { translate } from '@/i18n/i18n' -import { dedupeReadySetupRows } from './project-host-setup-row-dedupe' +import { + dedupeReadySetupRows, + type SelectedProjectHostSetup +} from './project-host-setup-row-dedupe' export type ProjectHostSetupSelection = { id: string; hostId: ExecutionHostId } @@ -63,6 +66,7 @@ type BuildReadySetupOptionsInput = { projectHostSetups: readonly ProjectHostSetup[] eligibleRepos: readonly Repo[] hosts: readonly ExecutionHostRegistryEntry[] + selectedSetup?: SelectedProjectHostSetup | null } type BuildNeedsSetupOptionsInput = { @@ -77,13 +81,16 @@ type BuildProjectHostSetupOptionsInput = { projectHostSetups: readonly ProjectHostSetup[] eligibleRepos: readonly Repo[] hosts: readonly ExecutionHostRegistryEntry[] + /** Shown as its host's row when that host has several checkouts of the project. */ + selectedSetup?: SelectedProjectHostSetup | null } export function buildProjectHostSetupOptions({ projectId, projectHostSetups, eligibleRepos, - hosts + hosts, + selectedSetup }: BuildProjectHostSetupOptionsInput): ProjectHostSetupOption[] { if (!projectId) { return [] @@ -92,7 +99,8 @@ export function buildProjectHostSetupOptions({ projectId, projectHostSetups, eligibleRepos, - hosts + hosts, + selectedSetup }) const readySetupByHost = new Map(readyOptions.map((option) => [option.hostId, option])) const pendingSetupByHost = getPendingSetupByHost(projectId, projectHostSetups) @@ -127,7 +135,8 @@ function buildReadySetupOptions({ projectId, projectHostSetups, eligibleRepos, - hosts + hosts, + selectedSetup }: BuildReadySetupOptionsInput): ReadyProjectHostSetupOption[] { const eligibleRepoIds = new Set(eligibleRepos.map((repo) => repo.id)) const hostById = new Map(hosts.map((host) => [host.id, host])) @@ -153,7 +162,7 @@ function buildReadySetupOptions({ detail: setup.displayName, path: setup.path })) - return dedupeReadySetupRows(options, hostById) + return dedupeReadySetupRows(options, hostById, selectedSetup) } function buildNeedsSetupOptions({ diff --git a/src/renderer/src/lib/project-host-setup-row-dedupe.ts b/src/renderer/src/lib/project-host-setup-row-dedupe.ts index 9b203f109e2..585630fa4ba 100644 --- a/src/renderer/src/lib/project-host-setup-row-dedupe.ts +++ b/src/renderer/src/lib/project-host-setup-row-dedupe.ts @@ -5,15 +5,33 @@ import type { ReadyProjectHostSetupOption } from './project-host-setup-options' +export type SelectedProjectHostSetup = { projectHostSetupId: string; hostId: ExecutionHostId } + /** Collapses ready setup rows to one per host, then one per checkout on a merged machine. */ export function dedupeReadySetupRows( options: readonly ReadyProjectHostSetupOption[], - hostById: ReadonlyMap + hostById: ReadonlyMap, + selected?: SelectedProjectHostSetup | null ): ReadyProjectHostSetupOption[] { - return collapseMergedHostCheckouts(options.filter(dedupeByHost()), hostById) + return collapseMergedHostCheckouts( + selectedFirst(options, selected).filter(dedupeByHost()), + hostById + ) } -// Creation uses the first project+host setup, so display that same row; legacy duplicates (e.g. linked worktree projects) are otherwise identical and unreachable. +// Per-host dedupe keeps the first row, so a host with several checkouts shows the selected one. +function selectedFirst( + options: readonly ReadyProjectHostSetupOption[], + selected: SelectedProjectHostSetup | null | undefined +): readonly ReadyProjectHostSetupOption[] { + const isSelected = (option: ReadyProjectHostSetupOption): boolean => + option.id === selected?.projectHostSetupId && option.hostId === selected.hostId + return selected + ? [...options.filter(isSelected), ...options.filter((option) => !isSelected(option))] + : options +} + +// One row per host; the project picker lists a host's other checkouts, so Run on only switches host. function dedupeByHost(): (option: ReadyProjectHostSetupOption) => boolean { const seenHosts = new Set() return (option) => { diff --git a/src/renderer/src/lib/project-host-workspace-target.test.ts b/src/renderer/src/lib/project-host-workspace-target.test.ts index 211769880ad..ad3d94b71fa 100644 --- a/src/renderer/src/lib/project-host-workspace-target.test.ts +++ b/src/renderer/src/lib/project-host-workspace-target.test.ts @@ -160,20 +160,21 @@ describe('project-host workspace target resolution', () => { }) }) - it('canonicalizes a stale same-host setup id to the setup the picker shows', () => { - // Legacy drafts can name hidden duplicate setups; creation must use the canonical picker row/path. - const repos = [makeRepo('orca-main'), makeRepo('orca-worktree')] - const projects = [makeProject('github:stablyai/orca', ['orca-main', 'orca-worktree'])] + it('canonicalizes a duplicate setup of the same checkout to the first one', () => { + const repos = [makeRepo('orca-main'), makeRepo('orca-twin')] + const projects = [makeProject('github:stablyai/orca', ['orca-main', 'orca-twin'])] const projectHostSetups = [ makeSetup('orca-main', 'github:stablyai/orca', 'local', 'orca-main'), - makeSetup('orca-worktree', 'github:stablyai/orca', 'local', 'orca-worktree') + makeSetup('orca-twin', 'github:stablyai/orca', 'local', 'orca-twin', { + path: '/repos/orca-main/' + }) ] const resolution = resolveWorkspaceCreationTarget({ eligibleRepos: repos, projects, projectHostSetups, - projectHostSetupId: 'orca-worktree' + projectHostSetupId: 'orca-twin' }) expect(resolution).toMatchObject({ @@ -182,6 +183,28 @@ describe('project-host workspace target resolution', () => { }) }) + it('keeps an explicit setup for a second checkout of the project on the same host', () => { + // Two clones of one remote share a project; the picker lists each, so the chosen one must be created in. + const repos = [makeRepo('orca-main'), makeRepo('orca-review')] + const projects = [makeProject('github:stablyai/orca', ['orca-main', 'orca-review'])] + const projectHostSetups = [ + makeSetup('orca-main', 'github:stablyai/orca', 'local', 'orca-main'), + makeSetup('orca-review', 'github:stablyai/orca', 'local', 'orca-review') + ] + + const resolution = resolveWorkspaceCreationTarget({ + eligibleRepos: repos, + projects, + projectHostSetups, + projectHostSetupId: 'orca-review' + }) + + expect(resolution).toMatchObject({ + status: 'ready', + target: { projectHostSetupId: 'orca-review', repoId: 'orca-review', hostId: 'local' } + }) + }) + it('does not merge same-name repos without shared project identity', () => { const repos = [ makeRepo('personal-orca', { displayName: 'orca' }), diff --git a/src/renderer/src/lib/project-host-workspace-target.ts b/src/renderer/src/lib/project-host-workspace-target.ts index 5c60f6fbc61..c082614e5bc 100644 --- a/src/renderer/src/lib/project-host-workspace-target.ts +++ b/src/renderer/src/lib/project-host-workspace-target.ts @@ -12,6 +12,7 @@ import type { Project, ProjectHostSetup } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' import { resolveComposerRepoId } from './new-workspace-composer-repo' import { findSoleHostCopy } from '../../../shared/worktree/host-qualified-identity' +import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' export type WorkspaceCreationTarget = { projectId: string @@ -183,12 +184,16 @@ export function resolveWorkspaceCreationTarget( if (!isReadySetup(setup)) { return { status: 'unavailable', reason: 'setup-not-ready' } } - // Canonicalize legacy same-host duplicate drafts to the picker’s first setup so displayed and created paths match. + // Canonicalize legacy same-checkout duplicates to the first setup; a distinct same-host checkout is its own target. + const checkoutPath = normalizeRuntimePathForComparison(setup.path) const canonical = findReadySetupTarget( setups, reposById, - (entry) => entry.projectId === setup.projectId && entry.hostId === setup.hostId + (entry) => + entry.projectId === setup.projectId && + entry.hostId === setup.hostId && + normalizeRuntimePathForComparison(entry.path) === checkoutPath ) ?? createTarget(setup, reposById) if (canonical) { return { status: 'ready', target: canonical }