diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 17f2c3d221b..b8837322051 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -139,7 +139,8 @@ function QuickTabBody({ nameInputRef, submitQuick, createDisabled: formDisabled, - selectAddedProjectRepo + selectAddedProjectRepo, + selectAddedProjectGroup } = useComposerState({ initialName: modalData.prefilledName ?? '', // Why: the modal is quick-create only now, so prompt-prefill state is @@ -283,9 +284,10 @@ function QuickTabBody({ open: addProjectOpen, onOpenChange: setAddProjectOpen, onProjectAdded: handleProjectAdded, + onProjectGroupImported: selectAddedProjectGroup, onCloseAutoFocus: handleAddProjectCloseAutoFocus }), - [addProjectOpen, handleAddProjectCloseAutoFocus, handleProjectAdded] + [addProjectOpen, handleAddProjectCloseAutoFocus, handleProjectAdded, selectAddedProjectGroup] ) const selectedProjectOption = cardProps.projectOptions.find( (option) => option.id === cardProps.selectedProjectId diff --git a/src/renderer/src/components/sidebar/use-add-repo-hosted-controller.test.ts b/src/renderer/src/components/sidebar/use-add-repo-hosted-controller.test.ts index 1c4bd303a1d..e6049cf3f64 100644 --- a/src/renderer/src/components/sidebar/use-add-repo-hosted-controller.test.ts +++ b/src/renderer/src/components/sidebar/use-add-repo-hosted-controller.test.ts @@ -83,11 +83,29 @@ describe('useAddRepoHostedController', () => { onOpenChange, onProjectAdded }) - await finishProjectAdd?.('repo-1') + await finishProjectAdd?.('repo-1', 'local_folder_picker') expect(mocks.markOnboardingProjectAdded).toHaveBeenCalledWith('addedRepo') expect(onProjectAdded).toHaveBeenCalledWith('repo-1') // Why: closing before selection keeps the composer visible under the // dialog's close animation while the new project lands in the picker. expect(order).toEqual(['close', 'added']) }) + it.each([true, false])( + 'prefers the imported group when host accepts it (%s)', + async (accepted) => { + const onProjectAdded = vi.fn() + const onProjectGroupImported = vi.fn(() => accepted) + const { finishProjectAdd } = useAddRepoHostedController({ + open: true, + onOpenChange: vi.fn(), + onProjectAdded, + onProjectGroupImported + }) + await finishProjectAdd?.('repo-1', 'ssh_remote_path', 'ssh:box', { + importedProjectGroupId: 'group' + }) + expect(onProjectGroupImported).toHaveBeenCalledWith('group') + expect(onProjectAdded).toHaveBeenCalledTimes(accepted ? 0 : 1) + } + ) }) diff --git a/src/renderer/src/components/sidebar/use-add-repo-hosted-controller.ts b/src/renderer/src/components/sidebar/use-add-repo-hosted-controller.ts index 8d33646ea02..b00cd6abc86 100644 --- a/src/renderer/src/components/sidebar/use-add-repo-hosted-controller.ts +++ b/src/renderer/src/components/sidebar/use-add-repo-hosted-controller.ts @@ -1,3 +1,4 @@ +import type { CompleteGitRepoAdd } from './use-complete-git-repo-add' import { useMemo } from 'react' import { useAppStore } from '@/store' import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist' @@ -10,6 +11,7 @@ export type AddRepoDialogHostedController = { /** Called after a Git project is added; the host selects the new project * instead of running the default-checkout navigation handoff. */ onProjectAdded: (repoId: string) => void | Promise + onProjectGroupImported?: (groupId: string) => boolean | Promise /** Radix close-autofocus hook for the nested dialog. Lets the host redirect * focus (e.g. to the composer's name field) instead of Radix restoring it * to the already-unmounted combobox row that opened the dialog. */ @@ -23,27 +25,34 @@ export function useAddRepoHostedController(hosted: AddRepoDialogHostedController * composer selection. Hosted mode must close the composer too — leaving it * open would hide the navigation behind a stale project selection. */ closeForFolderHandoff: () => void - finishProjectAdd: ((repoId: string) => Promise) | undefined + finishProjectAdd: CompleteGitRepoAdd | undefined } { const storeCloseModal = useAppStore((s) => s.closeModal) const hostedOnOpenChange = hosted?.onOpenChange const hostedOnProjectAdded = hosted?.onProjectAdded + const hostedOnProjectGroupImported = hosted?.onProjectGroupImported // Why: hosted mode (nested inside the workspace composer) must close only // this dialog, never the composer modal that lives in the activeModal slot. const closeModal = useMemo( () => (hostedOnOpenChange ? () => hostedOnOpenChange(false) : storeCloseModal), [hostedOnOpenChange, storeCloseModal] ) - const finishProjectAdd = useMemo( + const finishProjectAdd = useMemo( () => hostedOnOpenChange && hostedOnProjectAdded - ? async (repoId: string): Promise => { + ? async (repoId, _source, _executionHostId, outcome): Promise => { await markOnboardingProjectAdded('addedRepo') hostedOnOpenChange(false) + if ( + outcome?.importedProjectGroupId && + (await hostedOnProjectGroupImported?.(outcome.importedProjectGroupId)) + ) { + return + } await hostedOnProjectAdded(repoId) } : undefined, - [hostedOnOpenChange, hostedOnProjectAdded] + [hostedOnOpenChange, hostedOnProjectAdded, hostedOnProjectGroupImported] ) const closeForFolderHandoff = useMemo( () => diff --git a/src/renderer/src/components/sidebar/use-complete-git-repo-add.ts b/src/renderer/src/components/sidebar/use-complete-git-repo-add.ts index 11f0f27fbca..95906bd2cf5 100644 --- a/src/renderer/src/components/sidebar/use-complete-git-repo-add.ts +++ b/src/renderer/src/components/sidebar/use-complete-git-repo-add.ts @@ -11,35 +11,37 @@ import { finishProjectAddWithDefaultCheckout } from './project-added-default-che import type { ExecutionHostId } from '../../../../shared/execution-host' import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' +export type GitRepoAddOutcome = { importedProjectGroupId?: string } + +export type CompleteGitRepoAdd = ( + repoId: string, + source: AddRepoExistingWorkspaceSource, + executionHostId?: ExecutionHostId, + outcome?: GitRepoAddOutcome +) => Promise + type CompleteGitRepoAddOptions = { closeModal: () => void setHideDefaultBranchWorkspace: (hide: boolean) => void /** Why: the nested Add Project flow (hosted inside the workspace composer) * keeps the composer open and selects the new project instead of running * the default-checkout navigation handoff. Telemetry above still applies. */ - finishProjectAdd?: ( - repoId: string, - source: AddRepoExistingWorkspaceSource, - executionHostId?: ExecutionHostId - ) => Promise + finishProjectAdd?: CompleteGitRepoAdd } export function useCompleteGitRepoAdd({ closeModal, setHideDefaultBranchWorkspace, finishProjectAdd -}: CompleteGitRepoAddOptions): ( - repoId: string, - source: AddRepoExistingWorkspaceSource, - executionHostId?: ExecutionHostId -) => Promise { +}: CompleteGitRepoAddOptions): CompleteGitRepoAdd { const detectedTelemetryTrackedRef = useRef>(new Set()) return useCallback( async ( repoId: string, source: AddRepoExistingWorkspaceSource, - executionHostId?: ExecutionHostId + executionHostId?: ExecutionHostId, + outcome?: GitRepoAddOutcome ): Promise => { const worktrees = (selectWorktreesByRepo(useAppStore.getState())[repoId] ?? []).filter( (worktree) => @@ -66,7 +68,7 @@ export function useCompleteGitRepoAdd({ track('add_repo_existing_workspaces_detected', existingWorkspaceTelemetry) } if (finishProjectAdd) { - await finishProjectAdd(repoId, source, executionHostId) + await finishProjectAdd(repoId, source, executionHostId, outcome) return } await finishProjectAddWithDefaultCheckout({ diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts index c5a03d6ca39..9b4ed9ef1e0 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts @@ -248,4 +248,51 @@ describe('useAddRepoNestedImportFlow open folder fallback', () => { }) expect(onGitRepoReady).not.toHaveBeenCalled() }) + it.each(['group', 'separate'] as const)( + 'carries grouped import outcome without losing SSH owner (%s)', + async (mode) => { + const importedRepo: Repo = { + ...folderRepo, + id: 'app', + kind: 'git', + path: scan.repos[0].path, + connectionId: 'ssh-builder' + } + mocks.state.repos = [importedRepo] + const onGitRepoReady = vi.fn() + const result: ProjectGroupImportResult = { + group: { + id: 'group', + name: 'platform', + parentPath: scan.selectedPath, + connectionId: 'ssh-builder', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + }, + projects: [{ path: importedRepo.path, projectId: importedRepo.id, status: 'imported' }], + importedCount: 1, + alreadyKnownCount: 0, + failedCount: 0 + } + const flow = useTestAddRepoNestedImportFlow({ + nestedConnectionId: 'ssh-builder', + nestedRuntimeKind: 'ssh', + nestedSelectedPaths: new Set([importedRepo.path]), + importNestedRepos: vi.fn().mockResolvedValue(result), + onGitRepoReady + }) + await flow.handleImportNestedRepos(mode) + expect(onGitRepoReady).toHaveBeenCalledWith( + importedRepo.id, + 'ssh_remote_path', + 'ssh:ssh-builder', + mode === 'group' ? { importedProjectGroupId: 'group' } : undefined + ) + } + ) }) diff --git a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts index 5b340d949d9..064c77898e0 100644 --- a/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts +++ b/src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.ts @@ -19,7 +19,7 @@ import { translate } from '@/i18n/i18n' 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' +import type { CompleteGitRepoAdd } from './use-complete-git-repo-add' export function useAddRepoNestedImportFlow({ nestedAttemptId, @@ -59,11 +59,7 @@ export function useAddRepoNestedImportFlow({ mode: 'group' | 'separate' }) => Promise getNestedRepoRuntimeKind: (connectionId: string | null) => NestedRepoTelemetryRuntimeKind - onGitRepoReady: ( - repoId: string, - source: AddRepoExistingWorkspaceSource, - executionHostId?: ExecutionHostId - ) => Promise + onGitRepoReady: CompleteGitRepoAdd setIsAdding: (isAdding: boolean) => void }): { handleImportNestedRepos: (mode: 'group' | 'separate') => Promise @@ -208,7 +204,14 @@ export function useAddRepoNestedImportFlow({ : activeRuntimeEnvironmentId?.trim() ? 'runtime_server_path' : 'local_folder_picker' - await onGitRepoReady(repo.id, source, completionOwnerOptions.executionHostId) + await onGitRepoReady( + repo.id, + source, + completionOwnerOptions.executionHostId, + mode === 'group' && result.group + ? { importedProjectGroupId: result.group.id } + : undefined + ) } } catch (err) { if (gen === nestedImportGenRef.current) { diff --git a/src/renderer/src/hooks/composer-state/composer-source-model.ts b/src/renderer/src/hooks/composer-state/composer-source-model.ts index 687d26fefbe..c0e5bc5f09e 100644 --- a/src/renderer/src/hooks/composer-state/composer-source-model.ts +++ b/src/renderer/src/hooks/composer-state/composer-source-model.ts @@ -62,6 +62,7 @@ export type ComposerSourceModel = { onConnectSelectedRepo: () => Promise prefetchSshConnectedGeneration: number resolvePendingSmartGitHubSubmit: () => Promise + selectAddedProjectGroup: (groupId: string) => boolean selectAddedProjectRepo: (nextRepoId: string) => void showProjectRequiredError: () => void smartNameSelection: SmartWorkspaceNameSelection | null diff --git a/src/renderer/src/hooks/composer-state/composer-source-state-contract.ts b/src/renderer/src/hooks/composer-state/composer-source-state-contract.ts index 10206c9d40b..e89179c4066 100644 --- a/src/renderer/src/hooks/composer-state/composer-source-state-contract.ts +++ b/src/renderer/src/hooks/composer-state/composer-source-state-contract.ts @@ -22,7 +22,7 @@ export type ComposerSourceState = { ComposerModel, 'handleRepoChange' | 'handleFolderSourceRepoChange' | 'handleProjectHostSetupChange' > - projectTargetActions: Pick + projectTargetActions: Pick branchStartPointActions: Pick< ComposerModel, | 'showProjectRequiredError' diff --git a/src/renderer/src/hooks/composer-state/imported-project-group-selection.test.tsx b/src/renderer/src/hooks/composer-state/imported-project-group-selection.test.tsx new file mode 100644 index 00000000000..241b69fecdc --- /dev/null +++ b/src/renderer/src/hooks/composer-state/imported-project-group-selection.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import { pr, repo, useReviewBaseSelectionFixture } from './composer-review-base-test-fixture' + +const originalState = useAppStore.getState() +const group: ProjectGroup = { + id: 'just-imported', + name: 'Imported group', + parentPath: '/imported', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 +} +afterEach(() => { + cleanup() + useAppStore.setState(originalState, true) +}) + +describe('imported group composer selection', () => { + it('selects a group created after render through the same reset path as the picker', () => { + const { result } = renderHook(() => useReviewBaseSelectionFixture(pr)) + const importedRepo = { + ...repo, + id: 'imported-repo', + path: '/imported/app', + projectGroupId: group.id + } + act(() => useAppStore.setState({ projectGroups: [group], repos: [importedRepo] })) + let selected = false + act(() => { + selected = result.current.selectAddedProjectGroup(group.id) + }) + expect(selected).toBe(true) + expect(result.current.selectedProjectGroupId).toBe(group.id) + expect(result.current.repoId).toBe(importedRepo.id) + expect(result.current.linkedWorkItem).toBeNull() + expect(result.current.baseBranch).toBeUndefined() + expect(result.current.branchNameOverride).toBeUndefined() + expect(result.current.pushTarget).toBeUndefined() + }) + + it.each([ + { ...group, id: 'different' }, + { ...group, parentPath: null }, + { ...group, executionHostId: 'ssh:missing' } + ])( + 'declines missing, non-folder or unreachable groups without changing selection (%j)', + (unavailable) => { + const { result } = renderHook(() => useReviewBaseSelectionFixture(pr)) + act(() => useAppStore.setState({ projectGroups: [unavailable] })) + let selected = true + act(() => { + selected = result.current.selectAddedProjectGroup(group.id) + }) + expect(selected).toBe(false) + expect(result.current.selectedProjectGroupId).toBeNull() + expect(result.current.repoId).toBe(repo.id) + expect(result.current.linkedWorkItem).toMatchObject({ type: 'pr', number: pr.number }) + } + ) +}) 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 6850766cb5f..202ccc9f6ab 100644 --- a/src/renderer/src/hooks/composer-state/project-target-actions.ts +++ b/src/renderer/src/hooks/composer-state/project-target-actions.ts @@ -1,3 +1,6 @@ +import { useAppStore } from '@/store' +import type { ProjectGroup } from '../../../../shared/project-group-types' +import type { Repo } from '../../../../shared/repo-types' import type { ComposerModel } from './composer-model' type ProjectTargetActionsInput = Pick< @@ -84,6 +87,82 @@ export function useProjectTargetActions(input: ProjectTargetActionsInput) { workspaceHostScope } = input + const applyProjectGroupSelection = useCallback( + ( + group: ProjectGroup, + sourceRepos: readonly Repo[], + sourceGroups: readonly ProjectGroup[] + ): void => { + const nextSourceRepo = getFolderSourceRepos(sourceRepos, sourceGroups, group)[0] + smartSourceSelectionRef.current = null + setSelectedProjectGroupId(group.id) + setProjectError(null) + setRepoId(nextSourceRepo?.id ?? '') + setLinkedIssue('') + setLinkedPR(null) + setLinkedGitLabIssue(null) + setLinkedGitLabMR(null) + if (linkedWorkItem && !shouldPreserveWorkspaceSourceOnRepoChange(linkedWorkItem)) { + setLinkedWorkItem(null) + setLinkedTaskSourceContext(null) + } + setSparseEnabled(false) + setSparseDirectories('') + setSparseSelectedPresetId(null) + setBaseBranch(undefined) + setPushTarget(undefined) + setBranchNameOverride(undefined) + // Why (#5181): clear branch-scoped reuse state on a project switch too. + setBranchNameOverridePreservesNameEdits(false) + setReuseEligibleBranch(null) + setReuseSelectedBranch(false) + setForkPushWarning(null) + setStartFromResetHint(null) + }, + [ + linkedWorkItem, + setRepoId, + setSelectedProjectGroupId, + setProjectError, + setLinkedIssue, + setLinkedPR, + setLinkedGitLabIssue, + setLinkedGitLabMR, + setLinkedWorkItem, + setLinkedTaskSourceContext, + setSparseEnabled, + setSparseDirectories, + setSparseSelectedPresetId, + setBaseBranch, + setPushTarget, + setBranchNameOverride, + setBranchNameOverridePreservesNameEdits, + setReuseEligibleBranch, + setReuseSelectedBranch, + setForkPushWarning, + setStartFromResetHint, + smartSourceSelectionRef + ] + ) + + const selectAddedProjectGroup = useCallback( + (groupId: string): boolean => { + const state = useAppStore.getState() + const group = findActionableFolderProjectGroup({ + projectGroups: state.projectGroups, + groupId, + actionableHostIds + }) + if (!group) { + return false + } + initialProjectGroupAppliedRef.current = true + applyProjectGroupSelection(group, state.repos, state.projectGroups) + return true + }, + [actionableHostIds, initialProjectGroupAppliedRef, applyProjectGroupSelection] + ) + const handleProjectChange = useCallback( (projectId: string): void => { initialProjectGroupAppliedRef.current = true @@ -104,31 +183,7 @@ export function useProjectTargetActions(input: ProjectTargetActionsInput) { ) return } - const nextSourceRepo = getFolderSourceRepos(repos, projectGroups, nextProjectGroup)[0] - smartSourceSelectionRef.current = null - setSelectedProjectGroupId(nextProjectGroup.id) - setProjectError(null) - setRepoId(nextSourceRepo?.id ?? '') - setLinkedIssue('') - setLinkedPR(null) - setLinkedGitLabIssue(null) - setLinkedGitLabMR(null) - if (linkedWorkItem && !shouldPreserveWorkspaceSourceOnRepoChange(linkedWorkItem)) { - setLinkedWorkItem(null) - setLinkedTaskSourceContext(null) - } - setSparseEnabled(false) - setSparseDirectories('') - setSparseSelectedPresetId(null) - setBaseBranch(undefined) - setPushTarget(undefined) - setBranchNameOverride(undefined) - // Why (#5181): clear branch-scoped reuse state on a project switch too. - setBranchNameOverridePreservesNameEdits(false) - setReuseEligibleBranch(null) - setReuseSelectedBranch(false) - setForkPushWarning(null) - setStartFromResetHint(null) + applyProjectGroupSelection(nextProjectGroup, repos, projectGroups) return } @@ -150,43 +205,25 @@ export function useProjectTargetActions(input: ProjectTargetActionsInput) { handleRepoChange(nextRepoId, { forceResetStartFrom: isProjectGroupTarget }) }, [ + applyProjectGroupSelection, eligibleRepos, actionableHostIds, handleRepoChange, isProjectGroupTarget, - linkedWorkItem, projectGroups, projectHostSetups, projects, repos, - setRepoId, selectedWorkspaceTarget, workspaceHostScope, initialProjectGroupAppliedRef, - setBaseBranch, - setBranchNameOverride, - setBranchNameOverridePreservesNameEdits, - setForkPushWarning, - setLinkedGitLabIssue, - setLinkedGitLabMR, - setLinkedIssue, - setLinkedPR, - setLinkedTaskSourceContext, - setLinkedWorkItem, - setProjectError, - setPushTarget, - setReuseEligibleBranch, - setReuseSelectedBranch, setSelectedProjectGroupId, - setSparseDirectories, - setSparseEnabled, - setSparseSelectedPresetId, - setStartFromResetHint, - smartSourceSelectionRef + setProjectError ] ) return { - handleProjectChange + handleProjectChange, + selectAddedProjectGroup } } diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index eb6762580d3..e44cfbe1491 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -73,6 +73,7 @@ export type UseComposerStateResult = { nameInputRef: RefObject submitQuick: (agent: TuiAgent | null, options?: QuickSubmitOptions) => Promise createDisabled: boolean + selectAddedProjectGroup: (groupId: string) => boolean selectAddedProjectRepo: (repoId: string) => void } @@ -104,6 +105,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS nameInputRef: model.nameInputRef, submitQuick: model.submitQuick, createDisabled, - selectAddedProjectRepo: model.selectAddedProjectRepo + selectAddedProjectRepo: model.selectAddedProjectRepo, + selectAddedProjectGroup: model.selectAddedProjectGroup } }