Show split actions on the active pane header (#695)

This commit is contained in:
Brennan Benson 2026-04-16 11:59:36 -07:00 committed by GitHub
parent 639f126aa6
commit 7db6270b91
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 142 additions and 95 deletions

View File

@ -1,21 +1,25 @@
import { lazy, Suspense, useCallback, useEffect, useState } from 'react'
import { Columns2, Rows2, X } from 'lucide-react'
import { lazy, Suspense } from 'react'
import { Columns2, Ellipsis, Rows2, X } from 'lucide-react'
import { useAppStore } from '../../store'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import TabBar from '../tab-bar/TabBar'
import TerminalPane from '../terminal-pane/TerminalPane'
import BrowserPane from '../browser-pane/BrowserPane'
import { useTabGroupWorkspaceModel } from './useTabGroupWorkspaceModel'
const EditorPanel = lazy(() => import('../editor/EditorPanel'))
const isMac = navigator.userAgent.includes('Mac')
export default function TabGroupPanel({
groupId,
worktreeId,
isFocused,
hasSplitGroups,
showSplitButton,
reserveClosedExplorerToggleSpace,
reserveCollapsedSidebarHeaderSpace
}: {
@ -23,46 +27,12 @@ export default function TabGroupPanel({
worktreeId: string
isFocused: boolean
hasSplitGroups: boolean
showSplitButton: boolean
reserveClosedExplorerToggleSpace: boolean
reserveCollapsedSidebarHeaderSpace: boolean
}): React.JSX.Element {
const rightSidebarOpen = useAppStore((state) => state.rightSidebarOpen)
const sidebarOpen = useAppStore((state) => state.sidebarOpen)
// Why: track Option/Alt key state so the split button icon can preview the
// alternate direction (down) before the user clicks, matching the modifier-
// toggle convention used in VS Code and macOS toolbars.
const [altHeld, setAltHeld] = useState(false)
useEffect(() => {
if (!showSplitButton) {
return
}
const clearAltHeld = (): void => {
setAltHeld(false)
}
const down = (e: KeyboardEvent): void => {
if (e.key === 'Alt') {
setAltHeld(true)
}
}
const up = (e: KeyboardEvent): void => {
if (e.key === 'Alt') {
clearAltHeld()
}
}
window.addEventListener('keydown', down)
window.addEventListener('keyup', up)
// Why: if the user Alt+Tabs away, this window may never receive the keyup
// event, so clear the preview state on blur to avoid a stuck split-down icon.
window.addEventListener('blur', clearAltHeld)
return () => {
window.removeEventListener('keydown', down)
window.removeEventListener('keyup', up)
window.removeEventListener('blur', clearAltHeld)
}
}, [showSplitButton])
const model = useTabGroupWorkspaceModel({ groupId, worktreeId })
const {
activeBrowserTab,
@ -76,15 +46,6 @@ export default function TabGroupPanel({
worktreePath
} = model
const handleSplit = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation()
const direction = event.altKey ? 'down' : 'right'
commands.createSplitGroup(direction)
},
[commands]
)
const tabBar = (
<TabBar
tabs={terminalTabs}
@ -165,12 +126,18 @@ export default function TabGroupPanel({
/>
)
const menuButtonClassName =
'my-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent'
const actionChromeClassName = `flex shrink-0 items-center overflow-hidden transition-[width,margin,opacity] duration-150 ${
isFocused
? 'ml-1.5 w-7 pointer-events-auto opacity-100'
: 'ml-1.5 w-7 pointer-events-none opacity-0'
}`
return (
<div
className={`flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
hasSplitGroups
? ` group/tab-group border-x border-b ${isFocused ? 'border-accent' : 'border-border'}`
: ''
className={`group/tab-group flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
hasSplitGroups ? ` border-x border-b ${isFocused ? 'border-accent' : 'border-border'}` : ''
}`}
onPointerDown={commands.focusGroup}
// Why: keyboard and assistive-tech users can move focus into an unfocused
@ -205,44 +172,80 @@ export default function TabGroupPanel({
width of that controls cluster instead of the old full sidebar
width so tabs cap at the agent badge, not at the old divider. */}
<div className="min-w-0 flex-1 h-full">{tabBar}</div>
{showSplitButton && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={altHeld ? 'Split Editor Down' : 'Split Editor Right'}
onClick={handleSplit}
className="my-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
{altHeld ? <Rows2 size={16} /> : <Columns2 size={16} />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
<div className="flex flex-col">
<span>Split Right</span>
{/* Why: split-button modifier hints appear in a shared header UI,
so the label must match the current platform's modifier
vocabulary instead of always showing Mac glyphs. */}
<span className="text-muted-foreground">[{isMac ? '⌥' : 'Alt'}] Split Down</span>
</div>
</TooltipContent>
</Tooltip>
)}
{hasSplitGroups && (
<button
type="button"
aria-label="Close Group"
title="Close Group"
onClick={(event) => {
event.stopPropagation()
commands.closeGroup()
}}
className="my-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground"
>
<X className="size-4" />
</button>
)}
{/* Why: pane-scoped layout actions belong with the active pane instead
of the global tab-bar `+`, which should keep opening tabs exactly
as before. The local overflow menu holds split directions and
close-group without changing the existing tab-creation affordance. */}
<div
className={actionChromeClassName}
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
{isFocused ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Pane Actions"
title="Pane Actions"
onClick={(event) => {
event.stopPropagation()
}}
className={menuButtonClassName}
>
<Ellipsis className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" sideOffset={4}>
<DropdownMenuItem
onSelect={() => {
commands.createSplitGroup('right')
}}
>
<Columns2 className="size-4" />
Split Right
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
commands.createSplitGroup('down')
}}
>
<Rows2 className="size-4" />
Split Down
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
commands.createSplitGroup('left')
}}
>
<Columns2 className="size-4" />
Split Left
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
commands.createSplitGroup('up')
}}
>
<Rows2 className="size-4" />
Split Up
</DropdownMenuItem>
{hasSplitGroups ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => {
commands.closeGroup()
}}
>
<X className="size-4" />
Close Group
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
</div>

View File

@ -32,6 +32,8 @@ describe('TabGroupSplitLayout', () => {
worktreeId: string
isFocused: boolean
hasSplitGroups: boolean
reserveClosedExplorerToggleSpace: boolean
reserveCollapsedSidebarHeaderSpace: boolean
}
}
@ -41,7 +43,9 @@ describe('TabGroupSplitLayout', () => {
groupId: 'group-1',
worktreeId: 'wt-1',
isFocused: false,
hasSplitGroups: false
hasSplitGroups: false,
reserveClosedExplorerToggleSpace: true,
reserveCollapsedSidebarHeaderSpace: true
})
)
})
@ -52,7 +56,50 @@ describe('TabGroupSplitLayout', () => {
groupId: 'group-1',
worktreeId: 'wt-1',
isFocused: true,
hasSplitGroups: false
hasSplitGroups: false,
reserveClosedExplorerToggleSpace: true,
reserveCollapsedSidebarHeaderSpace: true
})
)
})
it('only reserves top-right header space for the floating explorer toggle', () => {
const element = TabGroupSplitLayout({
layout: {
type: 'split',
direction: 'horizontal',
ratio: 0.5,
first: { type: 'leaf', groupId: 'left-group' },
second: { type: 'leaf', groupId: 'right-group' }
},
worktreeId: 'wt-1',
focusedGroupId: 'right-group',
isWorktreeActive: true
})
const splitNodeElement = element.props.children
const rootElement = splitNodeElement.type(splitNodeElement.props)
const leftChild = rootElement.props.children[0].props.children
const rightChild = rootElement.props.children[2].props.children
const leftPanelProps = leftChild.type(leftChild.props).props as {
reserveClosedExplorerToggleSpace: boolean
reserveCollapsedSidebarHeaderSpace: boolean
}
const rightPanelProps = rightChild.type(rightChild.props).props as {
reserveClosedExplorerToggleSpace: boolean
reserveCollapsedSidebarHeaderSpace: boolean
}
expect(leftPanelProps).toEqual(
expect.objectContaining({
reserveClosedExplorerToggleSpace: false,
reserveCollapsedSidebarHeaderSpace: true
})
)
expect(rightPanelProps).toEqual(
expect.objectContaining({
reserveClosedExplorerToggleSpace: true,
reserveCollapsedSidebarHeaderSpace: false
})
)
})

View File

@ -113,9 +113,6 @@ function SplitNode({
// "focused", Cmd/Ctrl+W and split shortcuts can hit the wrong worktree.
isFocused={isWorktreeActive && node.groupId === focusedGroupId}
hasSplitGroups={hasSplitGroups}
// Why: only the top-right group renders the split button so it appears
// exactly once in the workspace, in the top-right corner of the header.
showSplitButton={touchesTopEdge && touchesRightEdge}
reserveClosedExplorerToggleSpace={touchesTopEdge && touchesRightEdge}
reserveCollapsedSidebarHeaderSpace={touchesTopEdge && touchesLeftEdge}
/>