From e4804c76df43891a7e2025f1fbc062b60d691b14 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 2 Nov 2026 22:03:33 -0800 Subject: [PATCH] fix(new-workspace): list each same-host checkout in the create-worktree picker (#36180) * fix(new-workspace): list each same-host checkout of a project in the create-worktree picker Two local clones of one Git remote share a project id. The sidebar already shows each clone as its own section, but the create-worktree dialog listed the project once, its Run on picker kept only the first clone per host, and an explicit setup id was folded onto that first clone. The second clone was unreachable, and starting a worktree from its sidebar section created it in the first clone instead. v1.4.243 (#32272) split such clones into separate projects, which hid this; its revert in v1.4.244 (#34662) brought the single picker row back. - Project picker: add one row per same-host checkout, using the sidebar's own checkout-scoped rule; the project row keeps the project's other hosts. - Run on: one row per host still, but the selected checkout is the one shown. - Target resolution: only setups for the same checkout path collapse onto the first one; a distinct checkout on the same host stays the target. * fix(new-workspace): name the host on remote checkout rows in the project picker Remote same-host checkouts were labeled by path only, so a checkout on an SSH/runtime host could read identically to a local one with the same repo name and path. Prefix the host label for non-local checkout rows and cover checkout-row listing, selection and labeling in a focused lib test. --------- Co-authored-by: m4air Co-authored-by: Neil --- .../NewWorkspaceComposerProjectSection.tsx | 8 +- .../composer-review-base-test-fixture.ts | 3 + .../composer-same-host-checkouts.test.tsx | 148 ++++++++++++++++++ .../composer-state/composer-source-state.ts | 5 + ...omposer-target-state.jira-mapping.test.tsx | 26 +-- .../composer-state/project-target-actions.ts | 23 ++- .../runtime-target-selection.ts | 6 +- ...workspace-project-checkout-options.test.ts | 115 ++++++++++++++ .../src/lib/new-workspace-project-options.ts | 113 ++++++++++++- .../src/lib/project-host-setup-options.ts | 19 ++- .../src/lib/project-host-setup-row-dedupe.ts | 24 ++- .../lib/project-host-workspace-target.test.ts | 35 ++++- .../src/lib/project-host-workspace-target.ts | 9 +- 13 files changed, 493 insertions(+), 41 deletions(-) create mode 100644 src/renderer/src/hooks/composer-state/composer-same-host-checkouts.test.tsx create mode 100644 src/renderer/src/lib/new-workspace-project-checkout-options.test.ts 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 }