Show split actions on the active pane header (#695)
This commit is contained in:
parent
639f126aa6
commit
7db6270b91
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in New Issue