mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix: make worktree launch mode selectable
The worktree control is a mode choice, so it should behave like the branch selector instead of a hidden toggle. This adds a compact dropdown with current and isolated worktree options while preserving the existing launch layout and isolation guard. Constraint: Keep the composer context rail layout unchanged. Rejected: Keep the single-click toggle | the interaction hid the available modes and made the state harder to reason about. Confidence: high Scope-risk: narrow Directive: Worktree mode must stay an explicit selection surface, not a hidden toggle. Tested: cd desktop && bun run test -- --run src/components/chat/ChatInput.test.tsx src/pages/EmptySession.test.tsx src/components/shared/DirectoryPicker.test.tsx Tested: bun run check:desktop Tested: agent-browser worktree dropdown desktop and mobile screenshots at /tmp/cc-haha-worktree-dropdown-menu.png, /tmp/cc-haha-worktree-dropdown-selected.png, /tmp/cc-haha-worktree-dropdown-mobile-menu.png, /tmp/cc-haha-worktree-dropdown-mobile-selected.png Not-tested: Native Tauri packaged window visual check
This commit is contained in:
parent
ca4900ed0c
commit
5675539ef0
@ -354,7 +354,8 @@ describe('ChatInput file mentions', () => {
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Select branch: main/ }))
|
||||
fireEvent.click(await screen.findByRole('option', { name: /feature\/a/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Toggle worktree isolation' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Select worktree mode: Current worktree/ }))
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Isolated worktree' }))
|
||||
expect(screen.getByText('Isolated worktree')).toBeInTheDocument()
|
||||
const input = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
fireEvent.change(input, { target: { value: 'run in a worktree', selectionStart: 17 } })
|
||||
|
||||
@ -30,6 +30,8 @@ type Props = {
|
||||
|
||||
const BRANCH_MENU_HEIGHT = 360
|
||||
const BRANCH_MENU_WIDTH = 390
|
||||
const WORKTREE_MENU_HEIGHT = 126
|
||||
const WORKTREE_MENU_WIDTH = 226
|
||||
const VIEWPORT_GUTTER = 12
|
||||
|
||||
function stateMessage(context: RepositoryContextResult | null, error: string | null) {
|
||||
@ -59,13 +61,18 @@ export function RepositoryLaunchControls({
|
||||
const [branchFilter, setBranchFilter] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null)
|
||||
const [worktreeMenuOpen, setWorktreeMenuOpen] = useState(false)
|
||||
const [worktreeMenuPos, setWorktreeMenuPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const branchButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const worktreeButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const worktreeMenuRef = useRef<HTMLDivElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const searchInputId = useId()
|
||||
const listboxId = useId()
|
||||
const worktreeListboxId = useId()
|
||||
|
||||
const updateMenuPos = useCallback(() => {
|
||||
if (!branchButtonRef.current) return
|
||||
@ -81,6 +88,20 @@ export function RepositoryLaunchControls({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateWorktreeMenuPos = useCallback(() => {
|
||||
if (!worktreeButtonRef.current) return
|
||||
const rect = worktreeButtonRef.current.getBoundingClientRect()
|
||||
const spaceAbove = rect.top
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const direction = spaceBelow >= WORKTREE_MENU_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up'
|
||||
const maxLeft = Math.max(VIEWPORT_GUTTER, window.innerWidth - WORKTREE_MENU_WIDTH - VIEWPORT_GUTTER)
|
||||
setWorktreeMenuPos({
|
||||
top: direction === 'down' ? rect.bottom + 6 : rect.top - 6,
|
||||
left: Math.min(Math.max(rect.left, VIEWPORT_GUTTER), maxLeft),
|
||||
direction,
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!workDir) {
|
||||
setContext(null)
|
||||
@ -131,17 +152,20 @@ export function RepositoryLaunchControls({
|
||||
}, [branch, context, onBranchChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (!branchMenuOpen) return
|
||||
if (!branchMenuOpen && !worktreeMenuOpen) return
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
if (rootRef.current?.contains(target)) return
|
||||
if (menuRef.current?.contains(target)) return
|
||||
if (worktreeMenuRef.current?.contains(target)) return
|
||||
setBranchMenuOpen(false)
|
||||
setWorktreeMenuOpen(false)
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return
|
||||
event.preventDefault()
|
||||
setBranchMenuOpen(false)
|
||||
setWorktreeMenuOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
@ -149,7 +173,7 @@ export function RepositoryLaunchControls({
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [branchMenuOpen])
|
||||
}, [branchMenuOpen, worktreeMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!branchMenuOpen) return
|
||||
@ -163,6 +187,17 @@ export function RepositoryLaunchControls({
|
||||
}
|
||||
}, [branchMenuOpen, updateMenuPos])
|
||||
|
||||
useEffect(() => {
|
||||
if (!worktreeMenuOpen) return
|
||||
updateWorktreeMenuPos()
|
||||
window.addEventListener('scroll', updateWorktreeMenuPos, true)
|
||||
window.addEventListener('resize', updateWorktreeMenuPos)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updateWorktreeMenuPos, true)
|
||||
window.removeEventListener('resize', updateWorktreeMenuPos)
|
||||
}
|
||||
}, [worktreeMenuOpen, updateWorktreeMenuPos])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [branchFilter])
|
||||
@ -217,6 +252,12 @@ export function RepositoryLaunchControls({
|
||||
setBranchFilter('')
|
||||
}
|
||||
|
||||
const selectWorktreeMode = (enabled: boolean) => {
|
||||
if (!enabled && requiresIsolation) return
|
||||
onUseWorktreeChange(enabled)
|
||||
setWorktreeMenuOpen(false)
|
||||
}
|
||||
|
||||
const handleBranchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
@ -284,6 +325,7 @@ export function RepositoryLaunchControls({
|
||||
title={selectedBranch?.name || t('repoLaunch.noBranch')}
|
||||
onClick={() => {
|
||||
setBranchMenuOpen((prev) => !prev)
|
||||
setWorktreeMenuOpen(false)
|
||||
setBranchFilter('')
|
||||
}}
|
||||
className={`${workbarButtonClassName} max-w-[260px]`}
|
||||
@ -296,12 +338,18 @@ export function RepositoryLaunchControls({
|
||||
</button>
|
||||
|
||||
<button
|
||||
ref={worktreeButtonRef}
|
||||
type="button"
|
||||
disabled={disabled || requiresIsolation}
|
||||
aria-pressed={useWorktree}
|
||||
aria-label={t('repoLaunch.toggleWorktree')}
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={worktreeMenuOpen}
|
||||
aria-controls={worktreeMenuOpen ? worktreeListboxId : undefined}
|
||||
aria-label={`${t('repoLaunch.selectWorktree')}: ${worktreeLabel}`}
|
||||
title={worktreeLabel}
|
||||
onClick={() => onUseWorktreeChange(!useWorktree)}
|
||||
onClick={() => {
|
||||
setWorktreeMenuOpen((prev) => !prev)
|
||||
setBranchMenuOpen(false)
|
||||
}}
|
||||
className={`${workbarButtonClassName} ${
|
||||
useWorktree
|
||||
? 'bg-[var(--color-surface-container-lowest)] text-[var(--color-text-primary)]'
|
||||
@ -312,6 +360,7 @@ export function RepositoryLaunchControls({
|
||||
<span className="min-w-0 truncate">
|
||||
{worktreeLabel}
|
||||
</span>
|
||||
<ChevronDown size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@ -415,6 +464,62 @@ export function RepositoryLaunchControls({
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{worktreeMenuOpen && worktreeMenuPos && createPortal(
|
||||
<div
|
||||
ref={worktreeMenuRef}
|
||||
className="w-[226px] overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: worktreeMenuPos.left,
|
||||
...(worktreeMenuPos.direction === 'down'
|
||||
? { top: worktreeMenuPos.top }
|
||||
: { bottom: window.innerHeight - worktreeMenuPos.top }),
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<div id={worktreeListboxId} role="listbox" aria-label={t('repoLaunch.selectWorktree')}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={!useWorktree}
|
||||
aria-disabled={requiresIsolation}
|
||||
disabled={requiresIsolation}
|
||||
onClick={() => selectWorktreeMode(false)}
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-45 ${
|
||||
!useWorktree ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<GitFork size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-medium text-[var(--color-text-primary)]">
|
||||
{t('repoLaunch.worktreeCurrent')}
|
||||
</span>
|
||||
</span>
|
||||
{!useWorktree && <Check size={16} className="shrink-0 text-[var(--color-brand)]" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={useWorktree}
|
||||
onClick={() => selectWorktreeMode(true)}
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 ${
|
||||
useWorktree ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<GitFork size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-medium text-[var(--color-text-primary)]">
|
||||
{t('repoLaunch.worktreeIsolated')}
|
||||
</span>
|
||||
</span>
|
||||
{useWorktree && <Check size={16} className="shrink-0 text-[var(--color-brand)]" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -705,7 +705,7 @@ export const en = {
|
||||
'repoLaunch.checkedOut': 'Checked out in another worktree',
|
||||
'repoLaunch.worktreeCurrent': 'Current worktree',
|
||||
'repoLaunch.worktreeIsolated': 'Isolated worktree',
|
||||
'repoLaunch.toggleWorktree': 'Toggle worktree isolation',
|
||||
'repoLaunch.selectWorktree': 'Select worktree mode',
|
||||
'repoLaunch.notGit': 'Current project is not a Git repository.',
|
||||
'repoLaunch.missingWorkdir': 'Working directory is missing.',
|
||||
'repoLaunch.dirtyWarning': 'Uncommitted changes detected. Direct switching will be blocked; enable isolated worktree to continue without touching this folder.',
|
||||
|
||||
@ -707,7 +707,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'repoLaunch.checkedOut': '已在其他工作树中检出',
|
||||
'repoLaunch.worktreeCurrent': '当前工作树',
|
||||
'repoLaunch.worktreeIsolated': '独立工作树',
|
||||
'repoLaunch.toggleWorktree': '切换工作树隔离',
|
||||
'repoLaunch.selectWorktree': '选择工作树模式',
|
||||
'repoLaunch.notGit': '当前项目不是 Git 仓库。',
|
||||
'repoLaunch.missingWorkdir': '工作目录不存在。',
|
||||
'repoLaunch.dirtyWarning': '检测到未提交变更,直接切换会被阻止。开启独立工作树即可继续,且不会改动当前目录。',
|
||||
|
||||
@ -344,6 +344,9 @@ describe('EmptySession', () => {
|
||||
expect(screen.getByText('Isolated worktree')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Select worktree mode: Isolated worktree/ }))
|
||||
expect(await screen.findByRole('option', { name: 'Current worktree' })).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user