feat(desktop): keep panel tabs in the titlebar
This commit is contained in:
@@ -37,8 +37,6 @@ import {
|
||||
import { $workspaceOwnerLabels, workspaceOwnerTitle } from '@/components/pane-shell/workspace-scope'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { discoverBundledPlugins } from '@/contrib/plugins'
|
||||
import { Slot } from '@/contrib/react/slot'
|
||||
import { useContributions } from '@/contrib/react/use-contributions'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
|
||||
import { translateNow } from '@/i18n'
|
||||
@@ -800,26 +798,6 @@ registerPaneCloser('files', () =>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TitlebarSlotProps {
|
||||
area: 'titleBar.center' | 'titleBar.left' | 'titleBar.right'
|
||||
className: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
function TitlebarSlot({ area, className, style }: TitlebarSlotProps) {
|
||||
const items = useContributions(area)
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
<Slot area={area} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContribController() {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const statusbarVisible = useStore($statusbarVisible)
|
||||
@@ -864,57 +842,7 @@ export function ContribController() {
|
||||
data-contrib-shell=""
|
||||
style={{ '--titlebar-height': '0px' } as CSSProperties}
|
||||
>
|
||||
{/* Title bar: fixed chrome outside the grid, composable via slots.
|
||||
Layout contract (no contribution can break it):
|
||||
- a full-bar DRAG BASE underneath (pointer-events-none, like
|
||||
AppShell's drag strips) — everywhere without content drags
|
||||
the window;
|
||||
- each slot region is width-fit, no-drag, pointer-events-auto,
|
||||
so every contribution is clickable by construction;
|
||||
- LEFT/RIGHT slots align to the MAIN PANE's geometry via the
|
||||
tree-published --workspace-left/right vars (pure CSS, no rect
|
||||
threading), clamped to clear the REAL TitlebarControls
|
||||
clusters (fixed, z-70); center is truly window-centered. */}
|
||||
<div className="relative flex h-[34px] shrink-0 items-center bg-(--ui-sidebar-surface-background) text-xs">
|
||||
{/* Drag strips, AppShell-style: cut to AVOID the fixed control
|
||||
clusters instead of overlapping them — Electron's no-drag
|
||||
carve-out of fixed/transformed elements is unreliable, so a
|
||||
full-bar drag base kills their clicks. In-flow slot content
|
||||
still carves via its own no-drag wrapper (the same pattern as
|
||||
the app's session-title button). */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 left-0 w-(--titlebar-controls-left,14px) [-webkit-app-region:drag]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 left-[calc(var(--titlebar-controls-left,14px)+(var(--titlebar-control-size,24px)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,5.5rem)+0.75rem)] [-webkit-app-region:drag]"
|
||||
/>
|
||||
<TitlebarSlot
|
||||
area="titleBar.left"
|
||||
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
|
||||
style={{
|
||||
left: 'max(calc(var(--workspace-left, 0px) + 0.5rem), calc(var(--titlebar-controls-left, 14px) + 2 * var(--titlebar-control-size, 24px) + 1rem))'
|
||||
}}
|
||||
/>
|
||||
<TitlebarSlot
|
||||
area="titleBar.center"
|
||||
className="pointer-events-auto absolute left-1/2 top-1/2 z-10 flex w-max -translate-x-1/2 -translate-y-1/2 items-center gap-2 [-webkit-app-region:no-drag]"
|
||||
/>
|
||||
<TitlebarSlot
|
||||
area="titleBar.right"
|
||||
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
|
||||
style={{
|
||||
right:
|
||||
// Five static cluster buttons: four systemTools plus the
|
||||
// always-present right-sidebar toggle (titlebar-controls.tsx).
|
||||
// Keep in sync with wiring.tsx's SYSTEM_TOOL_COUNT.
|
||||
'max(calc(var(--workspace-right, 0px) + 0.5rem), calc(var(--titlebar-tools-right, 0.75rem) + 5 * var(--titlebar-control-size, 24px) + 0.5rem))'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LayoutTreeRoot />
|
||||
<LayoutTreeRoot titlebar />
|
||||
|
||||
{/* "Close running tab?" — the busy/input-blocked tile close gate. */}
|
||||
<SessionTileCloseConfirm />
|
||||
|
||||
@@ -1144,20 +1144,12 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
const titlebarToolsRight = titlebarToolsRightCss(nativeOverlayWidth, titlebarChrome)
|
||||
// Pane-registered tools (preview's monitor/devtools cluster) anchor flush
|
||||
// against the static system cluster — in the tree layout the titlebar band
|
||||
// sits ABOVE the grid, so AppShell's pane-width anchoring doesn't apply.
|
||||
// Count every button the static cluster actually renders: four systemTools
|
||||
// (layout, haptics, keybinds, settings) PLUS the always-present
|
||||
// right-sidebar toggle (see titlebar-controls.tsx). A shared width that
|
||||
// under-counts leaves the find bar, the titlebar header padding, and the
|
||||
// pane-cluster anchor overlapping the fifth button.
|
||||
const SYSTEM_TOOL_COUNT = 5
|
||||
const paneToolCount = rightTitlebarTools.filter(tool => !tool.hidden).length
|
||||
const systemToolsWidth = titlebarToolsWidthCss(SYSTEM_TOOL_COUNT)
|
||||
// App controls live on the left; flip and the right toggle share the right.
|
||||
const titlebarToolsWidth = titlebarToolsWidthCss(2)
|
||||
|
||||
const titlebarToolsWidth =
|
||||
paneToolCount > 0 ? `calc(${systemToolsWidth} + ${titlebarToolsWidthCss(paneToolCount)})` : systemToolsWidth
|
||||
const leftToolsWidth = titlebarToolsWidthCss(
|
||||
4 + [...leftTitlebarTools, ...rightTitlebarTools].filter(tool => !tool.hidden).length
|
||||
)
|
||||
|
||||
return (
|
||||
<ContribWiringContext.Provider value={api}>
|
||||
@@ -1167,10 +1159,10 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
{
|
||||
'--titlebar-controls-left': `${controlsPos.left}px`,
|
||||
'--titlebar-controls-top': `${controlsPos.top}px`,
|
||||
'--titlebar-controls-width': leftToolsWidth,
|
||||
'--titlebar-controls-y-nudge': titlebarControlsYNudge(titlebarChrome),
|
||||
'--titlebar-tools-right': titlebarToolsRight,
|
||||
'--titlebar-tools-width': titlebarToolsWidth,
|
||||
'--shell-preview-toolbar-gap': systemToolsWidth
|
||||
'--titlebar-tools-width': titlebarToolsWidth
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
|
||||
@@ -8,12 +8,12 @@ import { resetLayoutTree } from '@/components/pane-shell/tree/store'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
|
||||
import { Slot } from '@/contrib/react/slot'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { compactNumber } from '@/lib/format'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { formatModifierToken } from '@/lib/keybinds/combo'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
|
||||
import { toggleHud } from '@/store/hud'
|
||||
import {
|
||||
$fileBrowserOpen,
|
||||
@@ -134,7 +134,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const modHeld = useModifierHeld()
|
||||
const hapticsMuted = useStore($hapticsMuted)
|
||||
const fileBrowserOpen = useStore($fileBrowserOpen)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
@@ -142,18 +141,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
const unreadBadge = unreadCount > 0 ? unreadCount : undefined
|
||||
const unreadHint = unreadBadge ? ` · ${t.titlebar.unreadSessions(unreadBadge)}` : ''
|
||||
|
||||
const toggleHaptics = () => {
|
||||
if (!hapticsMuted) {
|
||||
triggerHaptic('tap')
|
||||
}
|
||||
|
||||
toggleHapticsMuted()
|
||||
|
||||
if (hapticsMuted) {
|
||||
window.requestAnimationFrame(() => triggerHaptic('success'))
|
||||
}
|
||||
}
|
||||
|
||||
// POSITIONAL toggles: each button shows/hides everything on its physical
|
||||
// side of the main zone (the layout tree collapses the whole side), so they
|
||||
// stay correct through flips and rearranges. $sidebarOpen ≙ left side,
|
||||
@@ -164,30 +151,28 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
const leftLabel = leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar
|
||||
const rightLabel = rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar
|
||||
|
||||
const leftToolbarTools: TitlebarTool[] = [
|
||||
{
|
||||
actionId: 'view.toggleSidebar',
|
||||
badge: panesFlipped ? undefined : unreadBadge,
|
||||
icon: <TitlebarIcon name="layout-sidebar-left" />,
|
||||
id: 'sidebar',
|
||||
label: `${leftLabel}${panesFlipped ? '' : unreadHint}`,
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
leftEdge.toggle()
|
||||
}
|
||||
},
|
||||
{
|
||||
actionId: 'view.flipPanes',
|
||||
icon: <TitlebarIcon name="arrow-swap" />,
|
||||
id: 'flip-panes',
|
||||
label: t.titlebar.swapSidebarSides,
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
togglePanesFlipped()
|
||||
}
|
||||
},
|
||||
...leftTools
|
||||
]
|
||||
const sidebarTool: TitlebarTool = {
|
||||
actionId: 'view.toggleSidebar',
|
||||
badge: panesFlipped ? undefined : unreadBadge,
|
||||
icon: <TitlebarIcon name="layout-sidebar-left" />,
|
||||
id: 'sidebar',
|
||||
label: `${leftLabel}${panesFlipped ? '' : unreadHint}`,
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
leftEdge.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
const flipTool: TitlebarTool = {
|
||||
actionId: 'view.flipPanes',
|
||||
icon: <TitlebarIcon name="arrow-swap" />,
|
||||
id: 'flip-panes',
|
||||
label: t.titlebar.swapSidebarSides,
|
||||
onSelect: () => {
|
||||
triggerHaptic('tap')
|
||||
togglePanesFlipped()
|
||||
}
|
||||
}
|
||||
|
||||
const rightSidebarTool: TitlebarTool = {
|
||||
actionId: 'view.toggleRightSidebar',
|
||||
@@ -202,8 +187,18 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
tour: 'right-pane-toggle'
|
||||
}
|
||||
|
||||
// Static system tools — always pinned to the screen's right edge.
|
||||
// App actions stay visible beside the left sidebar toggle.
|
||||
const systemTools: TitlebarTool[] = [
|
||||
{
|
||||
actionId: 'nav.settings',
|
||||
icon: <TitlebarIcon name="settings-gear" />,
|
||||
id: 'settings',
|
||||
label: t.titlebar.openSettings,
|
||||
onSelect: () => {
|
||||
triggerHaptic('open')
|
||||
onOpenSettings()
|
||||
}
|
||||
},
|
||||
{
|
||||
className: 'group/tool',
|
||||
// Hover + held ⌘/Ctrl morphs the glyph into its reset form (see
|
||||
@@ -237,23 +232,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
triggerHaptic('open')
|
||||
toggleHud(hudTargetSessionId())
|
||||
}
|
||||
},
|
||||
{
|
||||
active: hapticsMuted,
|
||||
icon: <TitlebarIcon name={hapticsMuted ? 'mute' : 'unmute'} />,
|
||||
id: 'haptics',
|
||||
label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
|
||||
onSelect: toggleHaptics
|
||||
},
|
||||
{
|
||||
actionId: 'nav.settings',
|
||||
icon: <TitlebarIcon name="settings-gear" />,
|
||||
id: 'settings',
|
||||
label: t.titlebar.openSettings,
|
||||
onSelect: () => {
|
||||
triggerHaptic('open')
|
||||
onOpenSettings()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -265,8 +243,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
return null
|
||||
}
|
||||
|
||||
const visibleSystemTools = systemTools.filter(tool => !tool.hidden)
|
||||
const visiblePaneTools = tools.filter(tool => !tool.hidden)
|
||||
const visibleLeftTools = [sidebarTool, ...systemTools, ...leftTools, ...tools].filter(tool => !tool.hidden)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -277,42 +254,19 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
|
||||
'left-(--titlebar-controls-left) top-(--titlebar-controls-top) translate-y-(--titlebar-controls-y-nudge)'
|
||||
)}
|
||||
>
|
||||
{leftToolbarTools
|
||||
.filter(tool => !tool.hidden)
|
||||
.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
))}
|
||||
{visibleLeftTools.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
))}
|
||||
<Slot area="titleBar.left" />
|
||||
<Slot area="titleBar.center" />
|
||||
<Slot area="titleBar.right" />
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Pane-scoped tools (preview's monitor / devtools / refresh / X) render
|
||||
as their own fixed cluster. AppShell sets --shell-preview-toolbar-gap
|
||||
to either the static cluster's width (file-browser closed → cluster
|
||||
sits flush against system tools) or the file-browser pane's width
|
||||
(file-browser open → cluster sits flush against the file-browser pane,
|
||||
i.e. at the preview pane's right edge). No margin hacks needed.
|
||||
*/}
|
||||
{visiblePaneTools.length > 0 && (
|
||||
<div
|
||||
aria-label={t.shell.paneControls}
|
||||
className={cn(
|
||||
titlebarToolClusterClass,
|
||||
'top-[calc(var(--titlebar-controls-top)+var(--right-rail-top-inset,0px))] right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0))]'
|
||||
)}
|
||||
>
|
||||
{visiblePaneTools.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
aria-label={t.shell.appControls}
|
||||
className={cn(titlebarToolClusterClass, 'right-(--titlebar-tools-right) top-(--titlebar-controls-top)')}
|
||||
>
|
||||
{visibleSystemTools.map(tool => (
|
||||
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
|
||||
))}
|
||||
<TitlebarToolButton navigate={navigate} tool={flipTool} />
|
||||
<TitlebarToolButton navigate={navigate} tool={rightSidebarTool} />
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -514,6 +514,20 @@ export function startPaneDrag(
|
||||
enterZoneMode()
|
||||
}
|
||||
|
||||
// A strip is an exact target. Resolve it before the fuzzy zone engine:
|
||||
// near a panel seam, proximity can otherwise pick the neighboring sidebar.
|
||||
const hitStrip = !shift && strips.find(strip => rectContains(strip.rect, x, y))
|
||||
|
||||
if (hitStrip) {
|
||||
return {
|
||||
kind: 'group',
|
||||
groupId: hitStrip.groupId,
|
||||
groupIds: [hitStrip.groupId],
|
||||
pos: 'center',
|
||||
stack: slotBefore(hitStrip.slots, x, moving)
|
||||
}
|
||||
}
|
||||
|
||||
// The hint updates on highlight-set changes AND on sub-zone position
|
||||
// changes (center/edge regions within the same primary zone).
|
||||
const point = { x, y }
|
||||
|
||||
@@ -26,6 +26,7 @@ import { type ReactNode, useEffect } from 'react'
|
||||
import { useLayoutEditHotkey } from '../../edit-mode'
|
||||
import { publishWorkspaceGeometry } from '../../geometry'
|
||||
import { $layoutTree, trackActiveTreeGroup } from '../store'
|
||||
import { useTabKeyHints } from '../tab-key-hint-state'
|
||||
import { ZoneEditor } from '../zone-editor'
|
||||
|
||||
import { TreeEditBar } from './edit-bar'
|
||||
@@ -33,10 +34,11 @@ import { FloatingPanes } from './floating-panes'
|
||||
import { NarrowOverlays } from './narrow-overlays'
|
||||
import { TreeNode } from './tree-node'
|
||||
|
||||
export function LayoutTreeRoot({ children }: { children?: ReactNode }) {
|
||||
export function LayoutTreeRoot({ children, titlebar = false }: { children?: ReactNode; titlebar?: boolean }) {
|
||||
const tree = useStore($layoutTree)
|
||||
|
||||
useLayoutEditHotkey(true)
|
||||
useTabKeyHints()
|
||||
// Track the interacted zone so ⌘W closes the right tab even when nothing is
|
||||
// DOM-focused.
|
||||
useEffect(trackActiveTreeGroup, [])
|
||||
@@ -72,7 +74,14 @@ export function LayoutTreeRoot({ children }: { children?: ReactNode }) {
|
||||
display: none;
|
||||
}
|
||||
`}</style>
|
||||
<TreeNode node={tree} root rootRow={tree.type === 'split' && tree.orientation === 'row'} />
|
||||
<TreeNode
|
||||
leftEdge={titlebar}
|
||||
node={tree}
|
||||
rightEdge={titlebar}
|
||||
root
|
||||
rootRow={tree.type === 'split' && tree.orientation === 'row'}
|
||||
topEdge={titlebar}
|
||||
/>
|
||||
<NarrowOverlays />
|
||||
{/* Non-tiling panes: fixed cards above the tree, outside every zone. */}
|
||||
<FloatingPanes />
|
||||
|
||||
@@ -57,6 +57,28 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('TreeGroup', () => {
|
||||
it('keeps a top-edge strip inside its panel and yields native drag while moving a pane', () => {
|
||||
disposePane = registry.register({
|
||||
area: 'panes',
|
||||
data: { placement: 'main' },
|
||||
id: 'terminal',
|
||||
title: 'Terminal',
|
||||
render: () => <div>Terminal</div>
|
||||
})
|
||||
vi.stubGlobal('CSS', { escape: (value: string) => value })
|
||||
render(<TreeGroup leftEdge node={terminalGroup(false)} rightEdge topEdge />)
|
||||
const zone = container!.querySelector('[data-tree-group]')!
|
||||
const strip = zone.querySelector<HTMLElement>('[data-zone-tabstrip]')!
|
||||
const header = zone.querySelector<HTMLElement>('[data-panel-header]')!
|
||||
expect(strip.closest('[data-tree-group]')).toBe(zone)
|
||||
expect(header.contains(strip)).toBe(true)
|
||||
expect(zone.querySelectorAll('[data-window-drag-handle]').length).toBeGreaterThan(0)
|
||||
act(() => $treeDragging.set('terminal'))
|
||||
expect(strip.style).toHaveProperty('WebkitAppRegion', 'no-drag')
|
||||
act(() => $treeDragging.set(null))
|
||||
expect(strip.style).toHaveProperty('WebkitAppRegion', '')
|
||||
})
|
||||
|
||||
it('points the docked-zone chevron in the collapse or restore action direction', () => {
|
||||
disposePane = registry.register({
|
||||
area: 'panes',
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type CSSProperties, Fragment, type ReactNode, type RefObject, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
|
||||
import { ActionsContextMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DecodeText } from '@/components/ui/decode-text'
|
||||
@@ -72,6 +73,7 @@ import {
|
||||
setTreeGroupTabStrip,
|
||||
treeTabCloseTargets
|
||||
} from '../store'
|
||||
import { TabKeyHint } from '../tab-key-hints'
|
||||
import {
|
||||
$tabSelection,
|
||||
clearTabSelection,
|
||||
@@ -219,11 +221,17 @@ function ZoneMenu({
|
||||
export function TreeGroup({
|
||||
node,
|
||||
parentAxis,
|
||||
railSide = 'left'
|
||||
railSide = 'left',
|
||||
topEdge = false,
|
||||
leftEdge = false,
|
||||
rightEdge = false
|
||||
}: {
|
||||
node: GroupNode
|
||||
parentAxis?: 'column' | 'row'
|
||||
railSide?: 'left' | 'right'
|
||||
topEdge?: boolean
|
||||
leftEdge?: boolean
|
||||
rightEdge?: boolean
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
@@ -245,7 +253,7 @@ export function TreeGroup({
|
||||
// overlay, not every zone's header/body (and not the menuDirections walk).
|
||||
const dragging = useStore($treeDragging)
|
||||
const editMode = useStore($layoutEditMode)
|
||||
const wcOverlap = useWindowControlsOverlap(ref, true)
|
||||
const wcOverlap = useWindowControlsOverlap(ref, !topEdge)
|
||||
|
||||
const hiddenPanes = useStore($hiddenTreePanes)
|
||||
const narrow = useStore($narrowViewport)
|
||||
@@ -419,6 +427,7 @@ export function TreeGroup({
|
||||
<div
|
||||
className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-(--ui-editor-surface-background)"
|
||||
data-tree-group={node.id}
|
||||
data-window-top={topEdge || undefined}
|
||||
// Advertises the visible tab strip so panes can drop their own
|
||||
// self-naming labels (see [data-pane-self-label] in styles.css).
|
||||
data-zone-header={headerVisible || undefined}
|
||||
@@ -487,181 +496,218 @@ export function TreeGroup({
|
||||
</ZoneMenu>
|
||||
)}
|
||||
|
||||
{/* Header: the shared pane tab strip (PaneTabStrip + PaneTab). */}
|
||||
{headerVisible && (
|
||||
<ZoneMenu {...zoneMenu}>
|
||||
<PaneTabStrip
|
||||
// data-zone-tabstrip: a drop over here STACKS (drag-session reads it).
|
||||
data-zone-tabstrip={node.id}
|
||||
listRef={tabsRef}
|
||||
onPointerDown={e =>
|
||||
// Drag still moves the pane. Tapping the strip never collapses:
|
||||
// the chevron is the collapse affordance. Overloading the header
|
||||
// (and, in a lone-tab zone, the tab sitting in it) made a click
|
||||
// on the active chip fold the zone — and on a row-docked tile
|
||||
// the chip vanished with the body, leaving no mouse path back.
|
||||
// A minimized strip still restores on tap (it IS the handle).
|
||||
startPaneDrag(
|
||||
activeId,
|
||||
e,
|
||||
node.minimized ? () => restoreTreePane(activeId) : undefined,
|
||||
undefined,
|
||||
active?.title ?? activeId
|
||||
)
|
||||
}
|
||||
ref={stripRef}
|
||||
style={{ cursor: 'grab' }}
|
||||
trailing={
|
||||
<>
|
||||
{minimizable && (
|
||||
<button
|
||||
aria-label={node.minimized ? t.zones.restore : t.zones.minimize}
|
||||
className="mx-1 grid size-5 shrink-0 place-items-center self-center rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 group-hover/pane-header:opacity-100"
|
||||
onClick={toggleCollapse}
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name={node.minimized ? 'chevron-up' : 'chevron-down'} size="0.75rem" />
|
||||
</button>
|
||||
)}
|
||||
<StripDropCaret groupId={node.id} stripRef={stripRef} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{shown.map(paneId => {
|
||||
const isActive = paneId === activeId && !node.minimized
|
||||
const chrome = paneChrome(paneFor(paneId))
|
||||
const closeable = closeableTab(paneId)
|
||||
const title = paneFor(paneId)?.title ?? paneId
|
||||
const isSelected = tabSelection?.groupId === node.id && tabSelection.ids.has(paneId)
|
||||
{/* Keep the header INSIDE its zone: titlebar drops use the same panel
|
||||
bounds, strip refs, focus ownership and split geometry as the body. */}
|
||||
{(headerVisible || topEdge) && (
|
||||
<div
|
||||
className="flex min-w-0 shrink-0 bg-(--ui-sidebar-surface-background)"
|
||||
data-panel-header=""
|
||||
style={topEdge ? { height: TITLEBAR_HEIGHT } : undefined}
|
||||
>
|
||||
{topEdge && leftEdge && (
|
||||
<div className="flex shrink-0">
|
||||
<div className="w-(--titlebar-controls-left,14px) [-webkit-app-region:drag]" data-window-drag-handle="" />
|
||||
<div className="relative w-(--titlebar-controls-width,96px)">
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 h-(--titlebar-controls-top,5px) [-webkit-app-region:drag]"
|
||||
data-window-drag-handle=""
|
||||
/>
|
||||
</div>
|
||||
<div className="w-3 [-webkit-app-region:drag]" data-window-drag-handle="" />
|
||||
</div>
|
||||
)}
|
||||
{headerVisible ? (
|
||||
<ZoneMenu {...zoneMenu}>
|
||||
<PaneTabStrip
|
||||
className="flex-1"
|
||||
// data-zone-tabstrip: a drop over here STACKS (drag-session reads it).
|
||||
data-zone-tabstrip={node.id}
|
||||
listRef={tabsRef}
|
||||
onPointerDown={event => {
|
||||
// Native titlebar gaps move the window; tabs keep their own drag.
|
||||
if (!topEdge) {
|
||||
startPaneDrag(
|
||||
activeId,
|
||||
event,
|
||||
node.minimized ? () => restoreTreePane(activeId) : undefined,
|
||||
undefined,
|
||||
active?.title ?? activeId
|
||||
)
|
||||
}
|
||||
}}
|
||||
ref={stripRef}
|
||||
style={{ cursor: 'grab', WebkitAppRegion: dragging ? 'no-drag' : undefined } as CSSProperties}
|
||||
titlebar={topEdge}
|
||||
trailing={
|
||||
<>
|
||||
{minimizable && (
|
||||
<button
|
||||
aria-label={node.minimized ? t.zones.restore : t.zones.minimize}
|
||||
className="mx-1 grid size-5 shrink-0 place-items-center self-center [-webkit-app-region:no-drag] rounded-md text-(--ui-text-tertiary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground focus-visible:opacity-100 group-hover/pane-header:opacity-100"
|
||||
onClick={toggleCollapse}
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name={node.minimized ? 'chevron-up' : 'chevron-down'} size="0.75rem" />
|
||||
</button>
|
||||
)}
|
||||
<StripDropCaret groupId={node.id} stripRef={stripRef} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{shown.map((paneId, index) => {
|
||||
const isActive = paneId === activeId && !node.minimized
|
||||
const chrome = paneChrome(paneFor(paneId))
|
||||
const closeable = closeableTab(paneId)
|
||||
const title = paneFor(paneId)?.title ?? paneId
|
||||
const isSelected = tabSelection?.groupId === node.id && tabSelection.ids.has(paneId)
|
||||
|
||||
const tab = (
|
||||
<PaneTab
|
||||
active={isActive}
|
||||
aria-selected={isActive}
|
||||
data-tree-tab={paneId}
|
||||
key={paneId}
|
||||
onClose={closeable ? () => closeTab(paneId) : undefined}
|
||||
onPointerDown={e => {
|
||||
// Chrome's tab-selection grammar, ahead of activate/drag:
|
||||
// Shift-click ranges from the anchor, ⌥-click (Ctrl-click
|
||||
// off-Mac) toggles. Neither activates nor starts a drag —
|
||||
// the press IS the selection edit. ⌘-click stays close
|
||||
// (PaneTab claims it first) and ⌃-click stays the macOS
|
||||
// context menu.
|
||||
if (e.button === 0 && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
selectTabRange(node.id, shown, paneId, activeId)
|
||||
const tab = (
|
||||
<PaneTab
|
||||
active={isActive}
|
||||
aria-selected={isActive}
|
||||
data-tree-tab={paneId}
|
||||
key={paneId}
|
||||
onClose={closeable ? () => closeTab(paneId) : undefined}
|
||||
onPointerDown={e => {
|
||||
// Chrome's tab-selection grammar, ahead of activate/drag:
|
||||
// Shift-click ranges from the anchor, ⌥-click (Ctrl-click
|
||||
// off-Mac) toggles. Neither activates nor starts a drag —
|
||||
// the press IS the selection edit. ⌘-click stays close
|
||||
// (PaneTab claims it first) and ⌃-click stays the macOS
|
||||
// context menu.
|
||||
if (e.button === 0 && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
selectTabRange(node.id, shown, paneId, activeId)
|
||||
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isToggleSelectClick(e)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
toggleTabSelected(node.id, paneId, activeId)
|
||||
if (isToggleSelectClick(e)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
toggleTabSelected(node.id, paneId, activeId)
|
||||
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Tabs ACTIVATE (restoring a collapsed group). Minimize
|
||||
// lives on the chevron — overloading the active tab made
|
||||
// double-click a minimize/restore/hide lottery. A plain
|
||||
// click also collapses any multi-tab selection back to the
|
||||
// one tab (Chrome semantics).
|
||||
const onTap = () => {
|
||||
clearTabSelection()
|
||||
// Tabs ACTIVATE (restoring a collapsed group). Minimize
|
||||
// lives on the chevron — overloading the active tab made
|
||||
// double-click a minimize/restore/hide lottery. A plain
|
||||
// click also collapses any multi-tab selection back to the
|
||||
// one tab (Chrome semantics).
|
||||
const onTap = () => {
|
||||
clearTabSelection()
|
||||
|
||||
if (node.minimized) {
|
||||
restoreTreePane(paneId)
|
||||
}
|
||||
if (node.minimized) {
|
||||
restoreTreePane(paneId)
|
||||
}
|
||||
|
||||
activateTreePane(node.id, paneId)
|
||||
}
|
||||
activateTreePane(node.id, paneId)
|
||||
}
|
||||
|
||||
// Claim the press so the STRIP's own pane-drag handler
|
||||
// (parent onPointerDown) can't also fire. startPaneDrag
|
||||
// does this internally; the session drag (shared with
|
||||
// sidebar rows) doesn't, so do it here for both paths.
|
||||
if (e.button === 0) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
// Claim the press so the STRIP's own pane-drag handler
|
||||
// (parent onPointerDown) can't also fire. startPaneDrag
|
||||
// does this internally; the session drag (shared with
|
||||
// sidebar rows) doesn't, so do it here for both paths.
|
||||
if (e.button === 0) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
// Dragging a SELECTED tab carries the whole selection as
|
||||
// one block through the generic pane move — a multi-tab
|
||||
// drag outranks the pane's own tab drag (the session drop
|
||||
// language is single-session).
|
||||
const dragSelection = selectionFor(node.id, shown, paneId)
|
||||
// Dragging a SELECTED tab carries the whole selection as
|
||||
// one block through the generic pane move — a multi-tab
|
||||
// drag outranks the pane's own tab drag (the session drop
|
||||
// language is single-session).
|
||||
const dragSelection = selectionFor(node.id, shown, paneId)
|
||||
|
||||
if (dragSelection) {
|
||||
startPaneDrag(
|
||||
paneId,
|
||||
e,
|
||||
onTap,
|
||||
stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined,
|
||||
t.zones.tabCount(dragSelection.length),
|
||||
dragSelection
|
||||
)
|
||||
if (dragSelection) {
|
||||
startPaneDrag(
|
||||
paneId,
|
||||
e,
|
||||
onTap,
|
||||
stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined,
|
||||
t.zones.tabCount(dragSelection.length),
|
||||
dragSelection
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// A pane may own its tab drag (a session tab speaks the
|
||||
// session drop language — link/stack/split); `false` defers
|
||||
// to the generic pane move (the workspace tab on a fresh
|
||||
// draft has no session to link).
|
||||
if (!chrome.tabDrag?.(e, onTap)) {
|
||||
startPaneDrag(
|
||||
paneId,
|
||||
e,
|
||||
onTap,
|
||||
stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined,
|
||||
title
|
||||
)
|
||||
}
|
||||
}}
|
||||
role="tab"
|
||||
selected={isSelected}
|
||||
style={{ cursor: 'grab' }}
|
||||
>
|
||||
{chrome.tabLead ? (
|
||||
<span className="ml-2 -mr-1 flex shrink-0 items-center">{chrome.tabLead()}</span>
|
||||
) : null}
|
||||
<PaneTabLabel>{tabLabel(paneId)}</PaneTabLabel>
|
||||
</PaneTab>
|
||||
)
|
||||
// A pane may own its tab drag (a session tab speaks the
|
||||
// session drop language — link/stack/split); `false` defers
|
||||
// to the generic pane move (the workspace tab on a fresh
|
||||
// draft has no session to link).
|
||||
if (!chrome.tabDrag?.(e, onTap)) {
|
||||
startPaneDrag(
|
||||
paneId,
|
||||
e,
|
||||
onTap,
|
||||
stripRef.current ? { groupId: node.id, strip: stripRef.current } : undefined,
|
||||
title
|
||||
)
|
||||
}
|
||||
}}
|
||||
role="tab"
|
||||
selected={isSelected}
|
||||
style={{ cursor: 'grab' }}
|
||||
>
|
||||
{chrome.tabLead ? (
|
||||
<span className="ml-2 -mr-1 flex shrink-0 items-center">
|
||||
<TabKeyHint groupId={node.id} slot={index + 1}>
|
||||
{chrome.tabLead()}
|
||||
</TabKeyHint>
|
||||
</span>
|
||||
) : null}
|
||||
<PaneTabLabel>{tabLabel(paneId)}</PaneTabLabel>
|
||||
</PaneTab>
|
||||
)
|
||||
|
||||
// A pane may wrap ITS tab in a domain menu (session verbs on a
|
||||
// tile tab); the wrapper needs the key since it's the root.
|
||||
return <Fragment key={paneId}>{chrome.tabWrap ? chrome.tabWrap(tab) : tab}</Fragment>
|
||||
})}
|
||||
// A pane may wrap ITS tab in a domain menu (session verbs on a
|
||||
// tile tab); the wrapper needs the key since it's the root.
|
||||
return <Fragment key={paneId}>{chrome.tabWrap ? chrome.tabWrap(tab) : tab}</Fragment>
|
||||
})}
|
||||
|
||||
{/* Plain "+" after the last tab — it mints another tab of the kind
|
||||
{/* Plain "+" after the last tab — it mints another tab of the kind
|
||||
you are LOOKING AT, so a Browser strip makes another Browser
|
||||
and a chat strip makes another session (mirrors ⌘T, via the
|
||||
app-registered action). The pointerdown focuses this zone
|
||||
first, so the tab lands in THIS strip. Hidden when the active
|
||||
pane is one of a kind and the zone holds no session tabs. */}
|
||||
{newTab && !node.minimized && (
|
||||
<span
|
||||
// The action docks into the FOCUSED chat zone; clicking a
|
||||
// background strip's "+" must make THAT zone the focused one
|
||||
// first, or the tab opens in whichever zone was last clicked.
|
||||
// (pointerdown's own focus tracking would land after the click
|
||||
// handler reads the anchor.)
|
||||
onPointerDownCapture={() => noteActiveTreeGroup(node.id)}
|
||||
>
|
||||
<PaneStripGlyph
|
||||
icon={<Codicon name="add" size="0.8125rem" />}
|
||||
label={newTab.label}
|
||||
onSelect={newTab.onSelect}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</PaneTabStrip>
|
||||
</ZoneMenu>
|
||||
{newTab && !node.minimized && (
|
||||
<span
|
||||
className="flex shrink-0 items-center [-webkit-app-region:no-drag]"
|
||||
// The action docks into the FOCUSED chat zone; clicking a
|
||||
// background strip's "+" must make THAT zone the focused one
|
||||
// first, or the tab opens in whichever zone was last clicked.
|
||||
// (pointerdown's own focus tracking would land after the click
|
||||
// handler reads the anchor.)
|
||||
onPointerDownCapture={() => noteActiveTreeGroup(node.id)}
|
||||
>
|
||||
<PaneStripGlyph
|
||||
icon={<Codicon name="add" size="0.8125rem" />}
|
||||
label={newTab.label}
|
||||
onSelect={newTab.onSelect}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</PaneTabStrip>
|
||||
</ZoneMenu>
|
||||
) : (
|
||||
<div className="min-w-0 flex-1 [-webkit-app-region:drag]" />
|
||||
)}
|
||||
{topEdge && rightEdge && (
|
||||
<div className="flex shrink-0">
|
||||
<div
|
||||
className="w-6"
|
||||
data-window-drag-handle=""
|
||||
style={{ WebkitAppRegion: dragging ? 'no-drag' : 'drag' } as CSSProperties}
|
||||
/>
|
||||
<div className="w-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,24px))] [-webkit-app-region:no-drag]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body: the zone's pane content — the active pane and bounded hot-hidden
|
||||
@@ -735,7 +781,7 @@ export function TreeGroup({
|
||||
className="absolute inset-x-0 bottom-0 z-50 flex cursor-grab items-center justify-center outline-1 -outline-offset-2 outline-dashed backdrop-blur-[2px]"
|
||||
onPointerDown={e => startPaneDrag(activeId, e, undefined, undefined, active?.title ?? activeId)}
|
||||
style={{
|
||||
top: headerVisible ? 28 : 0,
|
||||
top: topEdge ? TITLEBAR_HEIGHT : headerVisible ? 28 : 0,
|
||||
background:
|
||||
'color-mix(in srgb, var(--ui-accent) 6%, color-mix(in srgb, var(--ui-bg-chrome) 55%, transparent))',
|
||||
outlineColor: 'color-mix(in srgb, var(--ui-accent) 55%, transparent)'
|
||||
|
||||
@@ -17,17 +17,30 @@ export function TreeNode({
|
||||
parentAxis,
|
||||
railSide,
|
||||
root,
|
||||
rootRow
|
||||
rootRow,
|
||||
topEdge = false,
|
||||
leftEdge = false,
|
||||
rightEdge = false
|
||||
}: {
|
||||
node: LayoutNode
|
||||
parentAxis?: 'column' | 'row'
|
||||
railSide?: 'left' | 'right'
|
||||
root?: boolean
|
||||
rootRow?: boolean
|
||||
topEdge?: boolean
|
||||
leftEdge?: boolean
|
||||
rightEdge?: boolean
|
||||
}) {
|
||||
return node.type === 'split' ? (
|
||||
<TreeSplit node={node} root={root} rootRow={rootRow} />
|
||||
<TreeSplit leftEdge={leftEdge} node={node} rightEdge={rightEdge} root={root} rootRow={rootRow} topEdge={topEdge} />
|
||||
) : (
|
||||
<TreeGroup node={node} parentAxis={parentAxis} railSide={railSide} />
|
||||
<TreeGroup
|
||||
leftEdge={leftEdge}
|
||||
node={node}
|
||||
parentAxis={parentAxis}
|
||||
railSide={railSide}
|
||||
rightEdge={rightEdge}
|
||||
topEdge={topEdge}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,21 @@ function useSubtreeOverrides(paneIds: readonly string[]): TrackContext['override
|
||||
return useSyncExternalStore(cb => $paneStates.listen(cb), snapshot, snapshot)
|
||||
}
|
||||
|
||||
export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boolean; rootRow?: boolean }) {
|
||||
export function TreeSplit({
|
||||
node,
|
||||
root,
|
||||
rootRow,
|
||||
topEdge = false,
|
||||
leftEdge = false,
|
||||
rightEdge = false
|
||||
}: {
|
||||
node: SplitNode
|
||||
root?: boolean
|
||||
rootRow?: boolean
|
||||
topEdge?: boolean
|
||||
leftEdge?: boolean
|
||||
rightEdge?: boolean
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const panes = useContributions('panes')
|
||||
const hiddenPanes = useStore($hiddenTreePanes)
|
||||
@@ -720,10 +734,13 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
|
||||
)}
|
||||
{!narrowCollapsed && (
|
||||
<TreeNode
|
||||
leftEdge={leftEdge && (!horizontal || i === visibleOrder[0])}
|
||||
node={child}
|
||||
parentAxis={axis}
|
||||
railSide={horizontal ? railSideFor(i) : undefined}
|
||||
rightEdge={rightEdge && (!horizontal || i === visibleOrder[visibleOrder.length - 1])}
|
||||
rootRow={rootRow || childRootRow(child)}
|
||||
topEdge={topEdge && (horizontal || i === visibleOrder[0])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -755,6 +755,11 @@ export function tabStripVisibleForGroup(group: GroupNode): boolean {
|
||||
})
|
||||
}
|
||||
|
||||
/** Shared target for tab-number hints and shortcut dispatch. */
|
||||
export function treeTabSlotTarget(): GroupNode | null {
|
||||
return tabTargetGroup(candidate => shownPanesInGroup(candidate).length >= 2)
|
||||
}
|
||||
|
||||
/** ⌘1…⌘9: activate the Nth *visible* tab of the target zone — the first of
|
||||
* hovered / focused / workspace that is a real tab strip (≥2 shown panes).
|
||||
* Pointing at the sidebar (or nothing) therefore still switches main's tabs
|
||||
@@ -763,7 +768,7 @@ export function tabStripVisibleForGroup(group: GroupNode): boolean {
|
||||
* must also route back to the chat) — or null so it falls back to its
|
||||
* default (profile switch) when no zone qualifies. */
|
||||
export function activateTreeTabSlot(slot: number): null | string {
|
||||
const group = tabTargetGroup(candidate => shownPanesInGroup(candidate).length >= 2)
|
||||
const group = treeTabSlotTarget()
|
||||
const panes = group ? shownPanesInGroup(group) : []
|
||||
|
||||
if (!group || slot < 1 || slot > panes.length) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { atom } from 'nanostores'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { isMacPlatform } from '@/lib/platform'
|
||||
import { $capture } from '@/store/keybinds'
|
||||
|
||||
export const $heldTabModifier = atom(false)
|
||||
|
||||
/** One delayed observer; it never consumes or dispatches a key. */
|
||||
export function useTabKeyHints() {
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let down = false
|
||||
|
||||
const clear = () => {
|
||||
clearTimeout(timer)
|
||||
down = false
|
||||
$heldTabModifier.set(false)
|
||||
}
|
||||
|
||||
const sync = (event: KeyboardEvent) => {
|
||||
const held = isMacPlatform() ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
|
||||
|
||||
if (!held || event.altKey || event.shiftKey || event.isComposing || $capture.get()) {
|
||||
clear()
|
||||
} else if (!down) {
|
||||
down = true
|
||||
timer = setTimeout(() => $heldTabModifier.set(true), 400)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', sync, true)
|
||||
window.addEventListener('keyup', sync, true)
|
||||
window.addEventListener('blur', clear)
|
||||
const stopCapture = $capture.listen(clear)
|
||||
|
||||
return () => {
|
||||
clear()
|
||||
stopCapture()
|
||||
window.removeEventListener('keydown', sync, true)
|
||||
window.removeEventListener('keyup', sync, true)
|
||||
window.removeEventListener('blur', clear)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { act } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { isMacPlatform } from '@/lib/platform'
|
||||
import { $bindings, $capture } from '@/store/keybinds'
|
||||
|
||||
import { group, split } from './model'
|
||||
import { $activeTreeGroup, $hoveredTreeGroup, $layoutTree } from './store'
|
||||
import { $heldTabModifier, useTabKeyHints } from './tab-key-hint-state'
|
||||
import { TabKeyHint } from './tab-key-hints'
|
||||
|
||||
const disposers: Array<() => void> = []
|
||||
const container = globalThis.document.createElement('div')
|
||||
let root: ReturnType<typeof createRoot> | undefined
|
||||
|
||||
function Fixture() {
|
||||
useTabKeyHints()
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabKeyHint groupId="left" slot={1}>
|
||||
<span>left dot</span>
|
||||
</TabKeyHint>
|
||||
<TabKeyHint groupId="right" slot={1}>
|
||||
<span>right dot</span>
|
||||
</TabKeyHint>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function modifier(held: boolean) {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent(held ? 'keydown' : 'keyup', {
|
||||
key: isMacPlatform() ? 'Meta' : 'Control',
|
||||
code: isMacPlatform() ? 'MetaLeft' : 'ControlLeft',
|
||||
metaKey: isMacPlatform() && held,
|
||||
ctrlKey: !isMacPlatform() && held
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount())
|
||||
container.remove()
|
||||
disposers.splice(0).forEach(dispose => dispose())
|
||||
$layoutTree.set(null)
|
||||
$activeTreeGroup.set(null)
|
||||
$hoveredTreeGroup.set(null)
|
||||
$capture.set(null)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reveals only the shortcut target after a hold and clears on release, blur or capture', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
for (const id of ['workspace', 'a', 'b', 'c']) {
|
||||
disposers.push(registry.register({ area: 'panes', id, title: id, data: { placement: 'main' }, render: () => null }))
|
||||
}
|
||||
|
||||
$layoutTree.set(split('row', [group(['workspace', 'a'], { id: 'left' }), group(['b', 'c'], { id: 'right' })]))
|
||||
$activeTreeGroup.set('left')
|
||||
$hoveredTreeGroup.set('right')
|
||||
globalThis.document.body.append(container)
|
||||
root = createRoot(container)
|
||||
act(() => root!.render(<Fixture />))
|
||||
act(() => {
|
||||
modifier(true)
|
||||
vi.advanceTimersByTime(399)
|
||||
})
|
||||
expect(container.querySelector('[data-tab-key-hint]')).toBeNull()
|
||||
act(() => vi.advanceTimersByTime(1))
|
||||
expect(container.querySelector('[data-tab-key-hint]')?.parentElement?.textContent).toBe('right dot1')
|
||||
act(() => $hoveredTreeGroup.set(null))
|
||||
expect(container.querySelector('[data-tab-key-hint]')?.parentElement?.textContent).toBe('left dot1')
|
||||
|
||||
const originalBindings = $bindings.get()
|
||||
act(() => $bindings.set({ ...originalBindings, 'profile.switch.1': [] }))
|
||||
expect(container.querySelector('[data-tab-key-hint]')).toBeNull()
|
||||
act(() => $bindings.set(originalBindings))
|
||||
|
||||
act(() => modifier(false))
|
||||
expect($heldTabModifier.get()).toBe(false)
|
||||
act(() => {
|
||||
modifier(true)
|
||||
vi.advanceTimersByTime(400)
|
||||
window.dispatchEvent(new Event('blur'))
|
||||
})
|
||||
expect(container.querySelector('[data-tab-key-hint]')).toBeNull()
|
||||
act(() => {
|
||||
modifier(true)
|
||||
vi.advanceTimersByTime(400)
|
||||
$capture.set('profile.switch.1')
|
||||
})
|
||||
expect(container.querySelector('[data-tab-key-hint]')).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
import { $registryVersion } from '@/contrib/registry'
|
||||
import { $comboIndex } from '@/store/keybinds'
|
||||
|
||||
import { $activeTreeGroup, $hiddenTreePanes, $hoveredTreeGroup, $layoutTree, treeTabSlotTarget } from './store'
|
||||
import { $heldTabModifier } from './tab-key-hint-state'
|
||||
|
||||
/** Subscribe in the tiny lead, not the pane/transcript subtree. */
|
||||
export function TabKeyHint({ children, groupId, slot }: { children: ReactNode; groupId: string; slot: number }) {
|
||||
const held = useStore($heldTabModifier)
|
||||
|
||||
return (
|
||||
<span className="relative flex shrink-0 items-center [&:has([data-tab-key-hint])>span:first-child]:invisible">
|
||||
<span className="flex items-center">{children}</span>
|
||||
{held && <HeldTabKeyHint groupId={groupId} slot={slot} />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function HeldTabKeyHint({ groupId, slot }: { groupId: string; slot: number }) {
|
||||
useStore($activeTreeGroup)
|
||||
useStore($hoveredTreeGroup)
|
||||
useStore($layoutTree)
|
||||
useStore($hiddenTreePanes)
|
||||
useStore($registryVersion)
|
||||
const bindings = useStore($comboIndex)
|
||||
|
||||
if (slot > 9 || bindings.get(`mod+${slot}`) !== `profile.switch.${slot}` || treeTabSlotTarget()?.id !== groupId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center text-[0.625rem] font-semibold leading-none tabular-nums text-(--ui-text-secondary)"
|
||||
data-tab-key-hint={slot}
|
||||
>
|
||||
{slot}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -260,6 +260,8 @@ interface PaneTabStripProps extends React.ComponentProps<'div'> {
|
||||
listRef?: React.Ref<HTMLDivElement>
|
||||
/** Non-scrolling trailing chrome pinned to the right (the minimize chevron). */
|
||||
trailing?: React.ReactNode
|
||||
/** Top-edge panel header shares the native window-control band. */
|
||||
titlebar?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,7 +274,7 @@ interface PaneTabStripProps extends React.ComponentProps<'div'> {
|
||||
* `data-zone-tabstrip`, drop carets) ride on the usual div props.
|
||||
*/
|
||||
export const PaneTabStrip = React.forwardRef<HTMLDivElement, PaneTabStripProps>(function PaneTabStrip(
|
||||
{ children, className, listRef, trailing, ...props },
|
||||
{ children, className, listRef, trailing, titlebar = false, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
@@ -281,7 +283,8 @@ export const PaneTabStrip = React.forwardRef<HTMLDivElement, PaneTabStripProps>(
|
||||
// as one piece of chrome with the titlebar above it. No bottom rule — the
|
||||
// active tab's primary underline is the only seam.
|
||||
className={cn(
|
||||
'group/pane-header relative flex h-7 shrink-0 select-none bg-(--ui-sidebar-surface-background) [-webkit-app-region:no-drag] [--pane-tab-active-bg:var(--ui-sidebar-surface-background)]',
|
||||
'group/pane-header relative flex min-w-0 shrink-0 select-none bg-(--ui-sidebar-surface-background) [--pane-tab-active-bg:var(--ui-sidebar-surface-background)]',
|
||||
titlebar ? 'h-full flex-1 [-webkit-app-region:drag]' : 'h-7 [-webkit-app-region:no-drag]',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
@@ -327,7 +330,7 @@ export function PaneStripGlyph({ active, disabled, icon, label, onSelect }: Omit
|
||||
aria-label={label}
|
||||
aria-pressed={active ?? undefined}
|
||||
className={cn(
|
||||
'self-center bg-transparent select-none',
|
||||
'self-center bg-transparent select-none [-webkit-app-region:no-drag]',
|
||||
active ? 'opacity-100' : 'opacity-60 hover:opacity-100'
|
||||
)}
|
||||
disabled={disabled}
|
||||
|
||||
Reference in New Issue
Block a user