diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx new file mode 100644 index 00000000000..5b186fc9378 --- /dev/null +++ b/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx @@ -0,0 +1,229 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import NewWorkspaceComposerCard from './NewWorkspaceComposerCard' + +vi.mock('@/store', () => ({ + useAppStore: Object.assign( + (selector: (state: unknown) => unknown) => + selector({ + closeModal: vi.fn(), + openModal: vi.fn(), + openSettingsPage: vi.fn(), + openSettingsTarget: vi.fn(), + setRuntimeEnvironmentStatus: vi.fn(), + activeModal: 'new-workspace-composer', + settings: { defaultTuiAgent: null, disabledTuiAgents: [] }, + updateSettings: vi.fn(), + projects: [], + repos: [], + worktreesByRepo: {} + }), + { getState: () => ({}) } + ) +})) + +vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({ + useContextualTour: vi.fn() +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('@/components/agent/AgentCombobox', () => ({ + default: () => +})) + +vi.mock('@/components/sidebar/AddRemoteHostDialog', () => ({ + AddRemoteHostDialog: () => null +})) + +vi.mock('@/components/new-workspace/SmartWorkspaceNameField', () => ({ + default: () => +})) + +vi.mock('@/components/new-workspace/ProjectCombobox', () => ({ + default: () =>
+})) + +// Why: the picker owns its own test; here it only has to report its value and emit picks. +vi.mock('@/components/repo/CreateFromPicker', () => ({ + CreateFromPicker: ({ + value, + onValueChange + }: { + value: string + onValueChange: (next: string) => void + }) => ( +
+ + +
+ ) +})) + +function renderCard( + overrides: Partial> = {} +): HTMLDivElement { + const container = document.createElement('div') + document.body.appendChild(container) + act(() => { + createRoot(container).render( + {}} + eligibleRepos={[]} + repoId="repo-a" + selectedRepoIsGit + onRepoChange={() => {}} + onProjectChange={() => {}} + primaryActionLabel="Create workspace" + name="" + onNameValueChange={() => {}} + branchNameOverride={undefined} + onBranchNameOverrideChange={() => {}} + onSmartGitHubItemSelect={() => {}} + onSmartGitLabItemSelect={() => {}} + onSmartBranchSelect={() => {}} + onSmartLinearIssueSelect={() => {}} + smartNameSelection={{ kind: 'jira', label: 'ERP-1491' }} + onClearSmartNameSelection={() => {}} + canReuseSelectedBranch={false} + reuseSelectedBranch={false} + onReuseSelectedBranchChange={() => {}} + forkPushWarning={null} + detectedAgentIds={null} + onOpenAgentSettings={() => {}} + advancedOpen={false} + onToggleAdvanced={() => {}} + parentWorktreeId={null} + onParentWorktreeIdChange={() => {}} + createDisabled={false} + projectError={null} + creating={false} + onCreate={() => {}} + note="" + onNoteChange={() => {}} + setupConfig={null} + requiresExplicitSetupChoice={false} + setupDecision={null} + onSetupDecisionChange={() => {}} + setupAgentStartupPolicy="start-immediately" + onSetupAgentStartupPolicyChange={() => {}} + shouldWaitForSetupCheck={false} + resolvedSetupDecision={null} + createError={null} + selectedRepoConnectionId={null} + selectedRepoSshStatus={null} + selectedRepoRequiresConnection={false} + selectedRepoConnectInProgress={false} + onConnectSelectedRepo={async () => {}} + canUseSparseCheckout={false} + sparsePresets={[]} + sparseSelectedPresetId={null} + onSparseSelectPreset={() => {}} + branchesEnabled + setupControlsEnabled={false} + sparseControlsEnabled={false} + baseBranch={undefined} + onBaseBranchChange={() => {}} + startFromResetHint={null} + {...overrides} + /> + ) + }) + return container +} + +function clickButton(container: HTMLDivElement, label: string): void { + const button = [...container.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label + ) + act(() => button?.click()) +} + +describe('NewWorkspaceComposerCard start from', () => { + let container: HTMLDivElement | null = null + + afterEach(() => { + container?.remove() + container = null + }) + + it('offers a base ref while a Jira issue names the workspace', () => { + container = renderCard() + + expect(container.querySelector('[data-testid="base-ref-picker"]')).toBeTruthy() + }) + + it('reports the picked ref to the composer', () => { + const picks: (string | undefined)[] = [] + container = renderCard({ onBaseBranchChange: (next) => picks.push(next) }) + + clickButton(container, 'Pick release') + + expect(picks).toEqual(['release/1.2']) + }) + + it('reports the project default as no base at all', () => { + const picks: (string | undefined)[] = [] + container = renderCard({ + baseBranch: 'release/1.2', + onBaseBranchChange: (next) => picks.push(next) + }) + + clickButton(container, 'Pick project default') + + expect(picks).toEqual([undefined]) + }) + + // Why: picking a base clears reuse, so offering one here would silently turn a checkout of + // the picked branch into a new branch off something else. + it('omits the base ref for a branch source, which already is the base', () => { + container = renderCard({ + smartNameSelection: { kind: 'branch', label: 'feature/export-v2' }, + baseBranch: 'feature/export-v2' + }) + + expect(container.querySelector('[data-testid="base-ref-picker"]')).toBeNull() + }) + + it('offers the base ref while a plain typed name owns the field', () => { + container = renderCard({ smartNameSelection: null, name: 'my-own-name' }) + + expect(container.querySelector('[data-testid="base-ref-picker"]')).toBeTruthy() + }) + + it.each([ + ['github-pr', { kind: 'github-pr' as const, label: '#42 Fix' }], + ['gitlab-mr', { kind: 'gitlab-mr' as const, label: '!42 Fix' }] + ])( + 'omits the base ref for a %s source that carries its own base', + (_label, smartNameSelection) => { + container = renderCard({ smartNameSelection }) + + expect(container.querySelector('[data-testid="base-ref-picker"]')).toBeNull() + } + ) + + it('omits the base ref when branches are disabled', () => { + container = renderCard({ branchesEnabled: false }) + + expect(container.querySelector('[data-testid="base-ref-picker"]')).toBeNull() + }) + + it('surfaces the reset hint left by a project switch', () => { + container = renderCard({ startFromResetHint: 'was origin/main' }) + + expect(container.textContent).toContain('was origin/main') + }) +}) diff --git a/src/renderer/src/components/automations/AutomationWorkspaceField.tsx b/src/renderer/src/components/automations/AutomationWorkspaceField.tsx index 80e6feeda32..9bbcf37cb9a 100644 --- a/src/renderer/src/components/automations/AutomationWorkspaceField.tsx +++ b/src/renderer/src/components/automations/AutomationWorkspaceField.tsx @@ -1,12 +1,12 @@ import { Info } from 'lucide-react' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { CreateFromPicker } from '@/components/repo/CreateFromPicker' import { translate } from '@/i18n/i18n' import type { AutomationWorkspaceMode } from '../../../../shared/automations-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import { AUTOMATION_EDITOR_SECTION_LABEL_CLASS, Field } from './automation-page-parts' -import { CreateFromPicker } from './CreateFromPicker' import { WorkspaceCombobox } from './WorkspaceCombobox' import type { AutomationDraft } from './AutomationEditorDialog' diff --git a/src/renderer/src/components/new-workspace/ComposerBaseRefPicker.tsx b/src/renderer/src/components/new-workspace/ComposerBaseRefPicker.tsx new file mode 100644 index 00000000000..e97f980ee87 --- /dev/null +++ b/src/renderer/src/components/new-workspace/ComposerBaseRefPicker.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { CreateFromPicker } from '@/components/repo/CreateFromPicker' +import { useRepoMap, useWorktreesForRepo } from '@/store/selectors' + +type ComposerBaseRefPickerProps = { + repoId: string + baseBranch: string | undefined + onBaseBranchChange: (value: string | undefined) => void + resetHint: string | null | undefined +} + +/** + * Base ref control for the New Workspace composer. + * + * Owns its own store reads so the name section stays presentational and the + * worktree subscription only exists while the picker is actually on screen. + */ +export function ComposerBaseRefPicker({ + repoId, + baseBranch, + onBaseBranchChange, + resetHint +}: ComposerBaseRefPickerProps): React.JSX.Element { + const repoMap = useRepoMap() + const repoWorktrees = useWorktreesForRepo(repoId) + return ( +
+ onBaseBranchChange(nextBaseBranch || undefined)} + /> + {resetHint ?

{resetHint}

: null} +
+ ) +} + +export default ComposerBaseRefPicker diff --git a/src/renderer/src/components/new-workspace/NewWorkspaceComposerNameSection.tsx b/src/renderer/src/components/new-workspace/NewWorkspaceComposerNameSection.tsx index 225e0f790a5..f05ef9d06a1 100644 --- a/src/renderer/src/components/new-workspace/NewWorkspaceComposerNameSection.tsx +++ b/src/renderer/src/components/new-workspace/NewWorkspaceComposerNameSection.tsx @@ -3,6 +3,8 @@ import { AlertTriangle, Check } from 'lucide-react' import SmartWorkspaceNameField from '@/components/new-workspace/SmartWorkspaceNameField' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' +import { shouldShowComposerBaseRefPicker } from './base-ref-picker-visibility' +import { ComposerBaseRefPicker } from './ComposerBaseRefPicker' import type { NewWorkspaceComposerCardProps } from './new-workspace-composer-card-props' type NewWorkspaceComposerNameSectionProps = Pick< @@ -35,6 +37,9 @@ type NewWorkspaceComposerNameSectionProps = Pick< | 'canReuseSelectedBranch' | 'reuseSelectedBranch' | 'onReuseSelectedBranchChange' + | 'baseBranch' + | 'onBaseBranchChange' + | 'startFromResetHint' > & { onNamePlainEnter: () => void } @@ -68,8 +73,18 @@ export function NewWorkspaceComposerNameSection({ forkPushWarning, canReuseSelectedBranch, reuseSelectedBranch, - onReuseSelectedBranchChange + onReuseSelectedBranchChange, + baseBranch, + onBaseBranchChange, + startFromResetHint }: NewWorkspaceComposerNameSectionProps): React.JSX.Element { + const showBaseRefPicker = + Boolean(onBaseBranchChange) && + shouldShowComposerBaseRefPicker({ + selectedRepoIsGit, + branchesEnabled, + smartNameSelectionKind: smartNameSelection?.kind ?? null + }) return (