mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
fix(desktop): confirm newly created launch branches
Close the launch menu after creation, keep the new branch selected, and explain when checkout or worktree setup occurs.
This commit is contained in:
@@ -13,6 +13,10 @@ const apiMocks = vi.hoisted(() => ({
|
||||
createRepositoryBranch: vi.fn(),
|
||||
}))
|
||||
|
||||
const uiMocks = vi.hoisted(() => ({
|
||||
addToast: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useMobileViewport', () => ({
|
||||
useMobileViewport: () => viewportMocks.isMobile,
|
||||
}))
|
||||
@@ -29,6 +33,12 @@ vi.mock('../../api/sessions', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../stores/uiStore', () => ({
|
||||
useUIStore: (selector: (state: { addToast: typeof uiMocks.addToast }) => unknown) => (
|
||||
selector({ addToast: uiMocks.addToast })
|
||||
),
|
||||
}))
|
||||
|
||||
// Must match the component's own import specifier, or the mock silently does
|
||||
// nothing and the real panel renders. It moved to composite/ in the directory
|
||||
// reshuffle and this path was left behind.
|
||||
@@ -68,6 +78,8 @@ vi.mock('../../i18n', () => ({
|
||||
'repoLaunch.newBranchNameLabel': 'Branch name',
|
||||
'repoLaunch.newBranchPlaceholder': 'feature/my-change',
|
||||
'repoLaunch.newBranchSubmit': 'Create',
|
||||
'repoLaunch.newBranchSuccessCurrent': 'Created and selected “{branch}”. It will be checked out when the session starts.',
|
||||
'repoLaunch.newBranchSuccessIsolated': 'Created and selected “{branch}”. An isolated worktree will be created from it when the session starts.',
|
||||
'repoLaunch.newBranchTitle': 'New branch',
|
||||
'repoLaunch.noBranch': 'No branch',
|
||||
'repoLaunch.noBranchMatch': 'No matching branches',
|
||||
@@ -182,9 +194,16 @@ function renderControls(props: Partial<ComponentProps<typeof RepositoryLaunchCon
|
||||
* happens *after* a directory is picked needs the prop to actually come back
|
||||
* down changed.
|
||||
*/
|
||||
function ControlledHarness({ initialWorkDir = '' }: { initialWorkDir?: string }) {
|
||||
function ControlledHarness({
|
||||
initialWorkDir = '',
|
||||
initialUseWorktree = false,
|
||||
}: {
|
||||
initialWorkDir?: string
|
||||
initialUseWorktree?: boolean
|
||||
}) {
|
||||
const [workDir, setWorkDir] = useState(initialWorkDir)
|
||||
const [branch, setBranch] = useState<string | null>(null)
|
||||
const [useWorktree, setUseWorktree] = useState(initialUseWorktree)
|
||||
|
||||
return (
|
||||
<RepositoryLaunchControls
|
||||
@@ -192,8 +211,8 @@ function ControlledHarness({ initialWorkDir = '' }: { initialWorkDir?: string })
|
||||
onWorkDirChange={setWorkDir}
|
||||
branch={branch}
|
||||
onBranchChange={setBranch}
|
||||
useWorktree={false}
|
||||
onUseWorktreeChange={vi.fn()}
|
||||
useWorktree={useWorktree}
|
||||
onUseWorktreeChange={setUseWorktree}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -226,6 +245,7 @@ describe('RepositoryLaunchControls', () => {
|
||||
apiMocks.getRepositoryContext.mockReset()
|
||||
apiMocks.getRepositoryContext.mockResolvedValue(okRepositoryContext)
|
||||
apiMocks.createRepositoryBranch.mockReset()
|
||||
uiMocks.addToast.mockReset()
|
||||
apiMocks.createRepositoryBranch.mockResolvedValue({
|
||||
branch: 'feature/new',
|
||||
baseRef: 'main',
|
||||
@@ -536,17 +556,41 @@ describe('RepositoryLaunchControls', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// The pill switches to the new branch, and the list it came back with is
|
||||
// used as-is — re-reading the repository would be a second round trip.
|
||||
expect(await screen.findByRole('button', { name: 'Location: cc-haha / feature/new' }))
|
||||
.toBeInTheDocument()
|
||||
// Success closes the menu so the selected branch is immediately visible
|
||||
// on the pill instead of leaving the user inside an apparently unchanged
|
||||
// menu. The returned list is adopted without a second request.
|
||||
const pill = await screen.findByRole('button', { name: 'Location: cc-haha / feature/new' })
|
||||
expect(screen.queryByRole('menu', { name: 'Location' })).not.toBeInTheDocument()
|
||||
expect(apiMocks.getRepositoryContext).toHaveBeenCalledTimes(1)
|
||||
expect(uiMocks.addToast).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'Created and selected “feature/new”. It will be checked out when the session starts.',
|
||||
})
|
||||
|
||||
fireEvent.click(pill)
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: /Branch/ }))
|
||||
const listbox = await screen.findByRole('listbox', { name: 'Select branch' })
|
||||
expect(within(listbox).getByRole('option', { name: /feature\/new/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('explains that an isolated worktree starts from the new branch', async () => {
|
||||
render(<ControlledHarness initialWorkDir="/repo" initialUseWorktree />)
|
||||
const input = await openNewBranchForm()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'feature/new' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uiMocks.addToast).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'Created and selected “feature/new”. An isolated worktree will be created from it when the session starts.',
|
||||
})
|
||||
})
|
||||
expect(await screen.findByRole('button', { name: 'Location: cc-haha / feature/new' }))
|
||||
.toBeInTheDocument()
|
||||
expect(screen.getByText('Isolated')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Creating off `main` is also what `from: context.currentBranch` would do,
|
||||
// so the default-selection test cannot tell the two apart — it stayed green
|
||||
// under exactly that mutation. This drives a non-default selection, which is
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type RepositoryContextResult,
|
||||
} from '../../api/sessions'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useUIStore } from '../../stores/uiStore'
|
||||
import { RecentProjectsPanel } from '@/components/composite/DirectoryPicker'
|
||||
import { useDismissable } from '@/hooks/useDismissable'
|
||||
import { useMobileViewport } from '../../hooks/useMobileViewport'
|
||||
@@ -50,7 +51,8 @@ const VIEWPORT_GUTTER = 12
|
||||
* `root` lists directory, branch and the worktree modes; `directory` and
|
||||
* `branch` are its two drill-downs. The menu opens on `root` only when there
|
||||
* is a repo to describe — see `viewOnOpen`. `newBranch` is reached from the
|
||||
* branch list and returns to it.
|
||||
* branch list; cancelling returns there, while success closes the menu so the
|
||||
* newly selected branch is immediately visible in the pill.
|
||||
*/
|
||||
type MenuView = 'directory' | 'root' | 'branch' | 'newBranch'
|
||||
|
||||
@@ -139,6 +141,7 @@ export function RepositoryLaunchControls({
|
||||
placement = 'outside',
|
||||
}: Props) {
|
||||
const t = useTranslation()
|
||||
const addToast = useUIStore((state) => state.addToast)
|
||||
const isMobileBrowser = useMobileViewport() && !isDesktopRuntime()
|
||||
const isToolbar = placement === 'toolbar' && !isMobileBrowser
|
||||
const [context, setContext] = useState<RepositoryContextResult | null>(null)
|
||||
@@ -363,9 +366,18 @@ export function RepositoryLaunchControls({
|
||||
setContext(result.context)
|
||||
setError(null)
|
||||
onBranchChange(result.branch)
|
||||
setNewBranchName('')
|
||||
setBranchFilter('')
|
||||
setView('root')
|
||||
addToast({
|
||||
type: 'success',
|
||||
message: t(
|
||||
useWorktree
|
||||
? 'repoLaunch.newBranchSuccessIsolated'
|
||||
: 'repoLaunch.newBranchSuccessCurrent',
|
||||
{ branch: result.branch },
|
||||
),
|
||||
})
|
||||
// Closing exposes the pill, which now names the branch selected for launch.
|
||||
// Keeping the menu open made a successful create look like a no-op.
|
||||
closeMenu()
|
||||
} catch (err) {
|
||||
if (latestWorkDirRef.current !== requestWorkDir) return
|
||||
const code = err instanceof Error && 'body' in err
|
||||
|
||||
@@ -1647,6 +1647,8 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'repoLaunch.newBranchPlaceholder': 'feature/my-change',
|
||||
'repoLaunch.newBranchFrom': 'Starts from {branch}',
|
||||
'repoLaunch.newBranchSubmit': 'Create',
|
||||
'repoLaunch.newBranchSuccessCurrent': 'Created and selected “{branch}”. It will be checked out when the session starts.',
|
||||
'repoLaunch.newBranchSuccessIsolated': 'Created and selected “{branch}”. An isolated worktree will be created from it when the session starts.',
|
||||
'repoLaunch.newBranchErrorInvalid': 'Git will not accept this branch name.',
|
||||
'repoLaunch.newBranchErrorExists': 'A branch with this name already exists.',
|
||||
'repoLaunch.newBranchErrorFailed': 'Could not create the branch.',
|
||||
|
||||
@@ -1649,6 +1649,8 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'repoLaunch.newBranchPlaceholder': 'feature/my-change',
|
||||
'repoLaunch.newBranchFrom': '{branch} から作成',
|
||||
'repoLaunch.newBranchSubmit': '作成',
|
||||
'repoLaunch.newBranchSuccessCurrent': '「{branch}」を作成して選択しました。セッション開始時にこのブランチへ切り替えます。',
|
||||
'repoLaunch.newBranchSuccessIsolated': '「{branch}」を作成して選択しました。セッション開始時にこのブランチから独立した worktree を作成します。',
|
||||
'repoLaunch.newBranchErrorInvalid': 'Git はこのブランチ名を受け付けません。',
|
||||
'repoLaunch.newBranchErrorExists': '同じ名前のブランチが既に存在します。',
|
||||
'repoLaunch.newBranchErrorFailed': 'ブランチを作成できませんでした。',
|
||||
|
||||
@@ -1649,6 +1649,8 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'repoLaunch.newBranchPlaceholder': 'feature/my-change',
|
||||
'repoLaunch.newBranchFrom': '{branch} 기준으로 생성',
|
||||
'repoLaunch.newBranchSubmit': '생성',
|
||||
'repoLaunch.newBranchSuccessCurrent': '“{branch}” 브랜치를 만들고 선택했습니다. 세션을 시작할 때 이 브랜치로 전환합니다.',
|
||||
'repoLaunch.newBranchSuccessIsolated': '“{branch}” 브랜치를 만들고 선택했습니다. 세션을 시작할 때 이 브랜치를 기준으로 독립 worktree를 만듭니다.',
|
||||
'repoLaunch.newBranchErrorInvalid': 'Git이 이 브랜치 이름을 허용하지 않습니다.',
|
||||
'repoLaunch.newBranchErrorExists': '같은 이름의 브랜치가 이미 있습니다.',
|
||||
'repoLaunch.newBranchErrorFailed': '브랜치를 만들지 못했습니다.',
|
||||
|
||||
@@ -1648,6 +1648,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'repoLaunch.newBranchPlaceholder': 'feature/my-change',
|
||||
'repoLaunch.newBranchFrom': '以 {branch} 為基礎建立',
|
||||
'repoLaunch.newBranchSubmit': '建立',
|
||||
'repoLaunch.newBranchSuccessCurrent': '已建立並選取「{branch}」,工作階段啟動時將切換到該分支。',
|
||||
'repoLaunch.newBranchSuccessIsolated': '已建立並選取「{branch}」,工作階段啟動時將以它為基礎建立獨立工作樹。',
|
||||
'repoLaunch.newBranchErrorInvalid': 'Git 不接受這個分支名稱。',
|
||||
'repoLaunch.newBranchErrorExists': '同名分支已存在。',
|
||||
'repoLaunch.newBranchErrorFailed': '建立分支失敗。',
|
||||
|
||||
@@ -1648,6 +1648,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'repoLaunch.newBranchPlaceholder': 'feature/my-change',
|
||||
'repoLaunch.newBranchFrom': '基于 {branch} 创建',
|
||||
'repoLaunch.newBranchSubmit': '创建',
|
||||
'repoLaunch.newBranchSuccessCurrent': '已创建并选中“{branch}”,会话启动时将切换到该分支。',
|
||||
'repoLaunch.newBranchSuccessIsolated': '已创建并选中“{branch}”,会话启动时将基于它创建独立工作树。',
|
||||
'repoLaunch.newBranchErrorInvalid': 'Git 不接受这个分支名。',
|
||||
'repoLaunch.newBranchErrorExists': '同名分支已存在。',
|
||||
'repoLaunch.newBranchErrorFailed': '创建分支失败。',
|
||||
|
||||
Reference in New Issue
Block a user