mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(tabs): redesign tab splits and terminal pane discoverability (#5927)
Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Orca <help@stably.ai> Co-authored-by: brennanb2025 <brennankbenson@gmail.com>
This commit is contained in:
co-authored by
Cursor
Orca
brennanb2025
parent
c0f1d386c7
commit
bfb778570a
@@ -217,11 +217,12 @@
|
||||
border: 2px solid rgba(59, 130, 246, 0.5);
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
left 80ms ease,
|
||||
top 80ms ease,
|
||||
width 80ms ease,
|
||||
height 80ms ease;
|
||||
}
|
||||
|
||||
.tab-drop-overlay__label {
|
||||
font-size: 11px;
|
||||
color: var(--primary-foreground);
|
||||
background: color-mix(in srgb, var(--chart-2) 20%, transparent);
|
||||
}
|
||||
|
||||
/* ── Pane title bar ──────────────────────────────────── */
|
||||
@@ -278,9 +279,9 @@
|
||||
.pane-title-drag-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 12px;
|
||||
left: 50%;
|
||||
width: 32px;
|
||||
height: var(--orca-pane-title-height);
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -288,6 +289,7 @@
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
background: color-mix(in srgb, var(--orca-pane-title-fg) 36%, transparent);
|
||||
transform: translateX(-50%);
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
@@ -347,6 +349,12 @@
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.pane-title-bar[data-chromeless] {
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.pane-title-close {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
@@ -358,6 +366,24 @@
|
||||
color 120ms ease;
|
||||
}
|
||||
|
||||
.pane-title-actions {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.pane-title-split-trigger {
|
||||
color: var(--orca-pane-title-button-fg, rgb(255 255 255 / 0.3));
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.pane-title-bar[data-active-pane] .pane-title-split-trigger,
|
||||
.pane-title-bar[data-active-pane] .pane-title-close,
|
||||
.pane-title-bar:hover .pane-title-split-trigger,
|
||||
.pane-title-bar:focus-within .pane-title-split-trigger {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pane-title-bar:hover .pane-title-close,
|
||||
.pane-title-bar:focus-within .pane-title-close,
|
||||
.pane-title-close:focus-visible {
|
||||
@@ -367,7 +393,11 @@
|
||||
.pane-title-close:hover,
|
||||
.pane-title-close:active,
|
||||
.pane-title-close[data-state='delayed-open'],
|
||||
.pane-title-close[data-state='instant-open'] {
|
||||
.pane-title-close[data-state='instant-open'],
|
||||
.pane-title-split-trigger:hover,
|
||||
.pane-title-split-trigger:active,
|
||||
.pane-title-split-trigger[data-state='delayed-open'],
|
||||
.pane-title-split-trigger[data-state='instant-open'] {
|
||||
background: transparent;
|
||||
color: var(--orca-pane-title-button-hover-fg, rgb(255 255 255 / 0.8));
|
||||
}
|
||||
|
||||
@@ -21,10 +21,7 @@ import {
|
||||
handleContextualTourOverlayKeyDown,
|
||||
type ActiveTourRenderState
|
||||
} from './ContextualTourOverlaySurface'
|
||||
import {
|
||||
REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT,
|
||||
type RequestActiveTerminalPaneSplitDetail
|
||||
} from '@/constants/terminal'
|
||||
import { requestActiveTerminalPaneSplit } from '@/components/tab-bar/request-active-terminal-pane-split'
|
||||
import { performContextualTourStepAction } from './contextual-tour-step-actions'
|
||||
import { openWorkspaceCreationComposerWithTourHandoff } from './workspace-creation-tour-handoff'
|
||||
|
||||
@@ -321,14 +318,7 @@ export function ContextualTourOverlay(): JSX.Element | null {
|
||||
openModal,
|
||||
canCreateWorkspace,
|
||||
openWorkspaceComposer: openWorkspaceCreationComposerWithTourHandoff,
|
||||
dispatchTerminalPaneSplit: (detail) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<RequestActiveTerminalPaneSplitDetail>(
|
||||
REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT,
|
||||
{ detail }
|
||||
)
|
||||
)
|
||||
},
|
||||
dispatchTerminalPaneSplit: requestActiveTerminalPaneSplit,
|
||||
schedule: (callback) => {
|
||||
window.setTimeout(callback, 0)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,13 @@ vi.mock('@dnd-kit/sortable', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./tab-strip-pointer-activation', () => ({
|
||||
useTabStripPointerActivation: () => ({
|
||||
isPressed: false,
|
||||
onPointerDown: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Columns2: function Columns2(props: Record<string, unknown>) {
|
||||
return { type: 'Columns2', props }
|
||||
@@ -76,6 +83,21 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenuSeparator: function DropdownMenuSeparator() {
|
||||
return { type: 'DropdownMenuSeparator', props: {} }
|
||||
},
|
||||
DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuShortcut', props }
|
||||
},
|
||||
DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuLabel', props }
|
||||
},
|
||||
DropdownMenuSub: function DropdownMenuSub(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSub', props }
|
||||
},
|
||||
DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSubContent', props }
|
||||
},
|
||||
DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSubTrigger', props }
|
||||
},
|
||||
DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuTrigger', props }
|
||||
}
|
||||
@@ -133,7 +155,6 @@ async function renderBrowserTab(tab: BrowserTabState): Promise<unknown> {
|
||||
onActivate: () => {},
|
||||
onClose: () => {},
|
||||
onCloseToRight: () => {},
|
||||
onSplitGroup: () => {},
|
||||
onDuplicate: () => {},
|
||||
onTogglePin: () => {},
|
||||
dragData: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { Globe, X, ExternalLink, Columns2, Rows2, Copy, Pin, PinOff } from 'lucide-react'
|
||||
import { Globe, X, ExternalLink, Copy, Pin, PinOff } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -20,11 +20,14 @@ import {
|
||||
getDropIndicatorClasses,
|
||||
getTabRootStateClasses,
|
||||
getTabStripBorderClasses,
|
||||
showsTabSelectionChrome,
|
||||
type DropIndicator
|
||||
} from './drop-indicator'
|
||||
import { preventMiddleButtonDefault } from './middle-button-default-guard'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules'
|
||||
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
|
||||
import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection'
|
||||
|
||||
function formatBrowserTabUrlLabel(url: string): string {
|
||||
if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') {
|
||||
@@ -107,7 +110,6 @@ export default function BrowserTab({
|
||||
onActivate,
|
||||
onClose,
|
||||
onCloseToRight,
|
||||
onSplitGroup,
|
||||
onDuplicate,
|
||||
onTogglePin,
|
||||
dragData,
|
||||
@@ -121,7 +123,6 @@ export default function BrowserTab({
|
||||
onActivate: () => void
|
||||
onClose: () => void
|
||||
onCloseToRight: () => void
|
||||
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
|
||||
onDuplicate: () => void
|
||||
onTogglePin: () => void
|
||||
dragData: TabDragItemData
|
||||
@@ -170,6 +171,11 @@ export default function BrowserTab({
|
||||
return () => window.removeEventListener('blur', dismiss)
|
||||
}, [menuOpen])
|
||||
|
||||
const { isPressed, onPointerDown: onTabPointerDown } = useTabStripPointerActivation({
|
||||
onActivate
|
||||
})
|
||||
const showsSelectionChrome = showsTabSelectionChrome(isActive, isPressed)
|
||||
|
||||
const tabRoot = (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
@@ -177,13 +183,12 @@ export default function BrowserTab({
|
||||
data-pinned={isPinned ? 'true' : 'false'}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`}
|
||||
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive, isPressed)}`}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
}
|
||||
onActivate()
|
||||
listeners?.onPointerDown?.(e)
|
||||
onTabPointerDown(
|
||||
e,
|
||||
listeners?.onPointerDown as ((event: React.PointerEvent<Element>) => void) | undefined
|
||||
)
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1) {
|
||||
@@ -202,7 +207,7 @@ export default function BrowserTab({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isActive && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
|
||||
{showsSelectionChrome && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
|
||||
{/* Why: the browser tab icon is the only non-terminal, non-editor
|
||||
surface in the tab strip. Coloring the Globe blue (matching the
|
||||
in-app browser's identity and the default tab insertion bar)
|
||||
@@ -219,7 +224,7 @@ export default function BrowserTab({
|
||||
{!isPinned && (
|
||||
<button
|
||||
className={`flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
|
||||
isActive
|
||||
showsSelectionChrome
|
||||
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
|
||||
}`}
|
||||
@@ -276,23 +281,6 @@ export default function BrowserTab({
|
||||
sideOffset={0}
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('up', tab.id)}>
|
||||
<Rows2 className="mr-1.5 size-3.5" />
|
||||
{translate('auto.components.tab.bar.BrowserTab.96354ed249', 'Split Up')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('down', tab.id)}>
|
||||
<Rows2 className="mr-1.5 size-3.5" />
|
||||
{translate('auto.components.tab.bar.BrowserTab.2186a8407c', 'Split Down')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('left', tab.id)}>
|
||||
<Columns2 className="mr-1.5 size-3.5" />
|
||||
{translate('auto.components.tab.bar.BrowserTab.7e8106899f', 'Split Left')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('right', tab.id)}>
|
||||
<Columns2 className="mr-1.5 size-3.5" />
|
||||
{translate('auto.components.tab.bar.BrowserTab.966feb9ad5', 'Split Right')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onDuplicate}>
|
||||
<Copy className="mr-1.5 size-3.5" />
|
||||
{translate('auto.components.tab.bar.BrowserTab.5d6e89891f', 'Duplicate Tab')}
|
||||
@@ -308,6 +296,10 @@ export default function BrowserTab({
|
||||
? translate('auto.components.tab.bar.BrowserTab.c5aaee8c39', 'Unpin Tab')
|
||||
: translate('auto.components.tab.bar.BrowserTab.911542656f', 'Pin Tab')}
|
||||
</DropdownMenuItem>
|
||||
<TabWorkspaceLayoutMenuSection
|
||||
unifiedTabId={dragData.unifiedTabId}
|
||||
groupId={dragData.groupId}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => !isPinned && onClose()} disabled={isPinned}>
|
||||
{translate('auto.components.tab.bar.BrowserTab.1611a1324b', 'Close')}
|
||||
|
||||
@@ -9,7 +9,13 @@ const reactHookRuntime = vi.hoisted(() => ({
|
||||
const appStoreMocks = vi.hoisted(() => ({
|
||||
openMarkdownPreview: vi.fn(),
|
||||
getState: vi.fn(() => ({
|
||||
settings: {}
|
||||
settings: {},
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [{ id: '/repo/untitled-5.md', groupId: 'group-1' }]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
'wt-1': [{ id: 'group-1', tabOrder: ['/repo/untitled-5.md', 'tab-2'] }]
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -49,6 +55,13 @@ vi.mock('@dnd-kit/sortable', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./tab-strip-pointer-activation', () => ({
|
||||
useTabStripPointerActivation: () => ({
|
||||
isPressed: false,
|
||||
onPointerDown: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Columns2: function Columns2(props: Record<string, unknown>) {
|
||||
return { type: 'Columns2', props }
|
||||
@@ -101,6 +114,18 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuShortcut', props }
|
||||
},
|
||||
DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuLabel', props }
|
||||
},
|
||||
DropdownMenuSub: function DropdownMenuSub(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSub', props }
|
||||
},
|
||||
DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSubContent', props }
|
||||
},
|
||||
DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSubTrigger', props }
|
||||
},
|
||||
DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuTrigger', props }
|
||||
}
|
||||
@@ -164,7 +189,8 @@ vi.mock('./drop-indicator', () => ({
|
||||
ACTIVE_TAB_INDICATOR_CLASSES: 'active-tab-indicator',
|
||||
getDropIndicatorClasses: () => '',
|
||||
getTabStripBorderClasses: () => '',
|
||||
getTabRootStateClasses: () => ''
|
||||
getTabRootStateClasses: () => '',
|
||||
showsTabSelectionChrome: () => true
|
||||
}))
|
||||
|
||||
vi.mock('@/components/editor/markdown-preview-controls', () => ({
|
||||
@@ -217,7 +243,6 @@ async function renderEditorFileTab(
|
||||
onCloseAll: () => {},
|
||||
onMakePermanent,
|
||||
onTogglePin: () => {},
|
||||
onSplitGroup: () => {},
|
||||
dragData: {
|
||||
kind: 'tab',
|
||||
worktreeId: file.worktreeId,
|
||||
|
||||
@@ -22,12 +22,14 @@ import {
|
||||
getDropIndicatorClasses,
|
||||
getTabRootStateClasses,
|
||||
getTabStripBorderClasses,
|
||||
showsTabSelectionChrome,
|
||||
type DropIndicator
|
||||
} from './drop-indicator'
|
||||
import { canOpenMarkdownPreview } from '@/components/editor/markdown-preview-controls'
|
||||
import { EditorFileTabContextMenu } from './EditorFileTabContextMenu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules'
|
||||
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
|
||||
|
||||
export default function EditorFileTab({
|
||||
file,
|
||||
@@ -41,7 +43,6 @@ export default function EditorFileTab({
|
||||
onCloseAll,
|
||||
onMakePermanent,
|
||||
onTogglePin,
|
||||
onSplitGroup,
|
||||
dragData,
|
||||
dropIndicator,
|
||||
includeTopTabBorder = true
|
||||
@@ -57,7 +58,6 @@ export default function EditorFileTab({
|
||||
onCloseAll: () => void
|
||||
onMakePermanent?: () => void
|
||||
onTogglePin: () => void
|
||||
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
|
||||
dragData: TabDragItemData
|
||||
dropIndicator?: DropIndicator
|
||||
includeTopTabBorder?: boolean
|
||||
@@ -200,20 +200,26 @@ export default function EditorFileTab({
|
||||
return () => window.removeEventListener('blur', dismiss)
|
||||
}, [menuOpen])
|
||||
|
||||
const { isPressed, onPointerDown: onTabPointerDown } = useTabStripPointerActivation({
|
||||
onActivate,
|
||||
disabled: isRenaming
|
||||
})
|
||||
const showsSelectionChrome = showsTabSelectionChrome(isActive, isPressed)
|
||||
const dragListeners = isRenaming ? undefined : listeners
|
||||
|
||||
const tabRoot = (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
data-tab-id={file.tabId ?? file.id}
|
||||
data-pinned={isPinned ? 'true' : 'false'}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`}
|
||||
{...dragListeners}
|
||||
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive, isPressed)}`}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
}
|
||||
onActivate()
|
||||
listeners?.onPointerDown?.(e)
|
||||
onTabPointerDown(
|
||||
e,
|
||||
dragListeners?.onPointerDown as ((event: React.PointerEvent<Element>) => void) | undefined
|
||||
)
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (file.isPreview && onMakePermanent) {
|
||||
@@ -237,26 +243,26 @@ export default function EditorFileTab({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isActive && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
|
||||
{showsSelectionChrome && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
|
||||
{isConflictReview ? (
|
||||
<ShieldAlert
|
||||
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-orange-400' : 'text-orange-400/70'}`}
|
||||
className={`w-3 h-3 mr-1 shrink-0 ${showsSelectionChrome ? 'text-orange-400' : 'text-orange-400/70'}`}
|
||||
/>
|
||||
) : isCheckDetails ? (
|
||||
<ListChecks
|
||||
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
className={`w-3 h-3 mr-1 shrink-0 ${showsSelectionChrome ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
) : isDiff ? (
|
||||
<GitCompareArrows
|
||||
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
className={`w-3 h-3 mr-1 shrink-0 ${showsSelectionChrome ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
) : isMarkdownPreviewTab ? (
|
||||
<Eye
|
||||
className={`w-3.5 h-3.5 mr-1.5 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
className={`w-3.5 h-3.5 mr-1.5 shrink-0 ${showsSelectionChrome ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
) : (
|
||||
<FileIcon
|
||||
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
className={`w-3 h-3 mr-1 shrink-0 ${showsSelectionChrome ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
)}
|
||||
{isPinned && <Pin className="mr-1 size-3 shrink-0 text-muted-foreground" aria-hidden />}
|
||||
@@ -342,7 +348,7 @@ export default function EditorFileTab({
|
||||
className={`flex items-center justify-center w-4 h-4 rounded-sm ${
|
||||
file.isDirty
|
||||
? 'hidden group-hover:flex text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
: isActive
|
||||
: showsSelectionChrome
|
||||
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
|
||||
}`}
|
||||
@@ -390,6 +396,8 @@ export default function EditorFileTab({
|
||||
open={menuOpen}
|
||||
menuPoint={menuPoint}
|
||||
file={file}
|
||||
unifiedTabId={dragData.unifiedTabId}
|
||||
groupId={dragData.groupId}
|
||||
isPinned={isPinned}
|
||||
isRenaming={isRenaming}
|
||||
hasTabsToRight={hasTabsToRight}
|
||||
@@ -405,7 +413,6 @@ export default function EditorFileTab({
|
||||
onClose={onClose}
|
||||
onCloseAll={onCloseAll}
|
||||
onCloseToRight={onCloseToRight}
|
||||
onSplitGroup={onSplitGroup}
|
||||
onOpenMarkdownPreview={openMarkdownPreview}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -18,6 +18,18 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuShortcut', props }
|
||||
},
|
||||
DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuLabel', props }
|
||||
},
|
||||
DropdownMenuSub: function DropdownMenuSub(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSub', props }
|
||||
},
|
||||
DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSubContent', props }
|
||||
},
|
||||
DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSubTrigger', props }
|
||||
},
|
||||
DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuTrigger', props }
|
||||
}
|
||||
@@ -58,9 +70,33 @@ vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
}))
|
||||
|
||||
const useAppStoreMock = Object.assign(
|
||||
(selector: (state: { settings: Record<string, unknown> }) => unknown) =>
|
||||
selector({ settings: {} }),
|
||||
{ getState: () => ({ settings: {} }) }
|
||||
(
|
||||
selector: (state: {
|
||||
settings: Record<string, unknown>
|
||||
unifiedTabsByWorktree: Record<string, unknown[]>
|
||||
groupsByWorktree: Record<string, unknown[]>
|
||||
}) => unknown
|
||||
) =>
|
||||
selector({
|
||||
settings: {},
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', groupId: 'group-1' }]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
'wt-1': [{ id: 'group-1', tabOrder: ['tab-1', 'tab-2'] }]
|
||||
}
|
||||
}),
|
||||
{
|
||||
getState: () => ({
|
||||
settings: {},
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', groupId: 'group-1' }]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
'wt-1': [{ id: 'group-1', tabOrder: ['tab-1', 'tab-2'] }]
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
@@ -151,6 +187,8 @@ async function renderMenu(): Promise<unknown> {
|
||||
isDirty: false,
|
||||
mode: 'edit'
|
||||
},
|
||||
unifiedTabId: 'tab-1',
|
||||
groupId: 'group-1',
|
||||
isPinned: false,
|
||||
isRenaming: false,
|
||||
hasTabsToRight: false,
|
||||
@@ -166,7 +204,6 @@ async function renderMenu(): Promise<unknown> {
|
||||
onClose: vi.fn(),
|
||||
onCloseAll: vi.fn(),
|
||||
onCloseToRight: vi.fn(),
|
||||
onSplitGroup: vi.fn(),
|
||||
onOpenMarkdownPreview: vi.fn()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Copy, ExternalLink, Columns2, Rows2, Pencil, Pin, PinOff } from 'lucide-react'
|
||||
import { Copy, ExternalLink, Pencil, Pin, PinOff } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -13,6 +13,7 @@ import { useShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import type { OpenFile } from '../../store/slices/editor'
|
||||
import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const isLinux = navigator.userAgent.includes('Linux')
|
||||
@@ -28,6 +29,8 @@ type EditorFileTabContextMenuProps = {
|
||||
open: boolean
|
||||
menuPoint: { x: number; y: number }
|
||||
file: OpenFile & { tabId?: string }
|
||||
unifiedTabId: string
|
||||
groupId: string
|
||||
isPinned: boolean
|
||||
isRenaming: boolean
|
||||
hasTabsToRight: boolean
|
||||
@@ -43,7 +46,6 @@ type EditorFileTabContextMenuProps = {
|
||||
onClose: () => void
|
||||
onCloseAll: () => void
|
||||
onCloseToRight: () => void
|
||||
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
|
||||
onOpenMarkdownPreview: (
|
||||
file: {
|
||||
filePath: string
|
||||
@@ -60,6 +62,8 @@ export function EditorFileTabContextMenu({
|
||||
open,
|
||||
menuPoint,
|
||||
file,
|
||||
unifiedTabId,
|
||||
groupId,
|
||||
isPinned,
|
||||
isRenaming,
|
||||
hasTabsToRight,
|
||||
@@ -75,10 +79,8 @@ export function EditorFileTabContextMenu({
|
||||
onClose,
|
||||
onCloseAll,
|
||||
onCloseToRight,
|
||||
onSplitGroup,
|
||||
onOpenMarkdownPreview
|
||||
}: EditorFileTabContextMenuProps): React.JSX.Element {
|
||||
const sourceVisibleTabId = file.tabId ?? file.id
|
||||
const closeAllShortcut = useShortcutLabel('tab.closeAll')
|
||||
const showCloseAllShortcut = closeAllShortcut !== 'Unassigned'
|
||||
|
||||
@@ -104,23 +106,6 @@ export function EditorFileTabContextMenu({
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('up', sourceVisibleTabId)}>
|
||||
<Rows2 className="mr-1.5 size-3.5" />
|
||||
{translate('auto.components.tab.bar.EditorFileTabContextMenu.6b3efb106e', 'Split Up')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('down', sourceVisibleTabId)}>
|
||||
<Rows2 className="mr-1.5 size-3.5" />
|
||||
{translate('auto.components.tab.bar.EditorFileTabContextMenu.1d04b1630b', 'Split Down')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('left', sourceVisibleTabId)}>
|
||||
<Columns2 className="mr-1.5 size-3.5" />
|
||||
{translate('auto.components.tab.bar.EditorFileTabContextMenu.e3ff145b98', 'Split Left')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('right', sourceVisibleTabId)}>
|
||||
<Columns2 className="mr-1.5 size-3.5" />
|
||||
{translate('auto.components.tab.bar.EditorFileTabContextMenu.f7c3d7d5af', 'Split Right')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={!canRename || isRenaming}
|
||||
onSelect={() => {
|
||||
@@ -139,6 +124,7 @@ export function EditorFileTabContextMenu({
|
||||
? translate('auto.components.tab.bar.EditorFileTabContextMenu.8e9d603a09', 'Unpin Tab')
|
||||
: translate('auto.components.tab.bar.EditorFileTabContextMenu.fdd29eb669', 'Pin Tab')}
|
||||
</DropdownMenuItem>
|
||||
<TabWorkspaceLayoutMenuSection unifiedTabId={unifiedTabId} groupId={groupId} />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => !isPinned && onClose()} disabled={isPinned}>
|
||||
{translate('auto.components.tab.bar.EditorFileTabContextMenu.1ba8492c5b', 'Close')}
|
||||
|
||||
@@ -5,19 +5,33 @@ const reactHookRuntime = vi.hoisted(() => ({
|
||||
index: 0
|
||||
}))
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
agentStatusByPaneKey: {},
|
||||
clearTabLaunchAgent: vi.fn(),
|
||||
ptyIdsByTabId: {} as Record<string, string[]>,
|
||||
renamingTabId: null as string | null,
|
||||
repos: [],
|
||||
setRenamingTabId: vi.fn((tabId: string | null) => {
|
||||
storeState.renamingTabId = tabId
|
||||
}),
|
||||
terminalLayoutsByTabId: {},
|
||||
worktreesByRepo: {},
|
||||
unreadTerminalTabs: {} as Record<string, boolean>
|
||||
}))
|
||||
const storeState = vi.hoisted(
|
||||
(): {
|
||||
agentStatusByPaneKey: Record<string, unknown>
|
||||
clearTabLaunchAgent: ReturnType<typeof vi.fn>
|
||||
ptyIdsByTabId: Record<string, string[]>
|
||||
renamingTabId: string | null
|
||||
keybindings: Record<string, unknown>
|
||||
repos: unknown[]
|
||||
setRenamingTabId: ReturnType<typeof vi.fn>
|
||||
terminalLayoutsByTabId: Record<string, unknown>
|
||||
worktreesByRepo: Record<string, unknown>
|
||||
unreadTerminalTabs: Record<string, boolean>
|
||||
} => ({
|
||||
agentStatusByPaneKey: {},
|
||||
clearTabLaunchAgent: vi.fn(),
|
||||
ptyIdsByTabId: {} as Record<string, string[]>,
|
||||
renamingTabId: null as string | null,
|
||||
keybindings: {},
|
||||
repos: [],
|
||||
setRenamingTabId: vi.fn((tabId: string | null) => {
|
||||
storeState.renamingTabId = tabId
|
||||
}),
|
||||
terminalLayoutsByTabId: {},
|
||||
worktreesByRepo: {},
|
||||
unreadTerminalTabs: {} as Record<string, boolean>
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
|
||||
@@ -57,6 +71,13 @@ vi.mock('@dnd-kit/sortable', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./tab-strip-pointer-activation', () => ({
|
||||
useTabStripPointerActivation: () => ({
|
||||
isPressed: false,
|
||||
onPointerDown: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Columns2: function Columns2(props: Record<string, unknown>) {
|
||||
return { type: 'Columns2', props }
|
||||
@@ -64,6 +85,12 @@ vi.mock('lucide-react', () => ({
|
||||
Minimize2: function Minimize2(props: Record<string, unknown>) {
|
||||
return { type: 'Minimize2', props }
|
||||
},
|
||||
PanelBottomClose: function PanelBottomClose(props: Record<string, unknown>) {
|
||||
return { type: 'PanelBottomClose', props }
|
||||
},
|
||||
PanelRightClose: function PanelRightClose(props: Record<string, unknown>) {
|
||||
return { type: 'PanelRightClose', props }
|
||||
},
|
||||
Pin: function Pin(props: Record<string, unknown>) {
|
||||
return { type: 'Pin', props }
|
||||
},
|
||||
@@ -78,6 +105,10 @@ vi.mock('lucide-react', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
formatShortcutLabel: () => '⌘⇧\\'
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: function DropdownMenu(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenu', props }
|
||||
@@ -91,6 +122,21 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenuSeparator: function DropdownMenuSeparator() {
|
||||
return { type: 'DropdownMenuSeparator', props: {} }
|
||||
},
|
||||
DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuShortcut', props }
|
||||
},
|
||||
DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuLabel', props }
|
||||
},
|
||||
DropdownMenuSub: function DropdownMenuSub(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSub', props }
|
||||
},
|
||||
DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSubContent', props }
|
||||
},
|
||||
DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSubTrigger', props }
|
||||
},
|
||||
DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) {
|
||||
return props.children
|
||||
}
|
||||
@@ -130,15 +176,26 @@ vi.mock('./drop-indicator', () => ({
|
||||
ACTIVE_TAB_INDICATOR_CLASSES: 'active-tab-indicator',
|
||||
getDropIndicatorClasses: () => '',
|
||||
getTabStripBorderClasses: () => '',
|
||||
getTabRootStateClasses: () => ''
|
||||
getTabRootStateClasses: () => '',
|
||||
showsTabSelectionChrome: () => true
|
||||
}))
|
||||
|
||||
vi.mock('./middle-button-default-guard', () => ({
|
||||
preventMiddleButtonDefault: vi.fn()
|
||||
}))
|
||||
|
||||
const useAppStoreExport = (selector: (state: typeof storeState) => unknown) => selector(storeState)
|
||||
useAppStoreExport.getState = () => ({
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [{ id: 'terminal-tab-1', groupId: 'group-1' }]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
'wt-1': [{ id: 'group-1', tabOrder: ['terminal-tab-1', 'tab-2'] }]
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState)
|
||||
useAppStore: useAppStoreExport
|
||||
}))
|
||||
|
||||
type ReactElementLike = {
|
||||
@@ -162,6 +219,8 @@ async function renderSortableTab(): Promise<unknown> {
|
||||
const module = await import('./SortableTab')
|
||||
return module.default({
|
||||
tab: makeTerminalTab() as never,
|
||||
unifiedTabId: 'terminal-tab-1',
|
||||
groupId: 'group-1',
|
||||
tabCount: 1,
|
||||
hasTabsToRight: false,
|
||||
isActive: true,
|
||||
@@ -175,7 +234,6 @@ async function renderSortableTab(): Promise<unknown> {
|
||||
onSetTabColor: vi.fn(),
|
||||
onTogglePin: vi.fn(),
|
||||
onToggleExpand: vi.fn(),
|
||||
onSplitGroup: vi.fn(),
|
||||
dragData: {
|
||||
kind: 'tab',
|
||||
worktreeId: 'wt-1',
|
||||
|
||||
@@ -16,15 +16,19 @@ import {
|
||||
getDropIndicatorClasses,
|
||||
getTabRootStateClasses,
|
||||
getTabStripBorderClasses,
|
||||
showsTabSelectionChrome,
|
||||
type DropIndicator
|
||||
} from './drop-indicator'
|
||||
import { preventMiddleButtonDefault } from './middle-button-default-guard'
|
||||
import { SortableTabContextMenu } from './SortableTabContextMenu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules'
|
||||
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
|
||||
|
||||
type SortableTabProps = {
|
||||
tab: TerminalTab
|
||||
unifiedTabId: string
|
||||
groupId: string
|
||||
tabCount: number
|
||||
hasTabsToRight: boolean
|
||||
isActive: boolean
|
||||
@@ -38,7 +42,6 @@ type SortableTabProps = {
|
||||
onSetTabColor: (tabId: string, color: string | null) => void
|
||||
onTogglePin: () => void
|
||||
onToggleExpand: (tabId: string) => void
|
||||
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
|
||||
dragData: TabDragItemData
|
||||
dropIndicator?: DropIndicator
|
||||
includeTopTabBorder?: boolean
|
||||
@@ -48,6 +51,8 @@ export const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
|
||||
|
||||
export default function SortableTab({
|
||||
tab,
|
||||
unifiedTabId,
|
||||
groupId,
|
||||
tabCount,
|
||||
hasTabsToRight,
|
||||
isActive,
|
||||
@@ -61,7 +66,6 @@ export default function SortableTab({
|
||||
onSetTabColor,
|
||||
onTogglePin,
|
||||
onToggleExpand,
|
||||
onSplitGroup,
|
||||
dragData,
|
||||
dropIndicator,
|
||||
includeTopTabBorder = true
|
||||
@@ -195,6 +199,14 @@ export default function SortableTab({
|
||||
// so dnd-kit's a11y attributes (aria-roledescription, etc.) remain on the element — only
|
||||
// the pointer listeners are gated so a drag can't start while typing.
|
||||
const dragListeners = isEditing ? undefined : listeners
|
||||
const handleActivate = useCallback(() => {
|
||||
onActivate(tab.id)
|
||||
}, [onActivate, tab.id])
|
||||
const { isPressed, onPointerDown: onTabPointerDown } = useTabStripPointerActivation({
|
||||
onActivate: handleActivate,
|
||||
disabled: isEditing
|
||||
})
|
||||
const showsSelectionChrome = showsTabSelectionChrome(isActive, isPressed)
|
||||
const tabTitle = tab.customTitle ?? tab.title
|
||||
const tabRoot = (
|
||||
<div
|
||||
@@ -209,6 +221,7 @@ export default function SortableTab({
|
||||
// pass even if the tab-bar render path had silently broken (the same
|
||||
// tautology that let PR #1186's render crash ship past E2E in #1193).
|
||||
data-active={isActive ? 'true' : 'false'}
|
||||
data-pressed={isPressed ? 'true' : 'false'}
|
||||
{...attributes}
|
||||
{...dragListeners}
|
||||
// Why: on unread activity, tint the whole tab with a subtle amber
|
||||
@@ -218,7 +231,7 @@ export default function SortableTab({
|
||||
// tab still reads as "selected + has activity". The wash is
|
||||
// rendered as an absolutely-positioned child below so the ::after
|
||||
// pseudo-element stays free for the drop indicator.
|
||||
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`}
|
||||
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive, isPressed)}`}
|
||||
onDoubleClick={(e) => {
|
||||
if (isEditing) {
|
||||
return
|
||||
@@ -227,11 +240,10 @@ export default function SortableTab({
|
||||
handleRenameOpen()
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (isEditing || e.button !== 0) {
|
||||
return
|
||||
}
|
||||
onActivate(tab.id)
|
||||
dragListeners?.onPointerDown?.(e)
|
||||
onTabPointerDown(
|
||||
e,
|
||||
dragListeners?.onPointerDown as ((event: React.PointerEvent<Element>) => void) | undefined
|
||||
)
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
// Why: prevent default browser middle-click behavior (auto-scroll)
|
||||
@@ -257,7 +269,7 @@ export default function SortableTab({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isActive && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
|
||||
{showsSelectionChrome && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
|
||||
{showActivityAffordance && (
|
||||
// Why: amber wash for unread tabs. Rendered as a real DOM child so
|
||||
// both drop indicators (::before left / ::after right in
|
||||
@@ -279,7 +291,7 @@ export default function SortableTab({
|
||||
// Why: coding-agent tabs should read as Claude/Codex/etc. while the
|
||||
// harness is running; plain shells keep the generic terminal tile.
|
||||
<span
|
||||
className={`mr-1 inline-flex shrink-0 ${isActive ? '' : 'opacity-70'}`}
|
||||
className={`mr-1 inline-flex shrink-0 ${showsSelectionChrome ? '' : 'opacity-70'}`}
|
||||
data-agent-icon={tabAgent}
|
||||
aria-hidden
|
||||
>
|
||||
@@ -295,7 +307,7 @@ export default function SortableTab({
|
||||
// on inactive tabs to match the existing text treatment without
|
||||
// desaturating the brand colors beyond recognition.
|
||||
<span
|
||||
className={`mr-1 inline-flex shrink-0 ${isActive ? '' : 'opacity-70'}`}
|
||||
className={`mr-1 inline-flex shrink-0 ${showsSelectionChrome ? '' : 'opacity-70'}`}
|
||||
data-shell-icon={shellForIcon ?? 'generic'}
|
||||
aria-hidden
|
||||
>
|
||||
@@ -375,7 +387,7 @@ export default function SortableTab({
|
||||
{isExpanded && !isEditing && (
|
||||
<button
|
||||
className={`mr-1 flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
|
||||
isActive
|
||||
showsSelectionChrome
|
||||
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
|
||||
}`}
|
||||
@@ -393,7 +405,7 @@ export default function SortableTab({
|
||||
{!isEditing && !isPinned && (
|
||||
<button
|
||||
className={`relative z-10 flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
|
||||
isActive
|
||||
showsSelectionChrome
|
||||
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
|
||||
}`}
|
||||
@@ -447,19 +459,22 @@ export default function SortableTab({
|
||||
|
||||
<SortableTabContextMenu
|
||||
tab={tab}
|
||||
unifiedTabId={unifiedTabId}
|
||||
groupId={groupId}
|
||||
isActive={isActive}
|
||||
open={menuOpen}
|
||||
point={menuPoint}
|
||||
tabCount={tabCount}
|
||||
hasTabsToRight={hasTabsToRight}
|
||||
isPinned={isPinned}
|
||||
onOpenChange={setMenuOpen}
|
||||
onActivate={onActivate}
|
||||
onClose={onClose}
|
||||
onCloseOthers={onCloseOthers}
|
||||
onCloseToRight={onCloseToRight}
|
||||
onRenameOpen={handleRenameOpen}
|
||||
onSetTabColor={onSetTabColor}
|
||||
onTogglePin={onTogglePin}
|
||||
onSplitGroup={onSplitGroup}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { act, type ComponentProps, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT } from '@/constants/terminal'
|
||||
import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split'
|
||||
import { SortableTabContextMenu } from './SortableTabContextMenu'
|
||||
|
||||
const storeMock = vi.hoisted(() => ({
|
||||
dropUnifiedTab: vi.fn(),
|
||||
state: {
|
||||
keybindings: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {}
|
||||
} as Record<string, unknown>
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
formatShortcutLabel: () => '⌘D'
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({ children }: { children?: ReactNode }) => children,
|
||||
DropdownMenuContent: ({ children }: { children?: ReactNode }) => children,
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
disabled,
|
||||
onSelect
|
||||
}: {
|
||||
children?: ReactNode
|
||||
disabled?: boolean
|
||||
onSelect?: () => void
|
||||
}) => (
|
||||
<button type="button" disabled={disabled} onClick={() => onSelect?.()}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuLabel: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuSeparator: () => null,
|
||||
DropdownMenuSub: ({ children }: { children?: ReactNode }) => children,
|
||||
DropdownMenuSubContent: ({ children }: { children?: ReactNode }) => children,
|
||||
DropdownMenuSubTrigger: ({ children }: { children?: ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
DropdownMenuShortcut: ({ children }: { children?: ReactNode }) => children,
|
||||
DropdownMenuTrigger: ({ children }: { children?: ReactNode }) => children
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
PanelBottomClose: () => null,
|
||||
PanelRightClose: () => null,
|
||||
Pin: () => null,
|
||||
PinOff: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
(selector: (state: Record<string, unknown>) => unknown) => selector(storeMock.state),
|
||||
{
|
||||
getState: () => storeMock.state
|
||||
}
|
||||
)
|
||||
}))
|
||||
|
||||
const mounted: { container: HTMLDivElement; root: Root }[] = []
|
||||
|
||||
function renderMenu(overrides: Partial<ComponentProps<typeof SortableTabContextMenu>> = {}): {
|
||||
container: HTMLDivElement
|
||||
root: Root
|
||||
onActivate: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
const onActivate = vi.fn()
|
||||
act(() => {
|
||||
root.render(
|
||||
<SortableTabContextMenu
|
||||
tab={{
|
||||
id: 'term-1',
|
||||
ptyId: null,
|
||||
worktreeId: 'wt-1',
|
||||
title: 'bash',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}}
|
||||
unifiedTabId="tab-1"
|
||||
groupId="group-1"
|
||||
isActive
|
||||
open
|
||||
point={{ x: 0, y: 0 }}
|
||||
tabCount={2}
|
||||
hasTabsToRight
|
||||
isPinned={false}
|
||||
onOpenChange={vi.fn()}
|
||||
onActivate={onActivate}
|
||||
onClose={vi.fn()}
|
||||
onCloseOthers={vi.fn()}
|
||||
onCloseToRight={vi.fn()}
|
||||
onRenameOpen={vi.fn()}
|
||||
onSetTabColor={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
{...overrides}
|
||||
/>
|
||||
)
|
||||
})
|
||||
mounted.push({ container, root })
|
||||
return { container, root, onActivate }
|
||||
}
|
||||
|
||||
function getButton(container: HTMLElement, label: string): HTMLButtonElement {
|
||||
const button = Array.from(container.querySelectorAll('button')).find((candidate) =>
|
||||
candidate.textContent?.includes(label)
|
||||
)
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
throw new Error(`Missing button: ${label}`)
|
||||
}
|
||||
return button
|
||||
}
|
||||
|
||||
function getLastSplitEvent(spy: ReturnType<typeof vi.spyOn>): CustomEvent {
|
||||
const event = spy.mock.calls.at(-1)?.[0]
|
||||
if (!(event instanceof CustomEvent)) {
|
||||
throw new Error('Expected a split request event')
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
storeMock.dropUnifiedTab.mockReset()
|
||||
storeMock.state = {
|
||||
keybindings: {},
|
||||
dropUnifiedTab: storeMock.dropUnifiedTab,
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: 'tab-1',
|
||||
tabOrder: ['tab-1', 'tab-2']
|
||||
}
|
||||
]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'tab-1',
|
||||
groupId: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'terminal',
|
||||
entityId: 'term-1',
|
||||
label: 'bash',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { container, root } of mounted.splice(0)) {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('requestActiveTerminalPaneSplit', () => {
|
||||
it('dispatches the active terminal pane split event', () => {
|
||||
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
||||
|
||||
requestActiveTerminalPaneSplit({ tabId: 'term-1', direction: 'vertical' })
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1)
|
||||
const event = dispatchSpy.mock.calls[0]?.[0] as CustomEvent
|
||||
expect(event.type).toBe(REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT)
|
||||
expect(event.detail).toEqual({
|
||||
tabId: 'term-1',
|
||||
direction: 'vertical'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('SortableTabContextMenu', () => {
|
||||
it('dispatches split requests and activates inactive terminal tabs first', () => {
|
||||
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
||||
const { container, onActivate } = renderMenu({ isActive: false })
|
||||
|
||||
act(() => getButton(container, 'Split terminal right').click())
|
||||
expect(onActivate).toHaveBeenCalledWith('term-1')
|
||||
expect(getLastSplitEvent(dispatchSpy).detail).toEqual({
|
||||
tabId: 'term-1',
|
||||
direction: 'vertical'
|
||||
})
|
||||
|
||||
dispatchSpy.mockClear()
|
||||
act(() => getButton(container, 'Split terminal down').click())
|
||||
expect(getLastSplitEvent(dispatchSpy).detail).toEqual({
|
||||
tabId: 'term-1',
|
||||
direction: 'horizontal'
|
||||
})
|
||||
})
|
||||
|
||||
it('renders split actions and routes directions to the move path', () => {
|
||||
storeMock.dropUnifiedTab.mockReturnValue(true)
|
||||
const { container } = renderMenu()
|
||||
|
||||
expect(container.textContent).toContain('Move Tab to Split')
|
||||
|
||||
act(() => getButton(container, 'Right').click())
|
||||
expect(storeMock.dropUnifiedTab).toHaveBeenCalledWith('tab-1', {
|
||||
groupId: 'group-1',
|
||||
splitDirection: 'right'
|
||||
})
|
||||
})
|
||||
|
||||
it('hides split actions for a single-tab group', () => {
|
||||
storeMock.state = {
|
||||
...storeMock.state,
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: 'tab-1',
|
||||
tabOrder: ['tab-1']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
const { container } = renderMenu()
|
||||
|
||||
expect(container.textContent).not.toContain('Move Tab to Split')
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Columns2, Pin, PinOff, Rows2 } from 'lucide-react'
|
||||
import { PanelBottomClose, PanelRightClose, Pin, PinOff } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import { useAppStore } from '../../store'
|
||||
import { formatShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection'
|
||||
import { requestActiveTerminalPaneSplit } from './request-active-terminal-pane-split'
|
||||
|
||||
const TAB_COLORS = [
|
||||
{
|
||||
@@ -74,37 +79,54 @@ const TAB_COLORS = [
|
||||
|
||||
type SortableTabContextMenuProps = {
|
||||
tab: TerminalTab
|
||||
unifiedTabId: string
|
||||
groupId: string
|
||||
isActive: boolean
|
||||
open: boolean
|
||||
point: { x: number; y: number }
|
||||
tabCount: number
|
||||
hasTabsToRight: boolean
|
||||
isPinned: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onActivate: (tabId: string) => void
|
||||
onClose: (tabId: string) => void
|
||||
onCloseOthers: (tabId: string) => void
|
||||
onCloseToRight: (tabId: string) => void
|
||||
onRenameOpen: () => void
|
||||
onSetTabColor: (tabId: string, color: string | null) => void
|
||||
onTogglePin: () => void
|
||||
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
|
||||
}
|
||||
|
||||
export function SortableTabContextMenu({
|
||||
tab,
|
||||
unifiedTabId,
|
||||
groupId,
|
||||
isActive,
|
||||
open,
|
||||
point,
|
||||
tabCount,
|
||||
hasTabsToRight,
|
||||
isPinned,
|
||||
onOpenChange,
|
||||
onActivate,
|
||||
onClose,
|
||||
onCloseOthers,
|
||||
onCloseToRight,
|
||||
onRenameOpen,
|
||||
onSetTabColor,
|
||||
onTogglePin,
|
||||
onSplitGroup
|
||||
onTogglePin
|
||||
}: SortableTabContextMenuProps): React.JSX.Element {
|
||||
const keybindings = useAppStore((state) => state.keybindings)
|
||||
const splitRightShortcut = formatShortcutLabel('terminal.splitRight', keybindings)
|
||||
const splitDownShortcut = formatShortcutLabel('terminal.splitDown', keybindings)
|
||||
|
||||
const splitActiveTerminalPane = (direction: 'vertical' | 'horizontal'): void => {
|
||||
if (!isActive) {
|
||||
onActivate(tab.id)
|
||||
}
|
||||
requestActiveTerminalPaneSplit({ tabId: tab.id, direction })
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={onOpenChange} modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -115,23 +137,24 @@ export function SortableTabContextMenu({
|
||||
style={{ left: point.x, top: point.y }}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48" sideOffset={0} align="start">
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('up', tab.id)}>
|
||||
<Rows2 className="mr-1.5 size-3.5" />
|
||||
{translate('auto.components.tab.bar.SortableTabContextMenu.591f9b12c1', 'Split Up')}
|
||||
<DropdownMenuContent className="w-56" sideOffset={0} align="start">
|
||||
<DropdownMenuItem onSelect={() => splitActiveTerminalPane('vertical')}>
|
||||
<PanelRightClose />
|
||||
{translate(
|
||||
'auto.components.tab.bar.SortableTabContextMenu.splitTerminalRight',
|
||||
'Split terminal right'
|
||||
)}
|
||||
<DropdownMenuShortcut>{splitRightShortcut}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('down', tab.id)}>
|
||||
<Rows2 className="mr-1.5 size-3.5" />
|
||||
{translate('auto.components.tab.bar.SortableTabContextMenu.af80ed83c1', 'Split Down')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('left', tab.id)}>
|
||||
<Columns2 className="mr-1.5 size-3.5" />
|
||||
{translate('auto.components.tab.bar.SortableTabContextMenu.0ce4bae39d', 'Split Left')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('right', tab.id)}>
|
||||
<Columns2 className="mr-1.5 size-3.5" />
|
||||
{translate('auto.components.tab.bar.SortableTabContextMenu.21132389e9', 'Split Right')}
|
||||
<DropdownMenuItem onSelect={() => splitActiveTerminalPane('horizontal')}>
|
||||
<PanelBottomClose />
|
||||
{translate(
|
||||
'auto.components.tab.bar.SortableTabContextMenu.splitTerminalDown',
|
||||
'Split terminal down'
|
||||
)}
|
||||
<DropdownMenuShortcut>{splitDownShortcut}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<TabWorkspaceLayoutMenuSection unifiedTabId={unifiedTabId} groupId={groupId} />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onTogglePin}>
|
||||
{isPinned ? <PinOff className="mr-1.5 size-3.5" /> : <Pin className="mr-1.5 size-3.5" />}
|
||||
|
||||
@@ -88,6 +88,15 @@ vi.mock('@dnd-kit/sortable', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./tab-strip-drag-scroll', () => ({
|
||||
useTabStripDragScrollHandlers: () => ({
|
||||
isTabDragActive: false,
|
||||
onDragScrollStartEnter: vi.fn(),
|
||||
onDragScrollEndEnter: vi.fn(),
|
||||
onDragScrollLeave: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const useAppStoreExport = (selector: Parameters<typeof useAppStoreMock>[0]): unknown =>
|
||||
useAppStoreMock(selector)
|
||||
useAppStoreExport.getState = vi.fn(() => ({
|
||||
@@ -174,6 +183,18 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuShortcut', props }
|
||||
},
|
||||
DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuLabel', props }
|
||||
},
|
||||
DropdownMenuSub: function DropdownMenuSub(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSub', props }
|
||||
},
|
||||
DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSubContent', props }
|
||||
},
|
||||
DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSubTrigger', props }
|
||||
},
|
||||
DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuTrigger', props }
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ import { translate } from '@/i18n/i18n'
|
||||
import { TabStripScrollIndicator } from './TabStripScrollIndicator'
|
||||
import { getTabStripScrollMaskClassName } from './tab-strip-scroll-metrics'
|
||||
import { useTabStripOverflowNavigation } from './tab-strip-overflow-navigation'
|
||||
import { useTabStripDragScrollHandlers } from './tab-strip-drag-scroll'
|
||||
|
||||
const isWindows = navigator.userAgent.includes('Windows')
|
||||
const isMacOs = navigator.userAgent.includes('Mac')
|
||||
@@ -137,10 +138,6 @@ type TabBarProps = {
|
||||
onMakePreviewFilePermanent?: (fileId: string, tabId?: string) => void
|
||||
onPinFile?: (fileId: string, tabId?: string) => void
|
||||
tabBarOrder?: string[]
|
||||
onCreateSplitGroup?: (
|
||||
direction: 'left' | 'right' | 'up' | 'down',
|
||||
sourceVisibleTabId?: string
|
||||
) => void
|
||||
hoveredTabInsertion?: HoveredTabInsertion | null
|
||||
/** Floating workspace panels are rounded; skip tab top borders that clash with the curve. */
|
||||
tabStripChrome?: 'default' | 'floating-panel'
|
||||
@@ -266,7 +263,6 @@ function TabBarInner({
|
||||
onMakePreviewFilePermanent,
|
||||
onPinFile,
|
||||
tabBarOrder,
|
||||
onCreateSplitGroup,
|
||||
hoveredTabInsertion,
|
||||
tabStripChrome = 'default'
|
||||
}: TabBarProps): React.JSX.Element {
|
||||
@@ -981,6 +977,10 @@ function TabBarInner({
|
||||
tabCount: orderedItems.length,
|
||||
worktreeId
|
||||
})
|
||||
const tabStripDragScroll = useTabStripDragScrollHandlers(scrollTabStrip, {
|
||||
start: tabStripOverflowState.canScrollStart,
|
||||
end: tabStripOverflowState.canScrollEnd
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1005,8 +1005,13 @@ function TabBarInner({
|
||||
'auto.components.tab.bar.TabBar.7a9b4af2af',
|
||||
'Scroll tabs left'
|
||||
)}
|
||||
disabled={!tabStripOverflowState.canScrollStart}
|
||||
aria-disabled={!tabStripOverflowState.canScrollStart}
|
||||
disabled={
|
||||
!tabStripDragScroll.isTabDragActive && !tabStripOverflowState.canScrollStart
|
||||
}
|
||||
onClick={() => scrollTabStrip('start')}
|
||||
onPointerEnter={tabStripDragScroll.onDragScrollStartEnter}
|
||||
onPointerLeave={tabStripDragScroll.onDragScrollLeave}
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
</Button>
|
||||
@@ -1069,6 +1074,8 @@ function TabBarInner({
|
||||
<SortableTab
|
||||
key={item.id}
|
||||
tab={terminalTab}
|
||||
unifiedTabId={item.unifiedTabId}
|
||||
groupId={resolvedGroupId}
|
||||
tabCount={orderedItems.length}
|
||||
hasTabsToRight={index < orderedItems.length - 1}
|
||||
isActive={
|
||||
@@ -1085,9 +1092,6 @@ function TabBarInner({
|
||||
onSetTabColor={onSetTabColor}
|
||||
onTogglePin={() => togglePinned(item)}
|
||||
onToggleExpand={onTogglePaneExpand}
|
||||
onSplitGroup={(direction, sourceVisibleTabId) =>
|
||||
onCreateSplitGroup?.(direction, sourceVisibleTabId)
|
||||
}
|
||||
dragData={dragData}
|
||||
dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null}
|
||||
includeTopTabBorder={includeTopTabBorder}
|
||||
@@ -1105,9 +1109,6 @@ function TabBarInner({
|
||||
onActivate={() => onActivateBrowserTab?.(item.id)}
|
||||
onClose={() => onCloseBrowserTab?.(item.id)}
|
||||
onCloseToRight={() => onCloseToRight(item.id)}
|
||||
onSplitGroup={(direction, sourceVisibleTabId) =>
|
||||
onCreateSplitGroup?.(direction, sourceVisibleTabId)
|
||||
}
|
||||
onDuplicate={() => onDuplicateBrowserTab?.(item.id)}
|
||||
onTogglePin={() => togglePinned(item)}
|
||||
dragData={dragData}
|
||||
@@ -1143,9 +1144,6 @@ function TabBarInner({
|
||||
onCloseAll={() => onCloseAllFiles?.()}
|
||||
onMakePermanent={() => {}}
|
||||
onTogglePin={() => togglePinned(item)}
|
||||
onSplitGroup={(direction, sourceVisibleTabId) =>
|
||||
onCreateSplitGroup?.(direction, sourceVisibleTabId)
|
||||
}
|
||||
dragData={dragData}
|
||||
dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null}
|
||||
includeTopTabBorder={includeTopTabBorder}
|
||||
@@ -1171,9 +1169,6 @@ function TabBarInner({
|
||||
onMakePreviewFilePermanent?.(item.data.id, item.data.tabId)
|
||||
}
|
||||
onTogglePin={() => togglePinned(item)}
|
||||
onSplitGroup={(direction, sourceVisibleTabId) =>
|
||||
onCreateSplitGroup?.(direction, sourceVisibleTabId)
|
||||
}
|
||||
dragData={dragData}
|
||||
dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null}
|
||||
includeTopTabBorder={includeTopTabBorder}
|
||||
@@ -1196,8 +1191,11 @@ function TabBarInner({
|
||||
'auto.components.tab.bar.TabBar.232e075b07',
|
||||
'Scroll tabs right'
|
||||
)}
|
||||
disabled={!tabStripOverflowState.canScrollEnd}
|
||||
aria-disabled={!tabStripOverflowState.canScrollEnd}
|
||||
disabled={!tabStripDragScroll.isTabDragActive && !tabStripOverflowState.canScrollEnd}
|
||||
onClick={() => scrollTabStrip('end')}
|
||||
onPointerEnter={tabStripDragScroll.onDragScrollEndEnter}
|
||||
onPointerLeave={tabStripDragScroll.onDragScrollLeave}
|
||||
>
|
||||
<ChevronRight className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -140,6 +140,15 @@ vi.mock('@dnd-kit/sortable', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./tab-strip-drag-scroll', () => ({
|
||||
useTabStripDragScrollHandlers: () => ({
|
||||
isTabDragActive: false,
|
||||
onDragScrollStartEnter: vi.fn(),
|
||||
onDragScrollEndEnter: vi.fn(),
|
||||
onDragScrollLeave: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const useAppStoreExport = (selector: Parameters<typeof useAppStoreMock>[0]): unknown =>
|
||||
useAppStoreMock(selector)
|
||||
useAppStoreExport.getState = vi.fn(() => ({
|
||||
@@ -237,6 +246,18 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuShortcut', props }
|
||||
},
|
||||
DropdownMenuLabel: function DropdownMenuLabel(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuLabel', props }
|
||||
},
|
||||
DropdownMenuSub: function DropdownMenuSub(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSub', props }
|
||||
},
|
||||
DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSubContent', props }
|
||||
},
|
||||
DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSubTrigger', props }
|
||||
},
|
||||
DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuTrigger', props }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuItem
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import type { TabSplitDirection } from '../../store/slices/tabs'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { canMoveTabToNewPaneColumn, moveTabToNewPaneColumn } from './tab-move-to-pane-column'
|
||||
|
||||
const PANE_COLUMN_DIRECTIONS: TabSplitDirection[] = ['right', 'left', 'down', 'up']
|
||||
|
||||
function paneColumnDirectionLabel(direction: TabSplitDirection): string {
|
||||
switch (direction) {
|
||||
case 'right':
|
||||
return translate('auto.components.tab.bar.TabWorkspaceLayoutMenuSection.right', 'Right')
|
||||
case 'left':
|
||||
return translate('auto.components.tab.bar.TabWorkspaceLayoutMenuSection.left', 'Left')
|
||||
case 'down':
|
||||
return translate('auto.components.tab.bar.TabWorkspaceLayoutMenuSection.down', 'Down')
|
||||
case 'up':
|
||||
return translate('auto.components.tab.bar.TabWorkspaceLayoutMenuSection.up', 'Up')
|
||||
}
|
||||
}
|
||||
|
||||
export function TabWorkspaceLayoutMenuSection({
|
||||
unifiedTabId,
|
||||
groupId
|
||||
}: {
|
||||
unifiedTabId: string
|
||||
groupId: string
|
||||
}): React.JSX.Element | null {
|
||||
if (!canMoveTabToNewPaneColumn(unifiedTabId, groupId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
{translate(
|
||||
'auto.components.tab.bar.TabWorkspaceLayoutMenuSection.moveToPaneColumn',
|
||||
'Move Tab to Split'
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{PANE_COLUMN_DIRECTIONS.map((direction) => (
|
||||
<DropdownMenuItem
|
||||
key={direction}
|
||||
onSelect={() => {
|
||||
moveTabToNewPaneColumn({ unifiedTabId, groupId, direction })
|
||||
}}
|
||||
>
|
||||
{paneColumnDirectionLabel(direction)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -84,4 +84,11 @@ describe('getTabRootStateClasses', () => {
|
||||
expect(classes).toContain('text-muted-foreground')
|
||||
expect(classes).toContain('hover:text-foreground')
|
||||
})
|
||||
|
||||
it('returns the selected-tab surface treatment while pressed before activation', () => {
|
||||
const classes = getTabRootStateClasses(false, true)
|
||||
expect(classes).toContain('bg-[color-mix(in_srgb,var(--foreground)_6%,var(--card))]')
|
||||
expect(classes).toContain('text-foreground')
|
||||
expect(classes).not.toContain('hover:text-foreground')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,8 +28,12 @@ export function getDropIndicatorClasses(dropIndicator: DropIndicator): string {
|
||||
export const ACTIVE_TAB_INDICATOR_CLASSES =
|
||||
'pointer-events-none absolute inset-x-0 bottom-0 h-[2px] bg-[color-mix(in_srgb,var(--foreground)_60%,var(--card))] z-10'
|
||||
|
||||
export function getTabRootStateClasses(isActive: boolean): string {
|
||||
return isActive
|
||||
export function showsTabSelectionChrome(isActive: boolean, isPressed = false): boolean {
|
||||
return isActive || isPressed
|
||||
}
|
||||
|
||||
export function getTabRootStateClasses(isActive: boolean, isPressed = false): string {
|
||||
return showsTabSelectionChrome(isActive, isPressed)
|
||||
? 'bg-[color-mix(in_srgb,var(--foreground)_6%,var(--card))] text-foreground'
|
||||
: 'bg-card text-muted-foreground hover:text-foreground'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT,
|
||||
type RequestActiveTerminalPaneSplitDetail
|
||||
} from '@/constants/terminal'
|
||||
|
||||
export function requestActiveTerminalPaneSplit(detail: RequestActiveTerminalPaneSplitDetail): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<RequestActiveTerminalPaneSplitDetail>(
|
||||
REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT,
|
||||
{ detail }
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '../../store'
|
||||
import type { Tab } from '../../../../shared/types'
|
||||
import { canMoveTabToNewPaneColumn, moveTabToNewPaneColumn } from './tab-move-to-pane-column'
|
||||
|
||||
const WT = 'wt-1'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mirrorWebRuntimeTabMove: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./web-runtime-tab-move-mirror', () => ({
|
||||
mirrorWebRuntimeTabMove: mocks.mirrorWebRuntimeTabMove
|
||||
}))
|
||||
|
||||
describe('tab-move-to-pane-column', () => {
|
||||
beforeEach(() => {
|
||||
mocks.mirrorWebRuntimeTabMove.mockReset()
|
||||
useAppStore.setState({
|
||||
activeWorktreeId: WT,
|
||||
groupsByWorktree: {
|
||||
[WT]: [
|
||||
{
|
||||
id: 'group-1',
|
||||
worktreeId: WT,
|
||||
activeTabId: 'tab-a',
|
||||
tabOrder: ['tab-a', 'tab-b']
|
||||
}
|
||||
]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
[WT]: [
|
||||
{
|
||||
id: 'tab-a',
|
||||
groupId: 'group-1',
|
||||
worktreeId: WT,
|
||||
contentType: 'terminal',
|
||||
entityId: 'term-a',
|
||||
label: 'A',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
} satisfies Tab,
|
||||
{
|
||||
id: 'tab-b',
|
||||
groupId: 'group-1',
|
||||
worktreeId: WT,
|
||||
contentType: 'terminal',
|
||||
entityId: 'term-b',
|
||||
label: 'B',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 1,
|
||||
createdAt: 1
|
||||
} satisfies Tab
|
||||
]
|
||||
},
|
||||
layoutByWorktree: {
|
||||
[WT]: { type: 'leaf', groupId: 'group-1' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('allows moving when the source group has more than one tab', () => {
|
||||
expect(canMoveTabToNewPaneColumn('tab-b', 'group-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks moving the only tab in a group', () => {
|
||||
useAppStore.setState({
|
||||
groupsByWorktree: {
|
||||
[WT]: [
|
||||
{
|
||||
id: 'group-1',
|
||||
worktreeId: WT,
|
||||
activeTabId: 'tab-a',
|
||||
tabOrder: ['tab-a']
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(canMoveTabToNewPaneColumn('tab-a', 'group-1')).toBe(false)
|
||||
expect(
|
||||
moveTabToNewPaneColumn({ unifiedTabId: 'tab-a', groupId: 'group-1', direction: 'right' })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('creates a sibling split pane via dropUnifiedTab', () => {
|
||||
const dropUnifiedTab = vi.fn(() => true)
|
||||
useAppStore.setState({ dropUnifiedTab } as Partial<ReturnType<typeof useAppStore.getState>>)
|
||||
|
||||
expect(
|
||||
moveTabToNewPaneColumn({ unifiedTabId: 'tab-b', groupId: 'group-1', direction: 'right' })
|
||||
).toBe(true)
|
||||
expect(dropUnifiedTab).toHaveBeenCalledWith('tab-b', {
|
||||
groupId: 'group-1',
|
||||
splitDirection: 'right'
|
||||
})
|
||||
expect(mocks.mirrorWebRuntimeTabMove).toHaveBeenCalledWith({
|
||||
kind: 'split',
|
||||
worktreeId: WT,
|
||||
tabId: 'tab-b',
|
||||
targetGroupId: 'group-1',
|
||||
splitDirection: 'right'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not mirror when the local store rejects the move', () => {
|
||||
const dropUnifiedTab = vi.fn(() => false)
|
||||
useAppStore.setState({ dropUnifiedTab } as Partial<ReturnType<typeof useAppStore.getState>>)
|
||||
|
||||
expect(
|
||||
moveTabToNewPaneColumn({ unifiedTabId: 'tab-b', groupId: 'group-1', direction: 'right' })
|
||||
).toBe(false)
|
||||
expect(mocks.mirrorWebRuntimeTabMove).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useAppStore } from '../../store'
|
||||
import type { TabSplitDirection } from '../../store/slices/tabs'
|
||||
import { mirrorWebRuntimeTabMove } from './web-runtime-tab-move-mirror'
|
||||
|
||||
type TabMovePaneColumnState = Pick<
|
||||
ReturnType<typeof useAppStore.getState>,
|
||||
'unifiedTabsByWorktree' | 'groupsByWorktree'
|
||||
>
|
||||
|
||||
export function canMoveTabToNewPaneColumnFromState(
|
||||
state: TabMovePaneColumnState,
|
||||
unifiedTabId: string,
|
||||
groupId: string
|
||||
): boolean {
|
||||
for (const [worktreeId, tabs] of Object.entries(state.unifiedTabsByWorktree)) {
|
||||
const tab = tabs.find((candidate) => candidate.id === unifiedTabId)
|
||||
if (!tab || tab.groupId !== groupId) {
|
||||
continue
|
||||
}
|
||||
const group = (state.groupsByWorktree[worktreeId] ?? []).find(
|
||||
(candidate) => candidate.id === groupId
|
||||
)
|
||||
if (!group) {
|
||||
return false
|
||||
}
|
||||
// Why: mirror dropUnifiedTab — splitting the only tab in a group onto an
|
||||
// adjacent split pane is a layout no-op the store rejects.
|
||||
return group.tabOrder.length > 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function canMoveTabToNewPaneColumn(unifiedTabId: string, groupId: string): boolean {
|
||||
return canMoveTabToNewPaneColumnFromState(useAppStore.getState(), unifiedTabId, groupId)
|
||||
}
|
||||
|
||||
export function moveTabToNewPaneColumn(args: {
|
||||
unifiedTabId: string
|
||||
groupId: string
|
||||
direction: TabSplitDirection
|
||||
}): boolean {
|
||||
const state = useAppStore.getState()
|
||||
const worktreeId = Object.entries(state.unifiedTabsByWorktree).find(([, tabs]) =>
|
||||
tabs.some(
|
||||
(candidate) => candidate.id === args.unifiedTabId && candidate.groupId === args.groupId
|
||||
)
|
||||
)?.[0]
|
||||
if (!worktreeId || !canMoveTabToNewPaneColumnFromState(state, args.unifiedTabId, args.groupId)) {
|
||||
return false
|
||||
}
|
||||
const moved = state.dropUnifiedTab(args.unifiedTabId, {
|
||||
groupId: args.groupId,
|
||||
splitDirection: args.direction
|
||||
})
|
||||
if (moved) {
|
||||
mirrorWebRuntimeTabMove({
|
||||
kind: 'split',
|
||||
worktreeId,
|
||||
tabId: args.unifiedTabId,
|
||||
targetGroupId: args.groupId,
|
||||
splitDirection: args.direction
|
||||
})
|
||||
}
|
||||
return moved
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useTabDragActive } from '../tab-group/tab-drag-context'
|
||||
|
||||
const TAB_STRIP_DRAG_SCROLL_INTERVAL_MS = 180
|
||||
|
||||
export function useTabStripDragScrollHandlers(
|
||||
scrollTabStrip: (direction: 'start' | 'end', behavior?: ScrollBehavior) => void,
|
||||
canScroll: { start: boolean; end: boolean }
|
||||
): {
|
||||
isTabDragActive: boolean
|
||||
onDragScrollStartEnter: () => void
|
||||
onDragScrollEndEnter: () => void
|
||||
onDragScrollLeave: () => void
|
||||
} {
|
||||
const isTabDragActive = useTabDragActive()
|
||||
const intervalRef = useRef<number | null>(null)
|
||||
const canScrollRef = useRef(canScroll)
|
||||
canScrollRef.current = canScroll
|
||||
|
||||
const stopDragScroll = useCallback((): void => {
|
||||
if (intervalRef.current !== null) {
|
||||
window.clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startDragScroll = useCallback(
|
||||
(direction: 'start' | 'end'): void => {
|
||||
stopDragScroll()
|
||||
if (!isTabDragActive) {
|
||||
return
|
||||
}
|
||||
|
||||
const canScrollInDirection = (): boolean =>
|
||||
direction === 'start' ? canScrollRef.current.start : canScrollRef.current.end
|
||||
|
||||
if (!canScrollInDirection()) {
|
||||
return
|
||||
}
|
||||
|
||||
const tick = (): void => {
|
||||
if (!canScrollInDirection()) {
|
||||
stopDragScroll()
|
||||
return
|
||||
}
|
||||
scrollTabStrip(direction, 'auto')
|
||||
}
|
||||
tick()
|
||||
intervalRef.current = window.setInterval(tick, TAB_STRIP_DRAG_SCROLL_INTERVAL_MS)
|
||||
},
|
||||
[isTabDragActive, scrollTabStrip, stopDragScroll]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTabDragActive) {
|
||||
stopDragScroll()
|
||||
}
|
||||
}, [isTabDragActive, stopDragScroll])
|
||||
|
||||
useEffect(() => () => stopDragScroll(), [stopDragScroll])
|
||||
|
||||
return {
|
||||
isTabDragActive,
|
||||
onDragScrollStartEnter: useCallback(() => startDragScroll('start'), [startDragScroll]),
|
||||
onDragScrollEndEnter: useCallback(() => startDragScroll('end'), [startDragScroll]),
|
||||
onDragScrollLeave: stopDragScroll
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { scrollTabStripByStep } from './tab-strip-overflow-navigation'
|
||||
|
||||
describe('scrollTabStripByStep', () => {
|
||||
it('scrolls instantly when requested for drag-hover navigation', () => {
|
||||
const scrollBy = vi.fn()
|
||||
const el = {
|
||||
clientWidth: 200,
|
||||
scrollBy
|
||||
} as unknown as HTMLElement
|
||||
|
||||
scrollTabStripByStep(el, 'end', 'auto')
|
||||
|
||||
expect(scrollBy).toHaveBeenCalledWith({
|
||||
left: 150,
|
||||
behavior: 'auto'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,21 @@ import {
|
||||
const TAB_STRIP_SCROLL_FRACTION = 0.75
|
||||
const TAB_STRIP_MIN_SCROLL_STEP_PX = 120
|
||||
|
||||
export function scrollTabStripByStep(
|
||||
el: HTMLElement,
|
||||
direction: 'start' | 'end',
|
||||
behavior: ScrollBehavior = 'smooth'
|
||||
): void {
|
||||
const scrollStep = Math.max(
|
||||
TAB_STRIP_MIN_SCROLL_STEP_PX,
|
||||
el.clientWidth * TAB_STRIP_SCROLL_FRACTION
|
||||
)
|
||||
el.scrollBy({
|
||||
left: direction === 'start' ? -scrollStep : scrollStep,
|
||||
behavior
|
||||
})
|
||||
}
|
||||
|
||||
const EMPTY_TAB_STRIP_OVERFLOW_STATE: TabStripScrollMetrics = {
|
||||
hasOverflow: false,
|
||||
canScrollStart: false,
|
||||
@@ -30,7 +45,7 @@ export function useTabStripOverflowNavigation({
|
||||
}): {
|
||||
tabStripRef: RefObject<HTMLDivElement | null>
|
||||
tabStripOverflowState: TabStripScrollMetrics
|
||||
scrollTabStrip: (direction: 'start' | 'end') => void
|
||||
scrollTabStrip: (direction: 'start' | 'end', behavior?: ScrollBehavior) => void
|
||||
} {
|
||||
const tabStripRef = useRef<HTMLDivElement>(null)
|
||||
const prevStripLenRef = useRef<{ worktreeId: string; len: number } | null>(null)
|
||||
@@ -48,20 +63,16 @@ export function useTabStripOverflowNavigation({
|
||||
sameTabStripScrollMetrics(previous, next) ? previous : next
|
||||
)
|
||||
}, [])
|
||||
const scrollTabStrip = useCallback((direction: 'start' | 'end'): void => {
|
||||
const el = tabStripRef.current
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
const scrollStep = Math.max(
|
||||
TAB_STRIP_MIN_SCROLL_STEP_PX,
|
||||
el.clientWidth * TAB_STRIP_SCROLL_FRACTION
|
||||
)
|
||||
el.scrollBy({
|
||||
left: direction === 'start' ? -scrollStep : scrollStep,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}, [])
|
||||
const scrollTabStrip = useCallback(
|
||||
(direction: 'start' | 'end', behavior: ScrollBehavior = 'smooth'): void => {
|
||||
const el = tabStripRef.current
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
scrollTabStripByStep(el, direction, behavior)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const el = tabStripRef.current
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { act, useRef, useState } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TabDragProvider } from '../tab-group/tab-drag-context'
|
||||
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
|
||||
|
||||
function Probe({ onActivate }: { onActivate: () => void }): React.JSX.Element {
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const dragActiveRef = useRef(false)
|
||||
dragActiveRef.current = dragActive
|
||||
|
||||
return (
|
||||
<TabDragProvider isTabDragActive={dragActive} isTabDragActiveRef={dragActiveRef}>
|
||||
<ProbeButton onActivate={onActivate} onDragActiveChange={setDragActive} />
|
||||
</TabDragProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbeButton({
|
||||
onActivate,
|
||||
onDragActiveChange
|
||||
}: {
|
||||
onActivate: () => void
|
||||
onDragActiveChange: (active: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
const { isPressed, onPointerDown } = useTabStripPointerActivation({ onActivate })
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-pressed={isPressed ? 'true' : 'false'}
|
||||
onPointerDown={(event) => onPointerDown(event)}
|
||||
>
|
||||
Tab
|
||||
</button>
|
||||
<button type="button" onClick={() => onDragActiveChange(true)}>
|
||||
Start drag
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
function renderProbe(onActivate = vi.fn()): {
|
||||
onActivate: ReturnType<typeof vi.fn>
|
||||
tabButton: HTMLButtonElement
|
||||
dragButton: HTMLButtonElement
|
||||
} {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
act(() => {
|
||||
root?.render(<Probe onActivate={onActivate} />)
|
||||
})
|
||||
const buttons = container.querySelectorAll('button')
|
||||
return {
|
||||
onActivate,
|
||||
tabButton: buttons[0] as HTMLButtonElement,
|
||||
dragButton: buttons[1] as HTMLButtonElement
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchPointer(target: EventTarget, type: string): void {
|
||||
target.dispatchEvent(new MouseEvent(type, { bubbles: true, button: 0 }))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount())
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
})
|
||||
|
||||
describe('useTabStripPointerActivation', () => {
|
||||
it('defers activation until pointerup', () => {
|
||||
const { onActivate, tabButton } = renderProbe()
|
||||
|
||||
act(() => dispatchPointer(tabButton, 'pointerdown'))
|
||||
expect(tabButton.dataset.pressed).toBe('true')
|
||||
expect(onActivate).not.toHaveBeenCalled()
|
||||
|
||||
act(() => dispatchPointer(window, 'pointerup'))
|
||||
expect(tabButton.dataset.pressed).toBe('false')
|
||||
expect(onActivate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cancels pending activation on pointercancel', () => {
|
||||
const { onActivate, tabButton } = renderProbe()
|
||||
|
||||
act(() => dispatchPointer(tabButton, 'pointerdown'))
|
||||
act(() => dispatchPointer(window, 'pointercancel'))
|
||||
|
||||
expect(tabButton.dataset.pressed).toBe('false')
|
||||
expect(onActivate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears pending activation when a drag starts', () => {
|
||||
const { onActivate, tabButton, dragButton } = renderProbe()
|
||||
|
||||
act(() => dispatchPointer(tabButton, 'pointerdown'))
|
||||
act(() => dragButton.click())
|
||||
act(() => dispatchPointer(window, 'pointerup'))
|
||||
|
||||
expect(tabButton.dataset.pressed).toBe('false')
|
||||
expect(onActivate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useTabDragActive, useTabDragActiveRef } from '../tab-group/tab-drag-context'
|
||||
|
||||
export function useTabStripPointerActivation({
|
||||
onActivate,
|
||||
disabled = false
|
||||
}: {
|
||||
onActivate: () => void
|
||||
disabled?: boolean
|
||||
}): {
|
||||
isPressed: boolean
|
||||
onPointerDown: (
|
||||
event: React.PointerEvent,
|
||||
dragListener?: (event: React.PointerEvent<Element>) => void
|
||||
) => void
|
||||
} {
|
||||
const isTabDragActive = useTabDragActive()
|
||||
const isTabDragActiveRef = useTabDragActiveRef()
|
||||
const [isPressed, setIsPressed] = useState(false)
|
||||
const pendingActivationRef = useRef(false)
|
||||
const onActivateRef = useRef(onActivate)
|
||||
onActivateRef.current = onActivate
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isTabDragActive) {
|
||||
return
|
||||
}
|
||||
pendingActivationRef.current = false
|
||||
setIsPressed(false)
|
||||
}, [isTabDragActive])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isPressed) {
|
||||
return
|
||||
}
|
||||
const finishPointerPress = (event: PointerEvent): void => {
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
const shouldActivate = pendingActivationRef.current && !isTabDragActiveRef.current
|
||||
pendingActivationRef.current = false
|
||||
setIsPressed(false)
|
||||
if (shouldActivate) {
|
||||
onActivateRef.current()
|
||||
}
|
||||
}
|
||||
const cancelPointerPress = (): void => {
|
||||
pendingActivationRef.current = false
|
||||
setIsPressed(false)
|
||||
}
|
||||
window.addEventListener('pointerup', finishPointerPress)
|
||||
window.addEventListener('pointercancel', cancelPointerPress)
|
||||
return () => {
|
||||
window.removeEventListener('pointerup', finishPointerPress)
|
||||
window.removeEventListener('pointercancel', cancelPointerPress)
|
||||
}
|
||||
}, [isPressed, isTabDragActiveRef])
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(event: React.PointerEvent, dragListener?: (event: React.PointerEvent<Element>) => void) => {
|
||||
if (disabled || event.button !== 0) {
|
||||
return
|
||||
}
|
||||
pendingActivationRef.current = true
|
||||
setIsPressed(true)
|
||||
dragListener?.(event)
|
||||
},
|
||||
[disabled]
|
||||
)
|
||||
|
||||
return { isPressed, onPointerDown }
|
||||
}
|
||||
@@ -62,6 +62,10 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenuItem: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuSeparator: () => null,
|
||||
DropdownMenuShortcut: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuLabel: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuSub: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuSubContent: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuSubTrigger: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
@@ -241,6 +245,8 @@ describe('tab title tooltips', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SortableTab
|
||||
tab={makeTerminalTab({ customTitle: 'Custom terminal title' })}
|
||||
unifiedTabId="terminal-1"
|
||||
groupId="group-1"
|
||||
tabCount={1}
|
||||
hasTabsToRight={false}
|
||||
isActive={true}
|
||||
@@ -254,7 +260,6 @@ describe('tab title tooltips', () => {
|
||||
onSetTabColor={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onToggleExpand={vi.fn()}
|
||||
onSplitGroup={vi.fn()}
|
||||
dragData={makeDragData('terminal', 'terminal-1')}
|
||||
/>
|
||||
)
|
||||
@@ -273,6 +278,8 @@ describe('tab title tooltips', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SortableTab
|
||||
tab={makeTerminalTab({ title: '✳ Claude Code' })}
|
||||
unifiedTabId="terminal-1"
|
||||
groupId="group-1"
|
||||
tabCount={1}
|
||||
hasTabsToRight={false}
|
||||
isActive={true}
|
||||
@@ -286,7 +293,6 @@ describe('tab title tooltips', () => {
|
||||
onSetTabColor={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onToggleExpand={vi.fn()}
|
||||
onSplitGroup={vi.fn()}
|
||||
dragData={makeDragData('terminal', 'terminal-1')}
|
||||
/>
|
||||
)
|
||||
@@ -309,7 +315,6 @@ describe('tab title tooltips', () => {
|
||||
onActivate={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onCloseToRight={vi.fn()}
|
||||
onSplitGroup={vi.fn()}
|
||||
onDuplicate={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
dragData={makeDragData('browser', 'browser-1')}
|
||||
@@ -340,7 +345,6 @@ describe('tab title tooltips', () => {
|
||||
onCloseAll={vi.fn()}
|
||||
onMakePermanent={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onSplitGroup={vi.fn()}
|
||||
dragData={makeDragData('editor', 'editor-tab-1')}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { RuntimeMobileSessionTabMove } from '../../../../shared/runtime-types'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { useAppStore } from '../../store'
|
||||
import {
|
||||
isWebRuntimeSessionActive,
|
||||
moveWebRuntimeSessionTab
|
||||
} from '../../runtime/web-runtime-session'
|
||||
|
||||
export function mirrorWebRuntimeTabMove(
|
||||
args: RuntimeMobileSessionTabMove & {
|
||||
worktreeId: string
|
||||
}
|
||||
): void {
|
||||
const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), args.worktreeId)
|
||||
if (!isWebRuntimeSessionActive(environmentId)) {
|
||||
return
|
||||
}
|
||||
void moveWebRuntimeSessionTab({
|
||||
...args,
|
||||
environmentId
|
||||
})
|
||||
}
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
readAiVaultSessionDragData
|
||||
} from '@/lib/ai-vault-session-drag'
|
||||
import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session'
|
||||
import { resolveDropZone, type TabDropZone } from './useTabDragSplit'
|
||||
import { resolveDropZone } from './tab-drop-zone'
|
||||
import type { TabDropZone } from './useTabDragSplit'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type PaneDropTarget = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { TabDropZone } from './useTabDragSplit'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
function getOverlayStyle(zone: TabDropZone): CSSProperties {
|
||||
switch (zone) {
|
||||
@@ -16,8 +17,27 @@ function getOverlayStyle(zone: TabDropZone): CSSProperties {
|
||||
}
|
||||
}
|
||||
|
||||
export default function TabGroupDropOverlay({ zone }: { zone: TabDropZone }): React.JSX.Element {
|
||||
export default function TabGroupDropOverlay({
|
||||
zone,
|
||||
showPaneColumnLabel = false,
|
||||
fillContainer = false
|
||||
}: {
|
||||
zone: TabDropZone
|
||||
showPaneColumnLabel?: boolean
|
||||
/** When the parent already sizes the overlay to the target region. */
|
||||
fillContainer?: boolean
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div aria-hidden="true" className="tab-drop-overlay absolute" style={getOverlayStyle(zone)} />
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="tab-drop-overlay absolute"
|
||||
style={fillContainer ? { inset: 0 } : getOverlayStyle(zone)}
|
||||
>
|
||||
{showPaneColumnLabel && zone !== 'center' ? (
|
||||
<span className="tab-drop-overlay__label pointer-events-none absolute bottom-2 left-2 rounded-sm px-1.5 py-0.5 font-medium">
|
||||
{translate('auto.components.tab.group.TabGroupDropOverlay.paneColumnLabel', 'New split')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,27 +1,22 @@
|
||||
import { Suspense, useMemo } from 'react'
|
||||
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
import { Columns2, Ellipsis, Rows2, X } from 'lucide-react'
|
||||
import { Columns2, Ellipsis, X } from 'lucide-react'
|
||||
import { useAppStore } from '../../store'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import TabBar from '../tab-bar/TabBar'
|
||||
|
||||
import { TabBarQuickCommandsButton } from '../tab-bar/TabBarQuickCommandsButton'
|
||||
import { useTabGroupWorkspaceModel } from './useTabGroupWorkspaceModel'
|
||||
import TabGroupDropOverlay from './TabGroupDropOverlay'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
import { resolveGroupTabFromVisibleId } from './tab-group-visible-id'
|
||||
import {
|
||||
getTabPaneBodyDroppableId,
|
||||
type HoveredTabInsertion,
|
||||
type TabDropZone
|
||||
} from './useTabDragSplit'
|
||||
import { getTabPaneBodyDroppableId, type HoveredTabInsertion } from './useTabDragSplit'
|
||||
import { tabGroupBodyAnchorName } from './tab-group-body-anchor'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
@@ -37,7 +32,6 @@ export default function TabGroupPanel({
|
||||
reserveClosedExplorerToggleSpace,
|
||||
reserveCollapsedSidebarHeaderSpace,
|
||||
isTabDragActive = false,
|
||||
activeDropZone = null,
|
||||
hoveredTabInsertion = null
|
||||
}: {
|
||||
groupId: string
|
||||
@@ -49,7 +43,6 @@ export default function TabGroupPanel({
|
||||
reserveClosedExplorerToggleSpace: boolean
|
||||
reserveCollapsedSidebarHeaderSpace: boolean
|
||||
isTabDragActive?: boolean
|
||||
activeDropZone?: TabDropZone | null
|
||||
hoveredTabInsertion?: HoveredTabInsertion | null
|
||||
}): React.JSX.Element {
|
||||
const rightSidebarOpen = useAppStore((state) => state.rightSidebarOpen)
|
||||
@@ -180,19 +173,20 @@ export default function TabGroupPanel({
|
||||
commands.pinFile(item.entityId, item.id)
|
||||
}}
|
||||
tabBarOrder={tabBarOrder}
|
||||
onCreateSplitGroup={commands.createSplitGroup}
|
||||
hoveredTabInsertion={hoveredTabInsertion}
|
||||
/>
|
||||
)
|
||||
|
||||
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'
|
||||
// Why: focused-only — the QC split-button and Pane Actions ellipsis both
|
||||
// appear together so the action cluster never reflows when focus shifts
|
||||
// between groups. Unfocused groups collapse the cluster fully (no
|
||||
// reserved width) since the surrounding tab strip already absorbs the
|
||||
// freed space.
|
||||
const actionChromeClassName = `flex shrink-0 items-center gap-0.5 overflow-hidden transition-[opacity] duration-150 ${
|
||||
// Why: every split pane owns its own split affordance (VS Code-style). The
|
||||
// button always splits right; up/down and left stay on tab drag.
|
||||
const splitPaneButtonClassName = `${menuButtonClassName} ${
|
||||
isFocused ? 'opacity-100' : 'opacity-70 hover:opacity-100'
|
||||
}`
|
||||
// Why: focused-only — quick commands and Close split pane stay with the
|
||||
// active pane so unfocused strips stay compact aside from the split control.
|
||||
const focusedActionChromeClassName = `flex shrink-0 items-center gap-0.5 overflow-hidden transition-[opacity] duration-150 ${
|
||||
isFocused ? 'ml-1.5 pointer-events-auto opacity-100' : 'pointer-events-none opacity-0 w-0'
|
||||
}`
|
||||
return (
|
||||
@@ -207,7 +201,7 @@ export default function TabGroupPanel({
|
||||
// reads as "selected" without making the unfocused content look
|
||||
// washed out or hard to read. Only applied when `hasSplitGroups`
|
||||
// because a lone group has nothing to contrast against.
|
||||
className={`group/tab-group flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
|
||||
className={`group/tab-group relative flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
|
||||
hasSplitGroups
|
||||
? // Why: drop the outer borders on the edge-touching groups. The
|
||||
// TabGroupSplitLayout wrapper already paints a full-height
|
||||
@@ -257,74 +251,63 @@ export default function TabGroupPanel({
|
||||
/>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1 h-full">{tabBar}</div>
|
||||
{/* Why: pane-scoped layout actions belong with the active pane instead
|
||||
{/* Why: pane-scoped layout actions belong with each split 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. */}
|
||||
as before. Split-right is one click (VS Code-style); close-group
|
||||
stays on the focused pane only. */}
|
||||
<div
|
||||
className={actionChromeClassName}
|
||||
className="ml-1.5 flex shrink-0 items-center gap-0.5"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
{isFocused ? (
|
||||
<TabBarQuickCommandsButton worktreeId={worktreeId} groupId={groupId} />
|
||||
) : null}
|
||||
{isFocused ? (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={translate(
|
||||
'auto.components.tab.group.TabGroupPanel.9acaf92093',
|
||||
'Pane Actions'
|
||||
)}
|
||||
title={translate(
|
||||
'auto.components.tab.group.TabGroupPanel.9acaf92093',
|
||||
'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" />
|
||||
{translate('auto.components.tab.group.TabGroupPanel.ab1e2bff04', 'Split Right')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
commands.createSplitGroup('down')
|
||||
}}
|
||||
>
|
||||
<Rows2 className="size-4" />
|
||||
{translate('auto.components.tab.group.TabGroupPanel.4df2a06d36', 'Split Down')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
commands.createSplitGroup('left')
|
||||
}}
|
||||
>
|
||||
<Columns2 className="size-4" />
|
||||
{translate('auto.components.tab.group.TabGroupPanel.30137df7d0', 'Split Left')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
commands.createSplitGroup('up')
|
||||
}}
|
||||
>
|
||||
<Rows2 className="size-4" />
|
||||
{translate('auto.components.tab.group.TabGroupPanel.0db2081805', 'Split Up')}
|
||||
</DropdownMenuItem>
|
||||
{hasSplitGroups ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={translate(
|
||||
'auto.components.tab.group.TabGroupPanel.addSplitPane',
|
||||
'Add split pane'
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
commands.createSplitGroup('right')
|
||||
}}
|
||||
className={splitPaneButtonClassName}
|
||||
>
|
||||
<Columns2 className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate(
|
||||
'auto.components.tab.group.TabGroupPanel.addSplitPane',
|
||||
'Add split pane'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className={focusedActionChromeClassName}>
|
||||
{isFocused ? (
|
||||
<TabBarQuickCommandsButton worktreeId={worktreeId} groupId={groupId} />
|
||||
) : null}
|
||||
{isFocused && hasSplitGroups ? (
|
||||
<Tooltip>
|
||||
<DropdownMenu modal={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={translate(
|
||||
'auto.components.tab.group.TabGroupPanel.9acaf92093',
|
||||
'Pane Actions'
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
className={menuButtonClassName}
|
||||
>
|
||||
<Ellipsis className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent align="end" side="bottom" sideOffset={4}>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => {
|
||||
@@ -333,15 +316,21 @@ export default function TabGroupPanel({
|
||||
>
|
||||
<X className="size-4" />
|
||||
{translate(
|
||||
'auto.components.tab.group.TabGroupPanel.f7d6ce445e',
|
||||
'Close Group'
|
||||
'auto.components.tab.group.TabGroupPanel.closePaneColumn',
|
||||
'Close split pane'
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{translate(
|
||||
'auto.components.tab.group.TabGroupPanel.9acaf92093',
|
||||
'Pane Actions'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* Why: Electron's native drag hit-test ignores z-index — a no-drag
|
||||
element only overrides drag when it's a DOM descendant, not a
|
||||
@@ -380,7 +369,6 @@ export default function TabGroupPanel({
|
||||
data-contextual-tour-target="workspace-agent-terminal-tip"
|
||||
/>
|
||||
) : null}
|
||||
{activeDropZone ? <TabGroupDropOverlay zone={activeDropZone} /> : null}
|
||||
{activeTab &&
|
||||
activeTab.contentType !== 'terminal' &&
|
||||
activeTab.contentType !== 'browser' &&
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const setTabGroupSplitRatioMock = vi.fn()
|
||||
@@ -33,6 +34,8 @@ vi.mock('./useTabDragSplit', () => ({
|
||||
activeDrag: null,
|
||||
collisionDetection: vi.fn(),
|
||||
hoveredDropTarget: null,
|
||||
hoveredTabInsertion: null,
|
||||
isTabDragActiveRef: { current: false },
|
||||
onDragCancel: vi.fn(),
|
||||
onDragEnd: vi.fn(),
|
||||
onDragMove: vi.fn(),
|
||||
@@ -45,6 +48,22 @@ vi.mock('./useTabDragSplit', () => ({
|
||||
|
||||
import TabGroupSplitLayout from './TabGroupSplitLayout'
|
||||
|
||||
type ReactElementLike = {
|
||||
type: string | ((props: Record<string, unknown>) => unknown)
|
||||
props: Record<string, unknown>
|
||||
}
|
||||
|
||||
function asElement(node: unknown): ReactElementLike {
|
||||
return node as ReactElementLike
|
||||
}
|
||||
|
||||
function invokeComponent(element: ReactElementLike): unknown {
|
||||
if (typeof element.type === 'function') {
|
||||
return element.type(element.props)
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
describe('TabGroupSplitLayout', () => {
|
||||
beforeEach(() => {
|
||||
setTabGroupSplitRatioMock.mockClear()
|
||||
@@ -53,6 +72,22 @@ describe('TabGroupSplitLayout', () => {
|
||||
useAppStoreMock.mockClear()
|
||||
})
|
||||
|
||||
function getLayoutWrapper(element: ReturnType<typeof TabGroupSplitLayout>) {
|
||||
const dndContext = asElement(element.props.children)
|
||||
return React.Children.toArray(dndContext.props.children as React.ReactNode)[0]
|
||||
}
|
||||
|
||||
function getSplitNodeElement(element: ReturnType<typeof TabGroupSplitLayout>) {
|
||||
const layoutWrapperChildren = React.Children.toArray(
|
||||
asElement(getLayoutWrapper(element)).props.children as React.ReactNode
|
||||
)
|
||||
const splitBody = layoutWrapperChildren[1]
|
||||
const splitNodeElement = React.Children.only(
|
||||
asElement(splitBody).props.children as React.ReactNode
|
||||
)
|
||||
return invokeComponent(asElement(splitNodeElement))
|
||||
}
|
||||
|
||||
function getLeafPanelProps(isWorktreeActive: boolean) {
|
||||
const element = TabGroupSplitLayout({
|
||||
layout: { type: 'leaf', groupId: 'group-1' },
|
||||
@@ -61,13 +96,7 @@ describe('TabGroupSplitLayout', () => {
|
||||
isWorktreeActive
|
||||
})
|
||||
|
||||
// DndContext has multiple children (layout wrapper + DragOverlay). The
|
||||
// layout wrapper holds [drag-strip, split-body]; the split-body holds the
|
||||
// SplitNode element.
|
||||
const layoutWrapper = element.props.children[0]
|
||||
const splitBody = layoutWrapper.props.children[1]
|
||||
const splitNodeElement = splitBody.props.children
|
||||
const tabGroupPanelElement = splitNodeElement.type(splitNodeElement.props)
|
||||
const tabGroupPanelElement = asElement(getSplitNodeElement(element))
|
||||
return tabGroupPanelElement.props as {
|
||||
groupId: string
|
||||
worktreeId: string
|
||||
@@ -112,9 +141,7 @@ describe('TabGroupSplitLayout', () => {
|
||||
isWorktreeActive: true
|
||||
})
|
||||
|
||||
const layoutWrapper = element.props.children[0]
|
||||
|
||||
expect(layoutWrapper.props.ref).toBe(setDragRootNodeMock)
|
||||
expect(asElement(getLayoutWrapper(element)).props.ref).toBe(setDragRootNodeMock)
|
||||
})
|
||||
|
||||
it('only reserves top-right header space for the floating explorer toggle', () => {
|
||||
@@ -131,17 +158,15 @@ describe('TabGroupSplitLayout', () => {
|
||||
isWorktreeActive: true
|
||||
})
|
||||
|
||||
const layoutWrapper = element.props.children[0]
|
||||
const splitBody = layoutWrapper.props.children[1]
|
||||
const splitNodeElement = splitBody.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 {
|
||||
const rootElement = asElement(getSplitNodeElement(element))
|
||||
const rootChildren = rootElement.props.children as unknown[]
|
||||
const leftChild = asElement(rootChildren[0]).props.children
|
||||
const rightChild = asElement(rootChildren[2]).props.children
|
||||
const leftPanelProps = asElement(invokeComponent(asElement(leftChild))).props as {
|
||||
reserveClosedExplorerToggleSpace: boolean
|
||||
reserveCollapsedSidebarHeaderSpace: boolean
|
||||
}
|
||||
const rightPanelProps = rightChild.type(rightChild.props).props as {
|
||||
const rightPanelProps = asElement(invokeComponent(asElement(rightChild))).props as {
|
||||
reserveClosedExplorerToggleSpace: boolean
|
||||
reserveCollapsedSidebarHeaderSpace: boolean
|
||||
}
|
||||
@@ -174,13 +199,10 @@ describe('TabGroupSplitLayout', () => {
|
||||
isWorktreeActive: true
|
||||
})
|
||||
|
||||
const layoutWrapper = element.props.children[0]
|
||||
const splitBody = layoutWrapper.props.children[1]
|
||||
const splitNodeElement = splitBody.props.children
|
||||
const rootElement = splitNodeElement.type(splitNodeElement.props)
|
||||
const resizeHandle = rootElement.props.children[1]
|
||||
const rootElement = asElement(getSplitNodeElement(element))
|
||||
const resizeHandle = asElement((rootElement.props.children as unknown[])[1])
|
||||
|
||||
resizeHandle.props.onResizeStart()
|
||||
;(resizeHandle.props.onResizeStart as () => void)()
|
||||
|
||||
expect(recordFeatureInteractionMock).toHaveBeenCalledWith('terminal-panes')
|
||||
})
|
||||
|
||||
@@ -4,7 +4,9 @@ import type { TabGroupLayoutNode } from '../../../../shared/types'
|
||||
import { useAppStore } from '../../store'
|
||||
import TabGroupPanel from './TabGroupPanel'
|
||||
import TabDragPreview from '../tab-bar/TabDragPreview'
|
||||
import { type HoveredTabInsertion, type TabDropZone, useTabDragSplit } from './useTabDragSplit'
|
||||
import { TabDragProvider } from './tab-drag-context'
|
||||
import TabPaneColumnSplitDragOverlay from './TabPaneColumnSplitDragOverlay'
|
||||
import { type HoveredTabInsertion, useTabDragSplit } from './useTabDragSplit'
|
||||
|
||||
const MIN_RATIO = 0.15
|
||||
const MAX_RATIO = 0.85
|
||||
@@ -120,8 +122,6 @@ function SplitNode({
|
||||
touchesRightEdge,
|
||||
touchesLeftEdge,
|
||||
isTabDragActive,
|
||||
activeDropGroupId,
|
||||
activeDropZone,
|
||||
hoveredTabInsertion
|
||||
}: {
|
||||
node: TabGroupLayoutNode
|
||||
@@ -134,8 +134,6 @@ function SplitNode({
|
||||
touchesRightEdge: boolean
|
||||
touchesLeftEdge: boolean
|
||||
isTabDragActive: boolean
|
||||
activeDropGroupId: string | null
|
||||
activeDropZone: TabDropZone | null
|
||||
hoveredTabInsertion: HoveredTabInsertion | null
|
||||
}): React.JSX.Element {
|
||||
const setTabGroupSplitRatio = useAppStore((state) => state.setTabGroupSplitRatio)
|
||||
@@ -157,7 +155,6 @@ function SplitNode({
|
||||
reserveClosedExplorerToggleSpace={touchesTopEdge && touchesRightEdge}
|
||||
reserveCollapsedSidebarHeaderSpace={touchesTopEdge && touchesLeftEdge}
|
||||
isTabDragActive={isTabDragActive}
|
||||
activeDropZone={activeDropGroupId === node.groupId ? activeDropZone : null}
|
||||
hoveredTabInsertion={
|
||||
hoveredTabInsertion?.groupId === node.groupId ? hoveredTabInsertion : null
|
||||
}
|
||||
@@ -185,8 +182,6 @@ function SplitNode({
|
||||
touchesRightEdge={isHorizontal ? false : touchesRightEdge}
|
||||
touchesLeftEdge={touchesLeftEdge}
|
||||
isTabDragActive={isTabDragActive}
|
||||
activeDropGroupId={activeDropGroupId}
|
||||
activeDropZone={activeDropZone}
|
||||
hoveredTabInsertion={hoveredTabInsertion}
|
||||
/>
|
||||
</div>
|
||||
@@ -207,8 +202,6 @@ function SplitNode({
|
||||
touchesRightEdge={touchesRightEdge}
|
||||
touchesLeftEdge={isHorizontal ? false : touchesLeftEdge}
|
||||
isTabDragActive={isTabDragActive}
|
||||
activeDropGroupId={activeDropGroupId}
|
||||
activeDropZone={activeDropZone}
|
||||
hoveredTabInsertion={hoveredTabInsertion}
|
||||
/>
|
||||
</div>
|
||||
@@ -231,22 +224,26 @@ export default function TabGroupSplitLayout({
|
||||
const hasSplits = layout.type === 'split'
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={dragSplit.sensors}
|
||||
collisionDetection={dragSplit.collisionDetection}
|
||||
onDragStart={dragSplit.onDragStart}
|
||||
onDragMove={dragSplit.onDragMove}
|
||||
onDragOver={dragSplit.onDragOver}
|
||||
onDragEnd={dragSplit.onDragEnd}
|
||||
onDragCancel={dragSplit.onDragCancel}
|
||||
// Why: dnd-kit auto-scrolls the tab strip when the cursor approaches its
|
||||
// edge, which in a multi-group layout creates a feedback loop — scroll
|
||||
// shifts tabs under the cursor, `over` re-resolves, scroll runs again.
|
||||
// We don't need autoscroll for tab-bar drags (strip fits the viewport),
|
||||
// so disabling it is the simplest fix.
|
||||
autoScroll={false}
|
||||
<TabDragProvider
|
||||
isTabDragActive={dragSplit.activeDrag !== null}
|
||||
isTabDragActiveRef={dragSplit.isTabDragActiveRef}
|
||||
>
|
||||
{/* Why: the 10px drag strip sits ABOVE the split layout — lifted out of
|
||||
<DndContext
|
||||
sensors={dragSplit.sensors}
|
||||
collisionDetection={dragSplit.collisionDetection}
|
||||
onDragStart={dragSplit.onDragStart}
|
||||
onDragMove={dragSplit.onDragMove}
|
||||
onDragOver={dragSplit.onDragOver}
|
||||
onDragEnd={dragSplit.onDragEnd}
|
||||
onDragCancel={dragSplit.onDragCancel}
|
||||
// Why: dnd-kit auto-scrolls the tab strip when the cursor approaches its
|
||||
// edge, which in a multi-group layout creates a feedback loop — scroll
|
||||
// shifts tabs under the cursor, `over` re-resolves, scroll runs again.
|
||||
// We don't need autoscroll for tab-bar drags (strip fits the viewport),
|
||||
// so disabling it is the simplest fix.
|
||||
autoScroll={false}
|
||||
>
|
||||
{/* Why: the 10px drag strip sits ABOVE the split layout — lifted out of
|
||||
each pane — so vertical split resize handles don't extend into the
|
||||
window-drag region at the top. Only the split layout's own panes
|
||||
own the resize handles, while this strip keeps the whole top of the
|
||||
@@ -262,41 +259,48 @@ export default function TabGroupSplitLayout({
|
||||
state. The leftmost pane suppresses its own `border-l` via
|
||||
`touchesLeftEdge`, so the seam is always exactly 1px — previously
|
||||
both painted and stacked into a 2px bar below the drag strip. */}
|
||||
<div
|
||||
ref={dragSplit.setDragRootNode}
|
||||
className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden border-l border-border"
|
||||
>
|
||||
<div
|
||||
className="h-[4px] shrink-0 bg-card"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
/>
|
||||
<div className="flex flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
<SplitNode
|
||||
node={layout}
|
||||
nodePath=""
|
||||
worktreeId={worktreeId}
|
||||
focusedGroupId={focusedGroupId}
|
||||
isWorktreeActive={isWorktreeActive}
|
||||
hasSplitGroups={hasSplits}
|
||||
touchesTopEdge={true}
|
||||
touchesRightEdge={true}
|
||||
touchesLeftEdge={true}
|
||||
isTabDragActive={dragSplit.activeDrag !== null}
|
||||
activeDropGroupId={dragSplit.hoveredDropTarget?.groupId ?? null}
|
||||
activeDropZone={dragSplit.hoveredDropTarget?.zone ?? null}
|
||||
hoveredTabInsertion={dragSplit.hoveredTabInsertion}
|
||||
ref={dragSplit.setDragRootNode}
|
||||
className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden border-l border-border"
|
||||
>
|
||||
<div
|
||||
className="h-[4px] shrink-0 bg-card"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
/>
|
||||
<div className="flex flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
<SplitNode
|
||||
node={layout}
|
||||
nodePath=""
|
||||
worktreeId={worktreeId}
|
||||
focusedGroupId={focusedGroupId}
|
||||
isWorktreeActive={isWorktreeActive}
|
||||
hasSplitGroups={hasSplits}
|
||||
touchesTopEdge={true}
|
||||
touchesRightEdge={true}
|
||||
touchesLeftEdge={true}
|
||||
isTabDragActive={dragSplit.activeDrag !== null}
|
||||
hoveredTabInsertion={dragSplit.hoveredTabInsertion}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Why: the sortable tab is anchored inside its source tab strip (no
|
||||
{/* Why: the sortable tab is anchored inside its source tab strip (no
|
||||
transform while dragging), and that strip uses overflow-hidden so
|
||||
the tab is invisible once the cursor leaves it. DragOverlay
|
||||
renders a ghost in a document-level portal that tracks the cursor
|
||||
across the whole window — the source tab keeps its spot, the
|
||||
ghost follows the cursor. */}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{dragSplit.activeDrag ? <TabDragPreview drag={dragSplit.activeDrag} /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{dragSplit.activeDrag ? <TabDragPreview drag={dragSplit.activeDrag} /> : null}
|
||||
</DragOverlay>
|
||||
{dragSplit.hoveredDropTarget &&
|
||||
dragSplit.hoveredDropTarget.zone !== 'center' &&
|
||||
dragSplit.hoveredDropTarget.panelRect ? (
|
||||
<TabPaneColumnSplitDragOverlay
|
||||
panelRect={dragSplit.hoveredDropTarget.panelRect}
|
||||
zone={dragSplit.hoveredDropTarget.zone}
|
||||
/>
|
||||
) : null}
|
||||
</DndContext>
|
||||
</TabDragProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { createPortalMock } = vi.hoisted(() => ({
|
||||
createPortalMock: vi.fn((node: unknown) => node)
|
||||
}))
|
||||
|
||||
vi.mock('react-dom', () => ({
|
||||
createPortal: createPortalMock
|
||||
}))
|
||||
|
||||
import TabPaneColumnSplitDragOverlay from './TabPaneColumnSplitDragOverlay'
|
||||
|
||||
function rect({
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height
|
||||
}: {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}): DOMRect {
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height
|
||||
} as DOMRect
|
||||
}
|
||||
|
||||
type ReactElementLike = {
|
||||
props: Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('TabPaneColumnSplitDragOverlay', () => {
|
||||
it('renders from cached panel bounds without reading layout during render', () => {
|
||||
const querySelector = vi.fn()
|
||||
vi.stubGlobal('document', {
|
||||
body: {},
|
||||
querySelector
|
||||
})
|
||||
|
||||
const overlay = TabPaneColumnSplitDragOverlay({
|
||||
panelRect: rect({ left: 100, top: 20, width: 400, height: 300 }),
|
||||
zone: 'right'
|
||||
})
|
||||
|
||||
expect(querySelector).not.toHaveBeenCalled()
|
||||
expect((overlay as ReactElementLike).props.style).toEqual({
|
||||
top: 20,
|
||||
left: 300,
|
||||
width: 200,
|
||||
height: 300
|
||||
})
|
||||
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { CSSProperties } from 'react'
|
||||
import TabGroupDropOverlay from './TabGroupDropOverlay'
|
||||
import type { TabDropZone } from './useTabDragSplit'
|
||||
|
||||
function getOverlayBounds(
|
||||
rect: DOMRect,
|
||||
zone: Exclude<TabDropZone, 'center'>
|
||||
): Pick<CSSProperties, 'top' | 'left' | 'width' | 'height'> {
|
||||
switch (zone) {
|
||||
case 'up':
|
||||
return {
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height / 2
|
||||
}
|
||||
case 'down':
|
||||
return {
|
||||
top: rect.top + rect.height / 2,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height / 2
|
||||
}
|
||||
case 'left':
|
||||
return {
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width / 2,
|
||||
height: rect.height
|
||||
}
|
||||
case 'right':
|
||||
return {
|
||||
top: rect.top,
|
||||
left: rect.left + rect.width / 2,
|
||||
width: rect.width / 2,
|
||||
height: rect.height
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function TabPaneColumnSplitDragOverlay({
|
||||
panelRect,
|
||||
zone
|
||||
}: {
|
||||
panelRect: DOMRect
|
||||
zone: Exclude<TabDropZone, 'center'>
|
||||
}): React.JSX.Element | null {
|
||||
const bounds = getOverlayBounds(panelRect, zone)
|
||||
return createPortal(
|
||||
<div aria-hidden="true" className="pointer-events-none fixed z-[10001]" style={bounds}>
|
||||
<TabGroupDropOverlay zone={zone} showPaneColumnLabel fillContainer />
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createContext, useContext, useMemo, type RefObject } from 'react'
|
||||
|
||||
type TabDragContextValue = {
|
||||
isTabDragActive: boolean
|
||||
isTabDragActiveRef: RefObject<boolean>
|
||||
}
|
||||
|
||||
const defaultRef: RefObject<boolean> = { current: false }
|
||||
|
||||
const TabDragContext = createContext<TabDragContextValue>({
|
||||
isTabDragActive: false,
|
||||
isTabDragActiveRef: defaultRef
|
||||
})
|
||||
|
||||
export function TabDragProvider({
|
||||
isTabDragActive,
|
||||
isTabDragActiveRef,
|
||||
children
|
||||
}: {
|
||||
isTabDragActive: boolean
|
||||
isTabDragActiveRef: RefObject<boolean>
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const value = useMemo(
|
||||
() => ({ isTabDragActive, isTabDragActiveRef }),
|
||||
[isTabDragActive, isTabDragActiveRef]
|
||||
)
|
||||
return <TabDragContext.Provider value={value}>{children}</TabDragContext.Provider>
|
||||
}
|
||||
|
||||
export function useTabDragActive(): boolean {
|
||||
return useContext(TabDragContext).isTabDragActive
|
||||
}
|
||||
|
||||
export function useTabDragActiveRef(): RefObject<boolean> {
|
||||
return useContext(TabDragContext).isTabDragActiveRef
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDragPointer } from './tab-drag-pointer'
|
||||
|
||||
describe('getDragPointer', () => {
|
||||
it('uses the activator client coordinates plus drag delta', () => {
|
||||
expect(
|
||||
getDragPointer({
|
||||
activatorEvent: { clientX: 100, clientY: 40 },
|
||||
delta: { x: 250, y: 10 },
|
||||
active: { rect: { current: { initial: null, translated: null } } }
|
||||
} as unknown as Parameters<typeof getDragPointer>[0])
|
||||
).toEqual({ x: 350, y: 50 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { DragEndEvent, DragMoveEvent, DragOverEvent } from '@dnd-kit/core'
|
||||
|
||||
type DragPointerEvent = Pick<
|
||||
DragMoveEvent | DragOverEvent | DragEndEvent,
|
||||
'activatorEvent' | 'delta' | 'active'
|
||||
>
|
||||
|
||||
/** Pointer position during a tab drag. Why: sortable tabs stay visually anchored
|
||||
* (no transform) while DragOverlay follows the cursor, so active.rect.translated
|
||||
* never tracks the pointer and panel-edge split targets would stay wrong. */
|
||||
export function getDragPointer(event: DragPointerEvent): { x: number; y: number } | null {
|
||||
const activator = event.activatorEvent
|
||||
if (
|
||||
activator &&
|
||||
typeof activator === 'object' &&
|
||||
'clientX' in activator &&
|
||||
'clientY' in activator &&
|
||||
typeof activator.clientX === 'number' &&
|
||||
typeof activator.clientY === 'number'
|
||||
) {
|
||||
return {
|
||||
x: activator.clientX + event.delta.x,
|
||||
y: activator.clientY + event.delta.y
|
||||
}
|
||||
}
|
||||
|
||||
const initial = event.active.rect.current.initial
|
||||
if (!initial) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
x: initial.left + initial.width / 2 + event.delta.x,
|
||||
y: initial.top + initial.height / 2 + event.delta.y
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { Tab } from '../../../../shared/types'
|
||||
import { useAppStore } from '../../store'
|
||||
import {
|
||||
applyDragPreviewTab,
|
||||
captureTabDragActivationSnapshot,
|
||||
restoreTabDragActivationSnapshot
|
||||
} from './tab-drag-preview-activation'
|
||||
|
||||
const WT = 'wt-preview-restore'
|
||||
|
||||
describe('restoreTabDragActivationSnapshot', () => {
|
||||
beforeEach(() => {
|
||||
useAppStore.setState({
|
||||
activeWorktreeId: WT,
|
||||
activeTabType: 'terminal',
|
||||
activeTabId: 'terminal-1',
|
||||
activeTabIdByWorktree: { [WT]: 'terminal-1' },
|
||||
activeTabTypeByWorktree: { [WT]: 'terminal' },
|
||||
groupsByWorktree: {
|
||||
[WT]: [
|
||||
{
|
||||
id: 'group-1',
|
||||
worktreeId: WT,
|
||||
activeTabId: 'tab-1',
|
||||
tabOrder: ['tab-1', 'tab-2']
|
||||
}
|
||||
]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
[WT]: [
|
||||
{
|
||||
id: 'tab-1',
|
||||
groupId: 'group-1',
|
||||
worktreeId: WT,
|
||||
contentType: 'terminal',
|
||||
entityId: 'terminal-1',
|
||||
label: 'Terminal 1',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
} satisfies Tab,
|
||||
{
|
||||
id: 'tab-2',
|
||||
groupId: 'group-1',
|
||||
worktreeId: WT,
|
||||
contentType: 'browser',
|
||||
entityId: 'browser-1',
|
||||
label: 'Browser',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 1,
|
||||
createdAt: 1
|
||||
} satisfies Tab
|
||||
]
|
||||
},
|
||||
activeGroupIdByWorktree: { [WT]: 'group-1' }
|
||||
})
|
||||
})
|
||||
|
||||
it('restores active-surface fields after a drag preview is cancelled', () => {
|
||||
const snapshot = captureTabDragActivationSnapshot(WT)
|
||||
|
||||
applyDragPreviewTab({
|
||||
worktreeId: WT,
|
||||
groupId: 'group-1',
|
||||
tabId: 'tab-2',
|
||||
activeGroupId: 'group-1'
|
||||
})
|
||||
|
||||
expect(useAppStore.getState().activeTabType).toBe('browser')
|
||||
expect(useAppStore.getState().activeBrowserTabId).toBe('browser-1')
|
||||
|
||||
restoreTabDragActivationSnapshot(WT, snapshot)
|
||||
|
||||
const state = useAppStore.getState()
|
||||
expect(state.groupsByWorktree[WT]?.[0]?.activeTabId).toBe('tab-1')
|
||||
expect(state.activeTabType).toBe('terminal')
|
||||
expect(state.activeTabId).toBe('terminal-1')
|
||||
expect(state.activeTabIdByWorktree[WT]).toBe('terminal-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useAppStore } from '../../store'
|
||||
import type { AppState } from '../../store/types'
|
||||
|
||||
export type TabDragActivationSnapshot = {
|
||||
activeGroupId: string | null
|
||||
activeTabIdByGroup: Record<string, string | null>
|
||||
}
|
||||
|
||||
function previewActiveSurfacePatch(
|
||||
state: AppState,
|
||||
worktreeId: string,
|
||||
groupId: string,
|
||||
tabId: string | null
|
||||
): Partial<AppState> {
|
||||
if (state.activeWorktreeId !== worktreeId || !tabId) {
|
||||
return {}
|
||||
}
|
||||
const unifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find(
|
||||
(tab) => tab.id === tabId && tab.groupId === groupId
|
||||
)
|
||||
if (!unifiedTab) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const nextActiveTabTypeByWorktree = (
|
||||
tabType: AppState['activeTabType']
|
||||
): AppState['activeTabTypeByWorktree'] => ({
|
||||
...state.activeTabTypeByWorktree,
|
||||
[worktreeId]: tabType
|
||||
})
|
||||
|
||||
if (unifiedTab.contentType === 'terminal') {
|
||||
return {
|
||||
activeTabId: unifiedTab.entityId,
|
||||
activeTabType: 'terminal',
|
||||
activeTabIdByWorktree: {
|
||||
...state.activeTabIdByWorktree,
|
||||
[worktreeId]: unifiedTab.entityId
|
||||
},
|
||||
activeTabTypeByWorktree: nextActiveTabTypeByWorktree('terminal')
|
||||
}
|
||||
}
|
||||
if (unifiedTab.contentType === 'browser') {
|
||||
return {
|
||||
activeBrowserTabId: unifiedTab.entityId,
|
||||
activeTabType: 'browser',
|
||||
activeBrowserTabIdByWorktree: {
|
||||
...state.activeBrowserTabIdByWorktree,
|
||||
[worktreeId]: unifiedTab.entityId
|
||||
},
|
||||
activeTabTypeByWorktree: nextActiveTabTypeByWorktree('browser')
|
||||
}
|
||||
}
|
||||
if (unifiedTab.contentType === 'simulator') {
|
||||
return {
|
||||
activeTabType: 'simulator',
|
||||
activeTabTypeByWorktree: nextActiveTabTypeByWorktree('simulator')
|
||||
}
|
||||
}
|
||||
return {
|
||||
activeFileId: unifiedTab.entityId,
|
||||
activeTabType: 'editor',
|
||||
activeFileIdByWorktree: {
|
||||
...state.activeFileIdByWorktree,
|
||||
[worktreeId]: unifiedTab.entityId
|
||||
},
|
||||
activeTabTypeByWorktree: nextActiveTabTypeByWorktree('editor')
|
||||
}
|
||||
}
|
||||
|
||||
export function captureTabDragActivationSnapshot(worktreeId: string): TabDragActivationSnapshot {
|
||||
const state = useAppStore.getState()
|
||||
const groups = state.groupsByWorktree[worktreeId] ?? []
|
||||
return {
|
||||
activeGroupId: state.activeGroupIdByWorktree[worktreeId] ?? null,
|
||||
activeTabIdByGroup: Object.fromEntries(groups.map((group) => [group.id, group.activeTabId]))
|
||||
}
|
||||
}
|
||||
|
||||
export function applyDragPreviewTab({
|
||||
worktreeId,
|
||||
groupId,
|
||||
tabId,
|
||||
activeGroupId
|
||||
}: {
|
||||
worktreeId: string
|
||||
groupId: string
|
||||
tabId: string | null
|
||||
activeGroupId: string
|
||||
}): void {
|
||||
useAppStore.setState((state): Partial<AppState> => {
|
||||
const groups = state.groupsByWorktree[worktreeId] ?? []
|
||||
const targetGroup = groups.find((group) => group.id === groupId)
|
||||
const groupUnchanged = targetGroup?.activeTabId === tabId
|
||||
const focusUnchanged = (state.activeGroupIdByWorktree[worktreeId] ?? null) === activeGroupId
|
||||
const surfacePatch = previewActiveSurfacePatch(state, worktreeId, groupId, tabId)
|
||||
if (groupUnchanged && focusUnchanged) {
|
||||
return Object.keys(surfacePatch).length > 0 ? surfacePatch : {}
|
||||
}
|
||||
|
||||
const next: Partial<AppState> = { ...surfacePatch }
|
||||
if (!groupUnchanged) {
|
||||
next.groupsByWorktree = {
|
||||
...state.groupsByWorktree,
|
||||
[worktreeId]: groups.map((group) =>
|
||||
group.id === groupId ? { ...group, activeTabId: tabId } : group
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!focusUnchanged) {
|
||||
next.activeGroupIdByWorktree = {
|
||||
...state.activeGroupIdByWorktree,
|
||||
[worktreeId]: activeGroupId
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function restoreTabDragActivationSnapshot(
|
||||
worktreeId: string,
|
||||
snapshot: TabDragActivationSnapshot
|
||||
): void {
|
||||
useAppStore.setState((state): Partial<AppState> => {
|
||||
const groups = state.groupsByWorktree[worktreeId] ?? []
|
||||
const groupsUnchanged = groups.every(
|
||||
(group) => (snapshot.activeTabIdByGroup[group.id] ?? null) === group.activeTabId
|
||||
)
|
||||
const focusUnchanged =
|
||||
(state.activeGroupIdByWorktree[worktreeId] ?? null) === snapshot.activeGroupId
|
||||
|
||||
const next: Partial<AppState> = {}
|
||||
if (!groupsUnchanged) {
|
||||
next.groupsByWorktree = {
|
||||
...state.groupsByWorktree,
|
||||
[worktreeId]: groups.map((group) => ({
|
||||
...group,
|
||||
activeTabId: snapshot.activeTabIdByGroup[group.id] ?? null
|
||||
}))
|
||||
}
|
||||
}
|
||||
if (!focusUnchanged) {
|
||||
if (snapshot.activeGroupId === null) {
|
||||
const nextActiveGroupIdByWorktree = { ...state.activeGroupIdByWorktree }
|
||||
delete nextActiveGroupIdByWorktree[worktreeId]
|
||||
next.activeGroupIdByWorktree = nextActiveGroupIdByWorktree
|
||||
} else {
|
||||
next.activeGroupIdByWorktree = {
|
||||
...state.activeGroupIdByWorktree,
|
||||
[worktreeId]: snapshot.activeGroupId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const restoredGroupId = snapshot.activeGroupId
|
||||
if (restoredGroupId) {
|
||||
const restoredTabId = snapshot.activeTabIdByGroup[restoredGroupId] ?? null
|
||||
Object.assign(
|
||||
next,
|
||||
previewActiveSurfacePatch(state, worktreeId, restoredGroupId, restoredTabId)
|
||||
)
|
||||
}
|
||||
|
||||
if (Object.keys(next).length === 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function restoreSourceGroupActiveTabAfterCrossGroupDrop({
|
||||
worktreeId,
|
||||
snapshot,
|
||||
sourceGroupId,
|
||||
movedTabId
|
||||
}: {
|
||||
worktreeId: string
|
||||
snapshot: TabDragActivationSnapshot
|
||||
sourceGroupId: string
|
||||
movedTabId: string
|
||||
}): void {
|
||||
const preDragActiveTabId = snapshot.activeTabIdByGroup[sourceGroupId] ?? null
|
||||
// Why: dropUnifiedTab already picks the next active tab when the moved tab
|
||||
// was the source group's selection; only preview contamination needs undo.
|
||||
if (preDragActiveTabId === movedTabId) {
|
||||
return
|
||||
}
|
||||
|
||||
useAppStore.setState((state): Partial<AppState> => {
|
||||
const groups = state.groupsByWorktree[worktreeId] ?? []
|
||||
const sourceGroup = groups.find((group) => group.id === sourceGroupId)
|
||||
if (!sourceGroup || sourceGroup.activeTabId === preDragActiveTabId) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
groupsByWorktree: {
|
||||
...state.groupsByWorktree,
|
||||
[worktreeId]: groups.map((group) =>
|
||||
group.id === sourceGroupId ? { ...group, activeTabId: preDragActiveTabId } : group
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { TabDragItemData } from './useTabDragSplit'
|
||||
import { isPaneDropData, isTabDragData } from './useTabDragSplit'
|
||||
|
||||
export type DragPreviewTabTarget = {
|
||||
groupId: string
|
||||
tabId: string | null
|
||||
}
|
||||
|
||||
export function resolveDragPreviewTabId({
|
||||
activeDrag,
|
||||
overData,
|
||||
preDragActiveTabIdByGroup,
|
||||
lastHoveredTabPreview = null
|
||||
}: {
|
||||
activeDrag: TabDragItemData
|
||||
overData: unknown
|
||||
preDragActiveTabIdByGroup: Record<string, string | null>
|
||||
lastHoveredTabPreview?: DragPreviewTabTarget | null
|
||||
}): DragPreviewTabTarget {
|
||||
const sourceGroupId = activeDrag.groupId
|
||||
const sourcePreDragTabId = preDragActiveTabIdByGroup[sourceGroupId] ?? null
|
||||
|
||||
if (isTabDragData(overData) && overData.unifiedTabId !== activeDrag.unifiedTabId) {
|
||||
// Why: tab-strip hovers target split drops or reorder slots. Previewing
|
||||
// the hovered tab's content reads like an in-tab split even though the drop
|
||||
// opens a new split.
|
||||
return { groupId: sourceGroupId, tabId: sourcePreDragTabId }
|
||||
}
|
||||
|
||||
if (isPaneDropData(overData)) {
|
||||
if (lastHoveredTabPreview?.groupId === overData.groupId && lastHoveredTabPreview.tabId) {
|
||||
return lastHoveredTabPreview
|
||||
}
|
||||
if (overData.groupId === sourceGroupId) {
|
||||
return { groupId: sourceGroupId, tabId: sourcePreDragTabId }
|
||||
}
|
||||
return {
|
||||
groupId: overData.groupId,
|
||||
tabId: preDragActiveTabIdByGroup[overData.groupId] ?? null
|
||||
}
|
||||
}
|
||||
|
||||
return { groupId: sourceGroupId, tabId: sourcePreDragTabId }
|
||||
}
|
||||
|
||||
export function resolveSourceGroupRestoreOnDrop(
|
||||
activeData: TabDragItemData,
|
||||
targetGroupId: string,
|
||||
restoreSnapshot: boolean
|
||||
): TabDragItemData | undefined {
|
||||
// Why: same-group splits keep the previewed active tab in the source pane via
|
||||
// dropUnifiedTab; restoring the pre-drag snapshot would undo that preview.
|
||||
if (restoreSnapshot || activeData.groupId === targetGroupId) {
|
||||
return undefined
|
||||
}
|
||||
return activeData
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolvePaneColumnEdgeZone, TAB_GROUP_TAB_STRIP_HEIGHT_PX } from './tab-drop-zone'
|
||||
|
||||
describe('resolvePaneColumnEdgeZone', () => {
|
||||
const panelRect = { left: 0, top: 0, width: 300, height: 200 }
|
||||
|
||||
it('returns right on the outer horizontal band in the body', () => {
|
||||
expect(resolvePaneColumnEdgeZone(panelRect, { x: 260, y: 100 })).toBe('right')
|
||||
})
|
||||
|
||||
it('returns null in the center band of the body', () => {
|
||||
expect(resolvePaneColumnEdgeZone(panelRect, { x: 150, y: 100 })).toBeNull()
|
||||
})
|
||||
|
||||
it('does not return up while the pointer is still in the tab strip', () => {
|
||||
expect(
|
||||
resolvePaneColumnEdgeZone(panelRect, {
|
||||
x: 150,
|
||||
y: TAB_GROUP_TAB_STRIP_HEIGHT_PX - 1
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns up on the top edge of the pane body', () => {
|
||||
expect(
|
||||
resolvePaneColumnEdgeZone(panelRect, {
|
||||
x: 150,
|
||||
y: TAB_GROUP_TAB_STRIP_HEIGHT_PX + 5
|
||||
})
|
||||
).toBe('up')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { TabDropZone } from './useTabDragSplit'
|
||||
|
||||
/** Matches TabGroupPanel tab row height (`h-[32px]`). */
|
||||
export const TAB_GROUP_TAB_STRIP_HEIGHT_PX = 32
|
||||
|
||||
type PaneRect = { left: number; top: number; width: number; height: number }
|
||||
|
||||
export function resolveDropZone(
|
||||
rect: { left: number; top: number; width: number; height: number },
|
||||
point: { x: number; y: number }
|
||||
): TabDropZone {
|
||||
const localX = point.x - rect.left
|
||||
const localY = point.y - rect.top
|
||||
const edgeWidthThreshold = rect.width * 0.1
|
||||
const edgeHeightThreshold = rect.height * 0.1
|
||||
const splitWidthThreshold = rect.width / 3
|
||||
|
||||
// Why: VS Code keeps a center "merge" zone while biasing side-by-side drops
|
||||
// toward left/right, which feels much more stable than a generic nearest-edge
|
||||
// calculation once a workspace has nested splits.
|
||||
if (
|
||||
localX > edgeWidthThreshold &&
|
||||
localX < rect.width - edgeWidthThreshold &&
|
||||
localY > edgeHeightThreshold &&
|
||||
localY < rect.height - edgeHeightThreshold
|
||||
) {
|
||||
return 'center'
|
||||
}
|
||||
|
||||
if (localX < splitWidthThreshold) {
|
||||
return 'left'
|
||||
}
|
||||
if (localX > splitWidthThreshold * 2) {
|
||||
return 'right'
|
||||
}
|
||||
return localY < rect.height / 2 ? 'up' : 'down'
|
||||
}
|
||||
|
||||
/** Outer band of a split pane panel where drags open another split. */
|
||||
export function resolvePaneColumnEdgeZone(
|
||||
panelRect: PaneRect,
|
||||
point: { x: number; y: number },
|
||||
options?: {
|
||||
bodyRect?: PaneRect | null
|
||||
tabStripHeightPx?: number
|
||||
}
|
||||
): Exclude<TabDropZone, 'center'> | null {
|
||||
const localX = point.x - panelRect.left
|
||||
const horizontalEdge = panelRect.width * 0.2
|
||||
|
||||
if (localX < horizontalEdge) {
|
||||
return 'left'
|
||||
}
|
||||
if (localX > panelRect.width - horizontalEdge) {
|
||||
return 'right'
|
||||
}
|
||||
|
||||
const tabStripHeight = options?.tabStripHeightPx ?? TAB_GROUP_TAB_STRIP_HEIGHT_PX
|
||||
const tabStripBottom = panelRect.top + tabStripHeight
|
||||
// Why: the tab strip is for reorder/insertion targets. Vertical pane splits
|
||||
// belong on the terminal/editor body edges only.
|
||||
if (point.y < tabStripBottom) {
|
||||
return null
|
||||
}
|
||||
|
||||
const bodyRect =
|
||||
options?.bodyRect ??
|
||||
({
|
||||
left: panelRect.left,
|
||||
top: tabStripBottom,
|
||||
width: panelRect.width,
|
||||
height: Math.max(0, panelRect.height - tabStripHeight)
|
||||
} satisfies PaneRect)
|
||||
|
||||
if (bodyRect.height <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const bodyLocalY = point.y - bodyRect.top
|
||||
const verticalEdge = bodyRect.height * 0.2
|
||||
|
||||
if (bodyLocalY < verticalEdge) {
|
||||
return 'up'
|
||||
}
|
||||
if (bodyLocalY > bodyRect.height - verticalEdge) {
|
||||
return 'down'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export type PaneColumnSplitTarget = {
|
||||
groupId: string
|
||||
zone: Exclude<TabDropZone, 'center'>
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TabGroup, TabGroupLayoutNode } from '../../../../shared/types'
|
||||
import type { TabDragItemData } from './useTabDragSplit'
|
||||
import {
|
||||
captureTabGroupPanelGeometrySnapshot,
|
||||
findTabGroupPanelUnderPointer,
|
||||
resolveActivePaneColumnSplitTarget,
|
||||
resolvePanelEdgePaneColumnSplit
|
||||
} from './tab-group-panel-split-target'
|
||||
import { TAB_GROUP_TAB_STRIP_HEIGHT_PX } from './tab-drop-zone'
|
||||
|
||||
function makeDragData(overrides: Partial<TabDragItemData> = {}): TabDragItemData {
|
||||
return {
|
||||
kind: 'tab',
|
||||
worktreeId: 'wt-1',
|
||||
groupId: 'group-1',
|
||||
unifiedTabId: 'tab-1',
|
||||
visibleTabId: 'tab-1',
|
||||
tabType: 'terminal',
|
||||
label: 'tab-1',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeEvent({
|
||||
activeData,
|
||||
overData = null,
|
||||
pointer = { x: 0, y: 0 }
|
||||
}: {
|
||||
activeData: TabDragItemData
|
||||
overData?: TabDragItemData | null
|
||||
pointer?: { x: number; y: number }
|
||||
}) {
|
||||
return {
|
||||
active: { data: { current: activeData } },
|
||||
over: overData
|
||||
? {
|
||||
data: { current: overData },
|
||||
rect: { left: 500, width: 120, top: 0, height: 32 }
|
||||
}
|
||||
: null,
|
||||
delta: { x: 0, y: 0 },
|
||||
activatorEvent: { clientX: pointer.x, clientY: pointer.y }
|
||||
} as unknown as Parameters<typeof resolveActivePaneColumnSplitTarget>[0]['event']
|
||||
}
|
||||
|
||||
function mockTabGroupRects(panelRect: DOMRect, bodyRect: DOMRect): void {
|
||||
vi.stubGlobal('document', {
|
||||
querySelector: vi.fn(() => ({
|
||||
getBoundingClientRect: () => bodyRect,
|
||||
parentElement: {
|
||||
getBoundingClientRect: () => panelRect
|
||||
}
|
||||
})),
|
||||
querySelectorAll: vi.fn(() => [
|
||||
{
|
||||
dataset: {
|
||||
tabGroupBodyId: 'group-2',
|
||||
worktreeId: 'wt-1'
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
function rect({
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height
|
||||
}: {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}): DOMRect {
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height
|
||||
} as DOMRect
|
||||
}
|
||||
|
||||
function mockTabGroupGeometry(
|
||||
entries: {
|
||||
groupId: string
|
||||
panelRect: DOMRect
|
||||
bodyRect: DOMRect
|
||||
counts?: { panelReads: number; bodyReads: number }
|
||||
}[]
|
||||
): { queryAll: ReturnType<typeof vi.fn> } {
|
||||
const bodies = entries.map((entry) => ({
|
||||
dataset: {
|
||||
tabGroupBodyId: entry.groupId,
|
||||
worktreeId: 'wt-1'
|
||||
},
|
||||
getBoundingClientRect: () => {
|
||||
if (entry.counts) {
|
||||
entry.counts.bodyReads += 1
|
||||
}
|
||||
return entry.bodyRect
|
||||
},
|
||||
parentElement: {
|
||||
getBoundingClientRect: () => {
|
||||
if (entry.counts) {
|
||||
entry.counts.panelReads += 1
|
||||
}
|
||||
return entry.panelRect
|
||||
}
|
||||
}
|
||||
}))
|
||||
const queryAll = vi.fn(() => bodies)
|
||||
vi.stubGlobal('document', {
|
||||
querySelector: vi.fn((selector: string) => {
|
||||
const match = selector.match(/data-tab-group-body-id="([^"]+)"/)
|
||||
return bodies.find((body) => body.dataset.tabGroupBodyId === match?.[1]) ?? null
|
||||
}),
|
||||
querySelectorAll: queryAll
|
||||
})
|
||||
return { queryAll }
|
||||
}
|
||||
|
||||
function percentile(values: number[], p: number): number {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]
|
||||
}
|
||||
|
||||
function fourGroupFixture(): {
|
||||
counts: { panelReads: number; bodyReads: number }[]
|
||||
groupsByWorktree: Record<string, TabGroup[]>
|
||||
layoutByWorktree: Record<string, TabGroupLayoutNode>
|
||||
queryAll: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const counts = [
|
||||
{ panelReads: 0, bodyReads: 0 },
|
||||
{ panelReads: 0, bodyReads: 0 },
|
||||
{ panelReads: 0, bodyReads: 0 },
|
||||
{ panelReads: 0, bodyReads: 0 }
|
||||
]
|
||||
const { queryAll } = mockTabGroupGeometry([
|
||||
{
|
||||
groupId: 'group-1',
|
||||
panelRect: rect({ left: 0, top: 0, width: 300, height: 600 }),
|
||||
bodyRect: rect({
|
||||
left: 0,
|
||||
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
|
||||
width: 300,
|
||||
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX
|
||||
}),
|
||||
counts: counts[0]
|
||||
},
|
||||
{
|
||||
groupId: 'group-2',
|
||||
panelRect: rect({ left: 304, top: 0, width: 300, height: 600 }),
|
||||
bodyRect: rect({
|
||||
left: 304,
|
||||
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
|
||||
width: 300,
|
||||
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX
|
||||
}),
|
||||
counts: counts[1]
|
||||
},
|
||||
{
|
||||
groupId: 'group-3',
|
||||
panelRect: rect({ left: 608, top: 0, width: 300, height: 600 }),
|
||||
bodyRect: rect({
|
||||
left: 608,
|
||||
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
|
||||
width: 300,
|
||||
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX
|
||||
}),
|
||||
counts: counts[2]
|
||||
},
|
||||
{
|
||||
groupId: 'group-4',
|
||||
panelRect: rect({ left: 912, top: 0, width: 300, height: 600 }),
|
||||
bodyRect: rect({
|
||||
left: 912,
|
||||
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
|
||||
width: 300,
|
||||
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX
|
||||
}),
|
||||
counts: counts[3]
|
||||
}
|
||||
])
|
||||
|
||||
return {
|
||||
counts,
|
||||
queryAll,
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{ id: 'group-1', worktreeId: 'wt-1', activeTabId: 'tab-1', tabOrder: ['tab-1', 'tab-5'] },
|
||||
{ id: 'group-2', worktreeId: 'wt-1', activeTabId: 'tab-2', tabOrder: ['tab-2'] },
|
||||
{ id: 'group-3', worktreeId: 'wt-1', activeTabId: 'tab-3', tabOrder: ['tab-3'] },
|
||||
{ id: 'group-4', worktreeId: 'wt-1', activeTabId: 'tab-4', tabOrder: ['tab-4'] }
|
||||
]
|
||||
},
|
||||
layoutByWorktree: {
|
||||
'wt-1': {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
ratio: 0.5,
|
||||
first: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
ratio: 0.5,
|
||||
first: { type: 'leaf', groupId: 'group-1' },
|
||||
second: { type: 'leaf', groupId: 'group-2' }
|
||||
},
|
||||
second: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
ratio: 0.5,
|
||||
first: { type: 'leaf', groupId: 'group-3' },
|
||||
second: { type: 'leaf', groupId: 'group-4' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const horizontalLayout: TabGroupLayoutNode = {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
ratio: 0.5,
|
||||
first: { type: 'leaf', groupId: 'group-1' },
|
||||
second: { type: 'leaf', groupId: 'group-2' }
|
||||
}
|
||||
|
||||
const twoGroupLayout = {
|
||||
'wt-1': [
|
||||
{ id: 'group-1', worktreeId: 'wt-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] },
|
||||
{ id: 'group-2', worktreeId: 'wt-1', activeTabId: 'tab-2', tabOrder: ['tab-2'] }
|
||||
]
|
||||
}
|
||||
|
||||
const layoutByWorktree = { 'wt-1': horizontalLayout }
|
||||
|
||||
describe('resolvePanelEdgePaneColumnSplit', () => {
|
||||
const panelRect = {
|
||||
left: 500,
|
||||
top: 0,
|
||||
width: 400,
|
||||
height: 600,
|
||||
right: 900,
|
||||
bottom: 600
|
||||
} as DOMRect
|
||||
const bodyRect = {
|
||||
left: 500,
|
||||
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
|
||||
width: 400,
|
||||
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX,
|
||||
right: 900,
|
||||
bottom: 600
|
||||
} as DOMRect
|
||||
|
||||
beforeEach(() => {
|
||||
mockTabGroupRects(panelRect, bodyRect)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns a right-edge split when the pointer is on the outer band', () => {
|
||||
expect(
|
||||
resolvePanelEdgePaneColumnSplit({
|
||||
activeDrag: makeDragData({ groupId: 'group-1' }),
|
||||
targetGroupId: 'group-2',
|
||||
worktreeId: 'wt-1',
|
||||
pointer: { x: 880, y: 300 },
|
||||
groupsByWorktree: twoGroupLayout,
|
||||
layoutByWorktree
|
||||
})
|
||||
).toEqual({ groupId: 'group-2', zone: 'right' })
|
||||
})
|
||||
|
||||
it('returns null in the center band so cross-group tab insertion can win', () => {
|
||||
expect(
|
||||
resolvePanelEdgePaneColumnSplit({
|
||||
activeDrag: makeDragData({ groupId: 'group-1' }),
|
||||
targetGroupId: 'group-2',
|
||||
worktreeId: 'wt-1',
|
||||
pointer: { x: 700, y: 300 },
|
||||
groupsByWorktree: twoGroupLayout,
|
||||
layoutByWorktree
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not treat the tab strip as a top split edge', () => {
|
||||
expect(
|
||||
resolvePanelEdgePaneColumnSplit({
|
||||
activeDrag: makeDragData({ groupId: 'group-2' }),
|
||||
targetGroupId: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
pointer: { x: 650, y: 16 },
|
||||
groupsByWorktree: twoGroupLayout,
|
||||
layoutByWorktree
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('suppresses adjacent sibling split drops that would collapse back to the current layout', () => {
|
||||
const group1Panel = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 400,
|
||||
height: 600,
|
||||
right: 400,
|
||||
bottom: 600
|
||||
} as DOMRect
|
||||
|
||||
expect(
|
||||
resolvePanelEdgePaneColumnSplit({
|
||||
activeDrag: makeDragData({ groupId: 'group-2', unifiedTabId: 'tab-2' }),
|
||||
targetGroupId: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
pointer: { x: 360, y: 300 },
|
||||
groupsByWorktree: twoGroupLayout,
|
||||
layoutByWorktree,
|
||||
panelRect: group1Panel
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects stale panel targets when the pointer has left the panel bounds', () => {
|
||||
expect(
|
||||
resolvePanelEdgePaneColumnSplit({
|
||||
activeDrag: makeDragData({ groupId: 'group-1' }),
|
||||
targetGroupId: 'group-2',
|
||||
worktreeId: 'wt-1',
|
||||
pointer: { x: 499, y: 300 },
|
||||
groupsByWorktree: twoGroupLayout,
|
||||
layoutByWorktree,
|
||||
panelRect
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveActivePaneColumnSplitTarget', () => {
|
||||
const panelRect = {
|
||||
left: 500,
|
||||
top: 0,
|
||||
width: 400,
|
||||
height: 600,
|
||||
right: 900,
|
||||
bottom: 600
|
||||
} as DOMRect
|
||||
const bodyRect = {
|
||||
left: 500,
|
||||
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
|
||||
width: 400,
|
||||
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX,
|
||||
right: 900,
|
||||
bottom: 600
|
||||
} as DOMRect
|
||||
|
||||
beforeEach(() => {
|
||||
mockTabGroupRects(panelRect, bodyRect)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('resolves cross-group panel-edge splits without an dnd-kit over target', () => {
|
||||
expect(findTabGroupPanelUnderPointer('wt-1', { x: 880, y: 300 })?.groupId).toBe('group-2')
|
||||
expect(
|
||||
resolveActivePaneColumnSplitTarget({
|
||||
event: makeEvent({
|
||||
activeData: makeDragData({ groupId: 'group-1' }),
|
||||
overData: null,
|
||||
pointer: { x: 880, y: 300 }
|
||||
}),
|
||||
groupsByWorktree: twoGroupLayout,
|
||||
layoutByWorktree,
|
||||
worktreeId: 'wt-1',
|
||||
getDragPointer: () => ({ x: 880, y: 300 })
|
||||
})
|
||||
).toEqual(expect.objectContaining({ groupId: 'group-2', zone: 'right' }))
|
||||
})
|
||||
|
||||
it('skips pane-edge splits for cross-group tab-strip hovers', () => {
|
||||
expect(
|
||||
resolveActivePaneColumnSplitTarget({
|
||||
event: makeEvent({
|
||||
activeData: makeDragData({ groupId: 'group-2', unifiedTabId: 'tab-2' }),
|
||||
overData: makeDragData({
|
||||
groupId: 'group-1',
|
||||
unifiedTabId: 'tab-1',
|
||||
visibleTabId: 'tab-1'
|
||||
}),
|
||||
pointer: { x: 650, y: 16 }
|
||||
}),
|
||||
groupsByWorktree: twoGroupLayout,
|
||||
layoutByWorktree,
|
||||
worktreeId: 'wt-1',
|
||||
getDragPointer: () => ({ x: 650, y: 16 })
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves body-edge splits when a stale cross-group tab hover remains active', () => {
|
||||
expect(
|
||||
resolveActivePaneColumnSplitTarget({
|
||||
event: makeEvent({
|
||||
activeData: makeDragData({ groupId: 'group-1' }),
|
||||
overData: makeDragData({
|
||||
groupId: 'group-2',
|
||||
unifiedTabId: 'tab-2',
|
||||
visibleTabId: 'tab-2'
|
||||
}),
|
||||
pointer: { x: 880, y: 300 }
|
||||
}),
|
||||
groupsByWorktree: twoGroupLayout,
|
||||
layoutByWorktree,
|
||||
worktreeId: 'wt-1',
|
||||
getDragPointer: () => ({ x: 880, y: 300 })
|
||||
})
|
||||
).toEqual(expect.objectContaining({ groupId: 'group-2', zone: 'right' }))
|
||||
})
|
||||
|
||||
it('ignores stale over targets after the pointer leaves the cached panel', () => {
|
||||
const geometry = {
|
||||
entries: [
|
||||
{
|
||||
groupId: 'group-2',
|
||||
panelRect,
|
||||
bodyRect
|
||||
}
|
||||
],
|
||||
byGroupId: new Map([['group-2', { groupId: 'group-2', panelRect, bodyRect }]])
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveActivePaneColumnSplitTarget({
|
||||
event: makeEvent({
|
||||
activeData: makeDragData({ groupId: 'group-1' }),
|
||||
overData: makeDragData({
|
||||
groupId: 'group-2',
|
||||
unifiedTabId: 'tab-2',
|
||||
visibleTabId: 'tab-2'
|
||||
}),
|
||||
pointer: { x: 499, y: 300 }
|
||||
}),
|
||||
groupsByWorktree: twoGroupLayout,
|
||||
layoutByWorktree,
|
||||
worktreeId: 'wt-1',
|
||||
getDragPointer: () => ({ x: 499, y: 300 }),
|
||||
geometry
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not create a split target while hovering over another tab in the same strip', () => {
|
||||
const panelRect = rect({ left: 500, top: 0, width: 400, height: 600 })
|
||||
mockTabGroupGeometry([
|
||||
{
|
||||
groupId: 'group-1',
|
||||
panelRect,
|
||||
bodyRect: rect({
|
||||
left: 500,
|
||||
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
|
||||
width: 400,
|
||||
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX
|
||||
})
|
||||
}
|
||||
])
|
||||
const geometry = captureTabGroupPanelGeometrySnapshot('wt-1')
|
||||
|
||||
expect(
|
||||
resolveActivePaneColumnSplitTarget({
|
||||
event: makeEvent({
|
||||
activeData: makeDragData({ groupId: 'group-1', unifiedTabId: 'tab-1' }),
|
||||
overData: makeDragData({
|
||||
groupId: 'group-1',
|
||||
unifiedTabId: 'tab-2',
|
||||
visibleTabId: 'tab-2'
|
||||
}),
|
||||
pointer: { x: 560, y: 16 }
|
||||
}),
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: 'tab-1',
|
||||
tabOrder: ['tab-1', 'tab-2']
|
||||
}
|
||||
]
|
||||
},
|
||||
layoutByWorktree: {
|
||||
'wt-1': { type: 'leaf', groupId: 'group-1' }
|
||||
},
|
||||
worktreeId: 'wt-1',
|
||||
getDragPointer: () => ({ x: 560, y: 16 }),
|
||||
geometry
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores same-group tab hovers after the pointer leaves the panel', () => {
|
||||
const panelRect = rect({ left: 500, top: 0, width: 400, height: 600 })
|
||||
mockTabGroupGeometry([
|
||||
{
|
||||
groupId: 'group-1',
|
||||
panelRect,
|
||||
bodyRect: rect({
|
||||
left: 500,
|
||||
top: TAB_GROUP_TAB_STRIP_HEIGHT_PX,
|
||||
width: 400,
|
||||
height: 600 - TAB_GROUP_TAB_STRIP_HEIGHT_PX
|
||||
})
|
||||
}
|
||||
])
|
||||
const geometry = captureTabGroupPanelGeometrySnapshot('wt-1')
|
||||
|
||||
expect(
|
||||
resolveActivePaneColumnSplitTarget({
|
||||
event: makeEvent({
|
||||
activeData: makeDragData({ groupId: 'group-1', unifiedTabId: 'tab-1' }),
|
||||
overData: makeDragData({
|
||||
groupId: 'group-1',
|
||||
unifiedTabId: 'tab-2',
|
||||
visibleTabId: 'tab-2'
|
||||
}),
|
||||
pointer: { x: 560, y: 601 }
|
||||
}),
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: 'tab-1',
|
||||
tabOrder: ['tab-1', 'tab-2']
|
||||
}
|
||||
]
|
||||
},
|
||||
layoutByWorktree: {
|
||||
'wt-1': { type: 'leaf', groupId: 'group-1' }
|
||||
},
|
||||
worktreeId: 'wt-1',
|
||||
getDragPointer: () => ({ x: 560, y: 601 }),
|
||||
geometry
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('reuses one geometry snapshot across drag frames instead of measuring layout per move', () => {
|
||||
const {
|
||||
counts,
|
||||
groupsByWorktree,
|
||||
layoutByWorktree: fourGroupLayoutByWorktree,
|
||||
queryAll
|
||||
} = fourGroupFixture()
|
||||
const geometry = captureTabGroupPanelGeometrySnapshot('wt-1')
|
||||
|
||||
const points = [
|
||||
{ x: 295, y: 300 },
|
||||
{ x: 360, y: 300 },
|
||||
{ x: 595, y: 300 },
|
||||
{ x: 664, y: 300 },
|
||||
{ x: 899, y: 300 },
|
||||
{ x: 968, y: 300 },
|
||||
{ x: 1204, y: 300 }
|
||||
]
|
||||
for (const pointer of points) {
|
||||
resolveActivePaneColumnSplitTarget({
|
||||
event: makeEvent({
|
||||
activeData: makeDragData({ groupId: 'group-1' }),
|
||||
overData: null,
|
||||
pointer
|
||||
}),
|
||||
groupsByWorktree,
|
||||
layoutByWorktree: fourGroupLayoutByWorktree,
|
||||
worktreeId: 'wt-1',
|
||||
getDragPointer: () => pointer,
|
||||
geometry
|
||||
})
|
||||
}
|
||||
|
||||
expect(queryAll).toHaveBeenCalledTimes(1)
|
||||
expect(counts.reduce((sum, entry) => sum + entry.panelReads + entry.bodyReads, 0)).toBe(8)
|
||||
})
|
||||
|
||||
it('reports cached split-target resolution timing without gating CI on wall-clock time', () => {
|
||||
const {
|
||||
counts,
|
||||
groupsByWorktree,
|
||||
layoutByWorktree: fourGroupLayoutByWorktree,
|
||||
queryAll
|
||||
} = fourGroupFixture()
|
||||
const geometry = captureTabGroupPanelGeometrySnapshot('wt-1')
|
||||
const points = [
|
||||
{ x: 295, y: 300 },
|
||||
{ x: 360, y: 300 },
|
||||
{ x: 595, y: 300 },
|
||||
{ x: 664, y: 300 },
|
||||
{ x: 899, y: 300 },
|
||||
{ x: 968, y: 300 },
|
||||
{ x: 1204, y: 300 },
|
||||
{ x: 650, y: 300 }
|
||||
]
|
||||
const durations: number[] = []
|
||||
for (let index = 0; index < 1_000; index += 1) {
|
||||
const pointer = points[index % points.length]!
|
||||
const startedAt = performance.now()
|
||||
resolveActivePaneColumnSplitTarget({
|
||||
event: makeEvent({
|
||||
activeData: makeDragData({ groupId: 'group-1' }),
|
||||
overData: null,
|
||||
pointer
|
||||
}),
|
||||
groupsByWorktree,
|
||||
layoutByWorktree: fourGroupLayoutByWorktree,
|
||||
worktreeId: 'wt-1',
|
||||
getDragPointer: () => pointer,
|
||||
geometry
|
||||
})
|
||||
durations.push(performance.now() - startedAt)
|
||||
}
|
||||
|
||||
const p95Ms = percentile(durations, 0.95)
|
||||
const maxMs = Math.max(...durations)
|
||||
console.info(
|
||||
`tab split preview resolver perf: p95=${p95Ms.toFixed(3)}ms max=${maxMs.toFixed(
|
||||
3
|
||||
)}ms samples=${durations.length}`
|
||||
)
|
||||
expect(queryAll).toHaveBeenCalledTimes(1)
|
||||
expect(counts.reduce((sum, entry) => sum + entry.panelReads + entry.bodyReads, 0)).toBe(8)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { DragEndEvent, DragMoveEvent, DragOverEvent } from '@dnd-kit/core'
|
||||
import type { TabGroup, TabGroupLayoutNode } from '../../../../shared/types'
|
||||
import { isPaneColumnSplitDropNoOp } from '../../store/slices/pane-column-split-drop-no-op'
|
||||
import {
|
||||
resolvePaneColumnEdgeZone,
|
||||
TAB_GROUP_TAB_STRIP_HEIGHT_PX,
|
||||
type PaneColumnSplitTarget
|
||||
} from './tab-drop-zone'
|
||||
import {
|
||||
canDropTabIntoPaneBody,
|
||||
isPaneDropData,
|
||||
isTabDragData,
|
||||
type TabDragItemData
|
||||
} from './useTabDragSplit'
|
||||
|
||||
export type TabGroupPanelGeometryEntry = {
|
||||
groupId: string
|
||||
panelRect: DOMRect
|
||||
bodyRect: DOMRect
|
||||
}
|
||||
|
||||
export type TabGroupPanelGeometrySnapshot = {
|
||||
entries: TabGroupPanelGeometryEntry[]
|
||||
byGroupId: Map<string, TabGroupPanelGeometryEntry>
|
||||
}
|
||||
|
||||
export type ActivePaneColumnSplitTarget = PaneColumnSplitTarget & {
|
||||
panelRect?: DOMRect
|
||||
}
|
||||
|
||||
function escapeCssAttrValue(value: string): string {
|
||||
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
||||
return CSS.escape(value)
|
||||
}
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
function getTabGroupBodyElement(groupId: string, worktreeId: string): HTMLElement | null {
|
||||
const escapedGroupId = escapeCssAttrValue(groupId)
|
||||
const escapedWorktreeId = escapeCssAttrValue(worktreeId)
|
||||
return document.querySelector<HTMLElement>(
|
||||
`[data-tab-group-body-id="${escapedGroupId}"][data-worktree-id="${escapedWorktreeId}"]`
|
||||
)
|
||||
}
|
||||
|
||||
export function getTabGroupPanelRect(groupId: string, worktreeId: string): DOMRect | null {
|
||||
return getTabGroupBodyElement(groupId, worktreeId)?.parentElement?.getBoundingClientRect() ?? null
|
||||
}
|
||||
|
||||
export function getTabGroupBodyRect(groupId: string, worktreeId: string): DOMRect | null {
|
||||
return getTabGroupBodyElement(groupId, worktreeId)?.getBoundingClientRect() ?? null
|
||||
}
|
||||
|
||||
export function captureTabGroupPanelGeometrySnapshot(
|
||||
worktreeId: string
|
||||
): TabGroupPanelGeometrySnapshot {
|
||||
const escapedWorktreeId = escapeCssAttrValue(worktreeId)
|
||||
const bodies = document.querySelectorAll<HTMLElement>(
|
||||
`[data-tab-group-body-id][data-worktree-id="${escapedWorktreeId}"]`
|
||||
)
|
||||
const entries: TabGroupPanelGeometryEntry[] = []
|
||||
for (const body of bodies) {
|
||||
const groupId = body.dataset.tabGroupBodyId
|
||||
const panelElement = body.parentElement
|
||||
if (!groupId || !panelElement) {
|
||||
continue
|
||||
}
|
||||
entries.push({
|
||||
groupId,
|
||||
panelRect: panelElement.getBoundingClientRect(),
|
||||
bodyRect: body.getBoundingClientRect()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
byGroupId: new Map(entries.map((entry) => [entry.groupId, entry]))
|
||||
}
|
||||
}
|
||||
|
||||
export function findTabGroupPanelUnderPointer(
|
||||
worktreeId: string,
|
||||
pointer: { x: number; y: number },
|
||||
options: {
|
||||
geometry?: TabGroupPanelGeometrySnapshot | null
|
||||
getPanelRect?: (groupId: string, worktreeId: string) => DOMRect | null
|
||||
} = {}
|
||||
): { groupId: string; panelRect: DOMRect } | null {
|
||||
if (options.geometry) {
|
||||
for (const entry of options.geometry.entries) {
|
||||
const { panelRect } = entry
|
||||
if (
|
||||
pointer.x >= panelRect.left &&
|
||||
pointer.x <= panelRect.right &&
|
||||
pointer.y >= panelRect.top &&
|
||||
pointer.y <= panelRect.bottom
|
||||
) {
|
||||
return { groupId: entry.groupId, panelRect }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getPanelRect = options.getPanelRect ?? getTabGroupPanelRect
|
||||
const escapedWorktreeId = escapeCssAttrValue(worktreeId)
|
||||
const bodies = document.querySelectorAll<HTMLElement>(
|
||||
`[data-tab-group-body-id][data-worktree-id="${escapedWorktreeId}"]`
|
||||
)
|
||||
for (const body of bodies) {
|
||||
const groupId = body.dataset.tabGroupBodyId
|
||||
if (!groupId) {
|
||||
continue
|
||||
}
|
||||
const panelRect = getPanelRect(groupId, worktreeId)
|
||||
if (!panelRect) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
pointer.x >= panelRect.left &&
|
||||
pointer.x <= panelRect.right &&
|
||||
pointer.y >= panelRect.top &&
|
||||
pointer.y <= panelRect.bottom
|
||||
) {
|
||||
return { groupId, panelRect }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolvePanelEdgePaneColumnSplit({
|
||||
activeDrag,
|
||||
targetGroupId,
|
||||
worktreeId,
|
||||
pointer,
|
||||
groupsByWorktree,
|
||||
layoutByWorktree,
|
||||
panelRect: providedPanelRect,
|
||||
bodyRect: providedBodyRect
|
||||
}: {
|
||||
activeDrag: TabDragItemData
|
||||
targetGroupId: string
|
||||
worktreeId: string
|
||||
pointer: { x: number; y: number }
|
||||
groupsByWorktree: Record<string, TabGroup[]>
|
||||
layoutByWorktree: Record<string, TabGroupLayoutNode>
|
||||
panelRect?: DOMRect | null
|
||||
bodyRect?: DOMRect | null
|
||||
}): PaneColumnSplitTarget | null {
|
||||
const panelRect = providedPanelRect ?? getTabGroupPanelRect(targetGroupId, worktreeId)
|
||||
if (!panelRect) {
|
||||
return null
|
||||
}
|
||||
// Why: dnd-kit can keep a closest-center `over` target after the pointer
|
||||
// leaves the pane; edge splits must only resolve inside the actual panel.
|
||||
if (
|
||||
pointer.x < panelRect.left ||
|
||||
pointer.x > panelRect.left + panelRect.width ||
|
||||
pointer.y < panelRect.top ||
|
||||
pointer.y > panelRect.top + panelRect.height
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const bodyRect = providedBodyRect ?? getTabGroupBodyRect(targetGroupId, worktreeId)
|
||||
|
||||
const zone = resolvePaneColumnEdgeZone(panelRect, pointer, {
|
||||
bodyRect: bodyRect ?? null,
|
||||
tabStripHeightPx: TAB_GROUP_TAB_STRIP_HEIGHT_PX
|
||||
})
|
||||
if (!zone) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sourceGroup = (groupsByWorktree[worktreeId] ?? []).find(
|
||||
(group) => group.id === activeDrag.groupId
|
||||
)
|
||||
if (
|
||||
isPaneColumnSplitDropNoOp({
|
||||
sourceGroupId: activeDrag.groupId,
|
||||
targetGroupId,
|
||||
splitDirection: zone,
|
||||
sourceTabCount: sourceGroup?.tabOrder.length ?? 0,
|
||||
layout: layoutByWorktree[worktreeId]
|
||||
})
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (activeDrag.groupId === targetGroupId) {
|
||||
if (
|
||||
!canDropTabIntoPaneBody({
|
||||
activeDrag,
|
||||
groupsByWorktree,
|
||||
overGroupId: targetGroupId,
|
||||
worktreeId
|
||||
})
|
||||
) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return { groupId: targetGroupId, zone }
|
||||
}
|
||||
|
||||
export function resolveActivePaneColumnSplitTarget({
|
||||
event,
|
||||
groupsByWorktree,
|
||||
layoutByWorktree,
|
||||
worktreeId,
|
||||
getDragPointer,
|
||||
geometry
|
||||
}: {
|
||||
event: DragMoveEvent | DragOverEvent | DragEndEvent
|
||||
groupsByWorktree: Record<string, TabGroup[]>
|
||||
layoutByWorktree: Record<string, TabGroupLayoutNode>
|
||||
worktreeId: string
|
||||
getDragPointer: (event: DragMoveEvent | DragOverEvent | DragEndEvent) => {
|
||||
x: number
|
||||
y: number
|
||||
} | null
|
||||
geometry?: TabGroupPanelGeometrySnapshot | null
|
||||
}): ActivePaneColumnSplitTarget | null {
|
||||
const activeData = event.active.data.current
|
||||
const pointer = getDragPointer(event)
|
||||
if (!isTabDragData(activeData) || !pointer) {
|
||||
return null
|
||||
}
|
||||
|
||||
const overData = event.over?.data.current
|
||||
const panelHit = findTabGroupPanelUnderPointer(worktreeId, pointer, { geometry })
|
||||
|
||||
if (isTabDragData(overData)) {
|
||||
// Why: tab-strip drags target reorder/insertion slots. Split creation stays
|
||||
// on pane/body edges so hovering over a tab never surprises the user with a
|
||||
// new split.
|
||||
if (!panelHit || pointer.y < panelHit.panelRect.top + TAB_GROUP_TAB_STRIP_HEIGHT_PX) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const targetGroupId =
|
||||
panelHit?.groupId ??
|
||||
(isTabDragData(overData) ? overData.groupId : null) ??
|
||||
(isPaneDropData(overData) ? overData.groupId : null)
|
||||
|
||||
if (!targetGroupId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const targetGeometry = geometry?.byGroupId.get(targetGroupId)
|
||||
const panelRect =
|
||||
panelHit?.groupId === targetGroupId ? panelHit.panelRect : targetGeometry?.panelRect
|
||||
const splitTarget = resolvePanelEdgePaneColumnSplit({
|
||||
activeDrag: activeData,
|
||||
targetGroupId,
|
||||
worktreeId,
|
||||
pointer,
|
||||
groupsByWorktree,
|
||||
layoutByWorktree,
|
||||
panelRect,
|
||||
bodyRect: targetGeometry?.bodyRect
|
||||
})
|
||||
return splitTarget ? { ...splitTarget, panelRect } : null
|
||||
}
|
||||
@@ -90,19 +90,18 @@ describe('resolveTabInsertion', () => {
|
||||
expect(resolveTabInsertion(event, isTabDragData, () => null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns side "left" when cursor center is left of the over midpoint', () => {
|
||||
it('returns side "left" when cursor is in the left reorder edge', () => {
|
||||
const overData = makeDragData({
|
||||
unifiedTabId: 'tab-over',
|
||||
visibleTabId: 'tab-over',
|
||||
groupId: 'group-2'
|
||||
})
|
||||
// Over rect: left=100, width=100 → midpoint=150
|
||||
// Over rect: left=100, width=100 → left edge ends at 130
|
||||
const event = makeDragEvent({
|
||||
activeData: makeDragData({ unifiedTabId: 'tab-active' }),
|
||||
overData,
|
||||
overRect: { left: 100, width: 100 }
|
||||
})
|
||||
// Cursor at x=120, which is left of midpoint 150
|
||||
const result = resolveTabInsertion(event, isTabDragData, () => ({ x: 120, y: 10 }))
|
||||
expect(result).toEqual({
|
||||
groupId: 'group-2',
|
||||
@@ -111,19 +110,18 @@ describe('resolveTabInsertion', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('returns side "right" when cursor center is right of the over midpoint', () => {
|
||||
it('returns side "right" when cursor is in the right reorder edge', () => {
|
||||
const overData = makeDragData({
|
||||
unifiedTabId: 'tab-over',
|
||||
visibleTabId: 'tab-over',
|
||||
groupId: 'group-2'
|
||||
})
|
||||
// Over rect: left=100, width=100 → midpoint=150
|
||||
// Over rect: left=100, width=100 → right edge starts at 170
|
||||
const event = makeDragEvent({
|
||||
activeData: makeDragData({ unifiedTabId: 'tab-active' }),
|
||||
overData,
|
||||
overRect: { left: 100, width: 100 }
|
||||
})
|
||||
// Cursor at x=180, which is right of midpoint 150
|
||||
const result = resolveTabInsertion(event, isTabDragData, () => ({ x: 180, y: 10 }))
|
||||
expect(result).toEqual({
|
||||
groupId: 'group-2',
|
||||
@@ -132,24 +130,39 @@ describe('resolveTabInsertion', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('returns side "right" when cursor is exactly at the midpoint', () => {
|
||||
it('uses midpoint insertion when reordering within the same pane', () => {
|
||||
const overData = makeDragData({
|
||||
unifiedTabId: 'tab-over',
|
||||
visibleTabId: 'tab-over',
|
||||
groupId: 'group-1'
|
||||
})
|
||||
const event = makeDragEvent({
|
||||
activeData: makeDragData({ unifiedTabId: 'tab-active', groupId: 'group-1' }),
|
||||
overData,
|
||||
overRect: { left: 0, width: 200 }
|
||||
})
|
||||
expect(resolveTabInsertion(event, isTabDragData, () => ({ x: 100, y: 10 }))).toEqual({
|
||||
groupId: 'group-1',
|
||||
visibleTabId: 'tab-over',
|
||||
side: 'right'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses midpoint insertion when dragging across split panes', () => {
|
||||
const overData = makeDragData({
|
||||
unifiedTabId: 'tab-over',
|
||||
visibleTabId: 'tab-over',
|
||||
groupId: 'group-2'
|
||||
})
|
||||
// Over rect: left=0, width=200 → midpoint=100
|
||||
const event = makeDragEvent({
|
||||
activeData: makeDragData({ unifiedTabId: 'tab-active' }),
|
||||
activeData: makeDragData({ unifiedTabId: 'tab-active', groupId: 'group-1' }),
|
||||
overData,
|
||||
overRect: { left: 0, width: 200 }
|
||||
})
|
||||
// Cursor at x=100 — exactly at midpoint, not < midpoint so → 'right'
|
||||
const result = resolveTabInsertion(event, isTabDragData, () => ({ x: 100, y: 10 }))
|
||||
expect(result).toEqual({
|
||||
expect(resolveTabInsertion(event, isTabDragData, () => ({ x: 80, y: 10 }))).toEqual({
|
||||
groupId: 'group-2',
|
||||
visibleTabId: 'tab-over',
|
||||
side: 'right'
|
||||
side: 'left'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TabGroup } from '../../../../shared/types'
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { act, createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../shared/types'
|
||||
import { useAppStore } from '../../store'
|
||||
import type { TabDragItemData } from './useTabDragSplit'
|
||||
import { canDropTabIntoPaneBody } from './useTabDragSplit'
|
||||
import {
|
||||
canDropTabForPaneColumnSplit,
|
||||
canDropTabIntoPaneBody,
|
||||
useTabDragSplit
|
||||
} from './useTabDragSplit'
|
||||
|
||||
vi.mock('../browser-pane/webview-registry', () => ({
|
||||
acquireWebviewsDragPassthrough: vi.fn(() => vi.fn())
|
||||
}))
|
||||
|
||||
vi.mock('../../runtime/web-runtime-session', () => ({
|
||||
isWebRuntimeSessionActive: vi.fn(() => false),
|
||||
moveWebRuntimeSessionTab: vi.fn()
|
||||
}))
|
||||
|
||||
const WT = 'wt-1'
|
||||
const mounted: { container: HTMLDivElement; root: Root }[] = []
|
||||
|
||||
function makeGroup(id: string, tabOrder: string[]): TabGroup {
|
||||
return {
|
||||
id,
|
||||
worktreeId: 'wt-1',
|
||||
worktreeId: WT,
|
||||
activeTabId: tabOrder[0] ?? null,
|
||||
tabOrder
|
||||
}
|
||||
@@ -15,7 +37,7 @@ function makeGroup(id: string, tabOrder: string[]): TabGroup {
|
||||
function makeDragData(groupId: string, unifiedTabId = 'tab-1'): TabDragItemData {
|
||||
return {
|
||||
kind: 'tab',
|
||||
worktreeId: 'wt-1',
|
||||
worktreeId: WT,
|
||||
groupId,
|
||||
unifiedTabId,
|
||||
visibleTabId: unifiedTabId,
|
||||
@@ -24,14 +46,145 @@ function makeDragData(groupId: string, unifiedTabId = 'tab-1'): TabDragItemData
|
||||
}
|
||||
}
|
||||
|
||||
function rect({
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height
|
||||
}: {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}): DOMRect {
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height
|
||||
} as DOMRect
|
||||
}
|
||||
|
||||
function addPanelGeometry(groupId: string, panelRect: DOMRect, bodyRect: DOMRect): HTMLElement {
|
||||
const panel = document.createElement('div')
|
||||
const body = document.createElement('div')
|
||||
body.dataset.tabGroupBodyId = groupId
|
||||
body.dataset.worktreeId = WT
|
||||
panel.getBoundingClientRect = () => panelRect
|
||||
body.getBoundingClientRect = () => bodyRect
|
||||
panel.appendChild(body)
|
||||
document.body.appendChild(panel)
|
||||
return panel
|
||||
}
|
||||
|
||||
function makeDragEvent(activeData: TabDragItemData, pointer: { x: number; y: number }) {
|
||||
return {
|
||||
active: {
|
||||
data: { current: activeData },
|
||||
rect: { current: { initial: null } }
|
||||
},
|
||||
over: null,
|
||||
delta: { x: 0, y: 0 },
|
||||
activatorEvent: { clientX: pointer.x, clientY: pointer.y }
|
||||
}
|
||||
}
|
||||
|
||||
function renderDragHook(): ReturnType<typeof useTabDragSplit> {
|
||||
let result: ReturnType<typeof useTabDragSplit> | null = null
|
||||
function Probe(): null {
|
||||
result = useTabDragSplit({ worktreeId: WT })
|
||||
return null
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
act(() => root.render(createElement(Probe)))
|
||||
mounted.push({ container, root })
|
||||
if (!result) {
|
||||
throw new Error('useTabDragSplit did not render')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAppStore.setState({
|
||||
activeWorktreeId: WT,
|
||||
activeGroupIdByWorktree: { [WT]: 'group-1' },
|
||||
groupsByWorktree: {
|
||||
[WT]: [makeGroup('group-1', ['tab-1', 'tab-3']), makeGroup('group-2', ['tab-2'])]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
[WT]: [
|
||||
{
|
||||
id: 'tab-1',
|
||||
groupId: 'group-1',
|
||||
worktreeId: WT,
|
||||
contentType: 'terminal',
|
||||
entityId: 'term-1',
|
||||
label: 'one',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
} satisfies Tab,
|
||||
{
|
||||
id: 'tab-2',
|
||||
groupId: 'group-2',
|
||||
worktreeId: WT,
|
||||
contentType: 'terminal',
|
||||
entityId: 'term-2',
|
||||
label: 'two',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 1,
|
||||
createdAt: 1
|
||||
} satisfies Tab,
|
||||
{
|
||||
id: 'tab-3',
|
||||
groupId: 'group-1',
|
||||
worktreeId: WT,
|
||||
contentType: 'terminal',
|
||||
entityId: 'term-3',
|
||||
label: 'three',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 2,
|
||||
createdAt: 2
|
||||
} satisfies Tab
|
||||
]
|
||||
},
|
||||
layoutByWorktree: {
|
||||
[WT]: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
ratio: 0.5,
|
||||
first: { type: 'leaf', groupId: 'group-1' },
|
||||
second: { type: 'leaf', groupId: 'group-2' }
|
||||
} satisfies TabGroupLayoutNode
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { container, root } of mounted.splice(0)) {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
}
|
||||
document.body.replaceChildren()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('canDropTabIntoPaneBody', () => {
|
||||
it('rejects pane-body drops that would split a single tab onto itself', () => {
|
||||
expect(
|
||||
canDropTabIntoPaneBody({
|
||||
activeDrag: makeDragData('group-1'),
|
||||
groupsByWorktree: { 'wt-1': [makeGroup('group-1', ['tab-1'])] },
|
||||
groupsByWorktree: { [WT]: [makeGroup('group-1', ['tab-1'])] },
|
||||
overGroupId: 'group-1',
|
||||
worktreeId: 'wt-1'
|
||||
worktreeId: WT
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
@@ -40,9 +193,9 @@ describe('canDropTabIntoPaneBody', () => {
|
||||
expect(
|
||||
canDropTabIntoPaneBody({
|
||||
activeDrag: makeDragData('group-1'),
|
||||
groupsByWorktree: { 'wt-1': [makeGroup('group-1', ['tab-1', 'tab-2'])] },
|
||||
groupsByWorktree: { [WT]: [makeGroup('group-1', ['tab-1', 'tab-2'])] },
|
||||
overGroupId: 'group-1',
|
||||
worktreeId: 'wt-1'
|
||||
worktreeId: WT
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
@@ -52,11 +205,59 @@ describe('canDropTabIntoPaneBody', () => {
|
||||
canDropTabIntoPaneBody({
|
||||
activeDrag: makeDragData('group-1'),
|
||||
groupsByWorktree: {
|
||||
'wt-1': [makeGroup('group-1', ['tab-1']), makeGroup('group-2', ['tab-2'])]
|
||||
[WT]: [makeGroup('group-1', ['tab-1']), makeGroup('group-2', ['tab-2'])]
|
||||
},
|
||||
overGroupId: 'group-2',
|
||||
worktreeId: 'wt-1'
|
||||
worktreeId: WT
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects tab-on-tab split drops across groups', () => {
|
||||
expect(
|
||||
canDropTabForPaneColumnSplit({
|
||||
activeDrag: makeDragData('group-1'),
|
||||
groupsByWorktree: {
|
||||
[WT]: [makeGroup('group-1', ['tab-1']), makeGroup('group-2', ['tab-2'])]
|
||||
},
|
||||
targetGroupId: 'group-2',
|
||||
worktreeId: WT
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTabDragSplit', () => {
|
||||
it('commits a geometry-only pane split when drag end has no over target', () => {
|
||||
addPanelGeometry(
|
||||
'group-2',
|
||||
rect({ left: 500, top: 0, width: 400, height: 600 }),
|
||||
rect({ left: 500, top: 32, width: 400, height: 568 })
|
||||
)
|
||||
const activeData = makeDragData('group-1')
|
||||
const dropUnifiedTab = vi.fn(() => true)
|
||||
useAppStore.setState({ dropUnifiedTab } as Partial<ReturnType<typeof useAppStore.getState>>)
|
||||
|
||||
const drag = renderDragHook()
|
||||
|
||||
act(() => {
|
||||
drag.onDragStart(
|
||||
makeDragEvent(activeData, { x: 880, y: 300 }) as unknown as Parameters<
|
||||
typeof drag.onDragStart
|
||||
>[0]
|
||||
)
|
||||
})
|
||||
act(() => {
|
||||
drag.onDragEnd(
|
||||
makeDragEvent(activeData, { x: 880, y: 300 }) as unknown as Parameters<
|
||||
typeof drag.onDragEnd
|
||||
>[0]
|
||||
)
|
||||
})
|
||||
|
||||
expect(dropUnifiedTab).toHaveBeenCalledWith('tab-1', {
|
||||
groupId: 'group-2',
|
||||
splitDirection: 'right'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* oxlint-disable max-lines -- Why: the drag-split hook co-locates drop-zone
|
||||
* resolution, same-group reordering, and cross-group handoff so state
|
||||
* transitions stay readable in one place. */
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useRef, useState, type RefObject } from 'react'
|
||||
import {
|
||||
closestCenter,
|
||||
pointerWithin,
|
||||
@@ -16,20 +16,30 @@ import {
|
||||
useSensors
|
||||
} from '@dnd-kit/core'
|
||||
import type { TabGroup, TuiAgent } from '../../../../shared/types'
|
||||
import type { RuntimeMobileSessionTabMove } from '../../../../shared/runtime-types'
|
||||
import { useAppStore } from '../../store'
|
||||
import {
|
||||
isWebRuntimeSessionActive,
|
||||
moveWebRuntimeSessionTab
|
||||
} from '../../runtime/web-runtime-session'
|
||||
import type { TabSplitDirection } from '../../store/slices/tabs'
|
||||
import { mirrorWebRuntimeTabMove } from '../tab-bar/web-runtime-tab-move-mirror'
|
||||
import {
|
||||
resolveTabInsertion,
|
||||
useHoveredTabInsertion,
|
||||
type HoveredTabInsertion
|
||||
} from './tab-insertion'
|
||||
import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import {
|
||||
applyDragPreviewTab,
|
||||
captureTabDragActivationSnapshot,
|
||||
restoreSourceGroupActiveTabAfterCrossGroupDrop,
|
||||
restoreTabDragActivationSnapshot,
|
||||
type TabDragActivationSnapshot
|
||||
} from './tab-drag-preview-activation'
|
||||
import { resolveDragPreviewTabId, resolveSourceGroupRestoreOnDrop } from './tab-drag-preview-target'
|
||||
import { getDragPointer } from './tab-drag-pointer'
|
||||
import {
|
||||
captureTabGroupPanelGeometrySnapshot,
|
||||
resolveActivePaneColumnSplitTarget,
|
||||
type ActivePaneColumnSplitTarget,
|
||||
type TabGroupPanelGeometrySnapshot
|
||||
} from './tab-group-panel-split-target'
|
||||
|
||||
export type { HoveredTabInsertion }
|
||||
|
||||
@@ -65,21 +75,7 @@ export type TabPaneDropData = {
|
||||
export type HoveredTabDropTarget = {
|
||||
groupId: string
|
||||
zone: TabDropZone
|
||||
}
|
||||
|
||||
function mirrorWebRuntimeTabMove(
|
||||
args: RuntimeMobileSessionTabMove & {
|
||||
worktreeId: string
|
||||
}
|
||||
): void {
|
||||
const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), args.worktreeId)
|
||||
if (!isWebRuntimeSessionActive(environmentId)) {
|
||||
return
|
||||
}
|
||||
void moveWebRuntimeSessionTab({
|
||||
...args,
|
||||
environmentId
|
||||
})
|
||||
panelRect?: DOMRect
|
||||
}
|
||||
|
||||
export function canDropTabIntoPaneBody({
|
||||
@@ -113,69 +109,33 @@ export function canDropTabIntoPaneBody({
|
||||
return true
|
||||
}
|
||||
|
||||
function isTabDragData(value: unknown): value is TabDragItemData {
|
||||
export function canDropTabForPaneColumnSplit(args: {
|
||||
activeDrag: TabDragItemData | null
|
||||
groupsByWorktree: Record<string, TabGroup[]>
|
||||
targetGroupId: string
|
||||
worktreeId: string
|
||||
}): boolean {
|
||||
if (!args.activeDrag || args.activeDrag.groupId !== args.targetGroupId) {
|
||||
return false
|
||||
}
|
||||
return canDropTabIntoPaneBody({
|
||||
activeDrag: args.activeDrag,
|
||||
groupsByWorktree: args.groupsByWorktree,
|
||||
overGroupId: args.targetGroupId,
|
||||
worktreeId: args.worktreeId
|
||||
})
|
||||
}
|
||||
|
||||
export function isTabDragData(value: unknown): value is TabDragItemData {
|
||||
return Boolean(value) && typeof value === 'object' && (value as TabDragItemData).kind === 'tab'
|
||||
}
|
||||
|
||||
function isPaneDropData(value: unknown): value is TabPaneDropData {
|
||||
export function isPaneDropData(value: unknown): value is TabPaneDropData {
|
||||
return (
|
||||
Boolean(value) && typeof value === 'object' && (value as TabPaneDropData).kind === 'pane-body'
|
||||
)
|
||||
}
|
||||
|
||||
function getDragCenter(
|
||||
event: Pick<DragMoveEvent, 'active' | 'delta'>
|
||||
): { x: number; y: number } | null {
|
||||
const translated = event.active.rect.current.translated
|
||||
if (translated) {
|
||||
return {
|
||||
x: translated.left + translated.width / 2,
|
||||
y: translated.top + translated.height / 2
|
||||
}
|
||||
}
|
||||
|
||||
const initial = event.active.rect.current.initial
|
||||
if (!initial) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
x: initial.left + initial.width / 2 + event.delta.x,
|
||||
y: initial.top + initial.height / 2 + event.delta.y
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDropZone(
|
||||
rect: { left: number; top: number; width: number; height: number },
|
||||
point: { x: number; y: number }
|
||||
): TabDropZone {
|
||||
const localX = point.x - rect.left
|
||||
const localY = point.y - rect.top
|
||||
const edgeWidthThreshold = rect.width * 0.1
|
||||
const edgeHeightThreshold = rect.height * 0.1
|
||||
const splitWidthThreshold = rect.width / 3
|
||||
|
||||
// Why: VS Code keeps a center "merge" zone while biasing side-by-side drops
|
||||
// toward left/right, which feels much more stable than a generic nearest-edge
|
||||
// calculation once a workspace has nested splits.
|
||||
if (
|
||||
localX > edgeWidthThreshold &&
|
||||
localX < rect.width - edgeWidthThreshold &&
|
||||
localY > edgeHeightThreshold &&
|
||||
localY < rect.height - edgeHeightThreshold
|
||||
) {
|
||||
return 'center'
|
||||
}
|
||||
|
||||
if (localX < splitWidthThreshold) {
|
||||
return 'left'
|
||||
}
|
||||
if (localX > splitWidthThreshold * 2) {
|
||||
return 'right'
|
||||
}
|
||||
return localY < rect.height / 2 ? 'up' : 'down'
|
||||
}
|
||||
|
||||
const collisionDetection: CollisionDetection = (args) => {
|
||||
const pointerCollisions = pointerWithin(args)
|
||||
return pointerCollisions.length > 0 ? pointerCollisions : closestCenter(args)
|
||||
@@ -199,6 +159,7 @@ export function useTabDragSplit({
|
||||
collisionDetection: CollisionDetection
|
||||
hoveredDropTarget: HoveredTabDropTarget | null
|
||||
hoveredTabInsertion: HoveredTabInsertion | null
|
||||
isTabDragActiveRef: RefObject<boolean>
|
||||
onDragCancel: () => void
|
||||
onDragEnd: (event: DragEndEvent) => void
|
||||
onDragMove: (event: DragMoveEvent) => void
|
||||
@@ -212,7 +173,12 @@ export function useTabDragSplit({
|
||||
const [activeDrag, setActiveDrag] = useState<TabDragItemData | null>(null)
|
||||
const [hoveredDropTarget, setHoveredDropTarget] = useState<HoveredTabDropTarget | null>(null)
|
||||
const releaseWebviewDragPassthroughRef = useRef<(() => void) | null>(null)
|
||||
const tabInsertion = useHoveredTabInsertion(isTabDragData, getDragCenter)
|
||||
const preDragActivationSnapshotRef = useRef<TabDragActivationSnapshot | null>(null)
|
||||
const lastPreviewRef = useRef<{ groupId: string; tabId: string | null } | null>(null)
|
||||
const lastHoveredTabPreviewRef = useRef<{ groupId: string; tabId: string } | null>(null)
|
||||
const tabDragActiveRef = useRef(false)
|
||||
const dragGeometryRef = useRef<TabGroupPanelGeometrySnapshot | null>(null)
|
||||
const tabInsertion = useHoveredTabInsertion(isTabDragData, getDragPointer)
|
||||
|
||||
// Why: hidden worktrees stay mounted so their PTYs survive worktree
|
||||
// switches, but their DndContext should not activate drags. We use an
|
||||
@@ -250,60 +216,135 @@ export function useTabDragSplit({
|
||||
)
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
tabDragActiveRef.current = false
|
||||
releaseWebviewDragPassthrough()
|
||||
setActiveDrag(null)
|
||||
setHoveredDropTarget(null)
|
||||
tabInsertion.clear()
|
||||
preDragActivationSnapshotRef.current = null
|
||||
lastPreviewRef.current = null
|
||||
lastHoveredTabPreviewRef.current = null
|
||||
dragGeometryRef.current = null
|
||||
}, [releaseWebviewDragPassthrough, tabInsertion])
|
||||
|
||||
const updateHoveredPane = useCallback(
|
||||
(event: DragMoveEvent | DragOverEvent) => {
|
||||
const overData = event.over?.data.current
|
||||
if (!event.over || !isPaneDropData(overData)) {
|
||||
// Why: using functional updater to avoid a new null reference when
|
||||
// the state is already null — prevents unnecessary re-renders during
|
||||
// high-frequency onDragMove events.
|
||||
setHoveredDropTarget((prev) => (prev === null ? prev : null))
|
||||
const restorePreDragActivation = useCallback(() => {
|
||||
const snapshot = preDragActivationSnapshotRef.current
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
restoreTabDragActivationSnapshot(worktreeId, snapshot)
|
||||
}, [worktreeId])
|
||||
|
||||
const restoreSourceGroupAfterCrossGroupDrop = useCallback(
|
||||
(activeData: TabDragItemData) => {
|
||||
const snapshot = preDragActivationSnapshotRef.current
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
|
||||
const activeData = event.active.data.current
|
||||
if (
|
||||
!isTabDragData(activeData) ||
|
||||
!canDropTabIntoPaneBody({
|
||||
activeDrag: activeData,
|
||||
groupsByWorktree: useAppStore.getState().groupsByWorktree,
|
||||
overGroupId: overData.groupId,
|
||||
worktreeId
|
||||
})
|
||||
) {
|
||||
setHoveredDropTarget((prev) => (prev === null ? prev : null))
|
||||
return
|
||||
}
|
||||
|
||||
const center = getDragCenter(event)
|
||||
if (!center) {
|
||||
setHoveredDropTarget((prev) => (prev === null ? prev : null))
|
||||
return
|
||||
}
|
||||
|
||||
// Why: onDragMove fires at pointer-move frequency (~60 fps). Creating
|
||||
// a new { groupId, zone } object every time would trigger a state
|
||||
// update and full re-render of the SplitNode tree on every frame even
|
||||
// when nothing meaningful changed. The functional updater lets us
|
||||
// compare against the previous value and return the same reference
|
||||
// when groupId and zone are unchanged.
|
||||
setHoveredDropTarget((prev) => {
|
||||
const zone = resolveDropZone(event.over!.rect, center)
|
||||
if (prev?.groupId === overData.groupId && prev?.zone === zone) {
|
||||
return prev
|
||||
}
|
||||
return { groupId: overData.groupId, zone }
|
||||
restoreSourceGroupActiveTabAfterCrossGroupDrop({
|
||||
worktreeId,
|
||||
snapshot,
|
||||
sourceGroupId: activeData.groupId,
|
||||
movedTabId: activeData.unifiedTabId
|
||||
})
|
||||
},
|
||||
[worktreeId]
|
||||
)
|
||||
|
||||
const finishDrag = useCallback(
|
||||
(restoreSnapshot: boolean, activeData?: TabDragItemData) => {
|
||||
if (restoreSnapshot) {
|
||||
restorePreDragActivation()
|
||||
} else if (activeData) {
|
||||
restoreSourceGroupAfterCrossGroupDrop(activeData)
|
||||
}
|
||||
clearDragState()
|
||||
},
|
||||
[clearDragState, restorePreDragActivation, restoreSourceGroupAfterCrossGroupDrop]
|
||||
)
|
||||
|
||||
const updateDragPreviewActivation = useCallback(
|
||||
(event: DragMoveEvent | DragOverEvent, activeData: TabDragItemData) => {
|
||||
const snapshot = preDragActivationSnapshotRef.current
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
|
||||
const overData = event.over?.data.current
|
||||
if (isTabDragData(overData) && overData.unifiedTabId !== activeData.unifiedTabId) {
|
||||
lastHoveredTabPreviewRef.current = {
|
||||
groupId: overData.groupId,
|
||||
tabId: overData.unifiedTabId
|
||||
}
|
||||
}
|
||||
|
||||
const preview = resolveDragPreviewTabId({
|
||||
activeDrag: activeData,
|
||||
overData,
|
||||
preDragActiveTabIdByGroup: snapshot.activeTabIdByGroup,
|
||||
lastHoveredTabPreview: lastHoveredTabPreviewRef.current
|
||||
})
|
||||
const lastPreview = lastPreviewRef.current
|
||||
if (lastPreview?.groupId === preview.groupId && lastPreview.tabId === preview.tabId) {
|
||||
return
|
||||
}
|
||||
lastPreviewRef.current = preview
|
||||
applyDragPreviewTab({
|
||||
worktreeId,
|
||||
groupId: preview.groupId,
|
||||
tabId: preview.tabId,
|
||||
activeGroupId: preview.groupId
|
||||
})
|
||||
},
|
||||
[worktreeId]
|
||||
)
|
||||
|
||||
const updateHoveredDropTargetFromSplit = useCallback(
|
||||
(splitTarget: ActivePaneColumnSplitTarget | null) => {
|
||||
if (!splitTarget) {
|
||||
setHoveredDropTarget((prev) => (prev === null ? prev : null))
|
||||
return
|
||||
}
|
||||
setHoveredDropTarget((prev) => {
|
||||
if (prev?.groupId === splitTarget.groupId && prev?.zone === splitTarget.zone) {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
groupId: splitTarget.groupId,
|
||||
zone: splitTarget.zone,
|
||||
panelRect: splitTarget.panelRect
|
||||
}
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleDragUpdate = useCallback(
|
||||
(event: DragMoveEvent | DragOverEvent) => {
|
||||
const activeData = event.active.data.current
|
||||
if (isTabDragData(activeData) && activeData.worktreeId === worktreeId) {
|
||||
updateDragPreviewActivation(event, activeData)
|
||||
}
|
||||
|
||||
const state = useAppStore.getState()
|
||||
const splitTarget = resolveActivePaneColumnSplitTarget({
|
||||
event,
|
||||
groupsByWorktree: state.groupsByWorktree,
|
||||
layoutByWorktree: state.layoutByWorktree,
|
||||
worktreeId,
|
||||
getDragPointer,
|
||||
geometry: dragGeometryRef.current
|
||||
})
|
||||
updateHoveredDropTargetFromSplit(splitTarget)
|
||||
if (splitTarget) {
|
||||
tabInsertion.clear()
|
||||
} else {
|
||||
tabInsertion.update(event)
|
||||
}
|
||||
},
|
||||
[tabInsertion, updateDragPreviewActivation, updateHoveredDropTargetFromSplit, worktreeId]
|
||||
)
|
||||
|
||||
const onDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const dragData = event.active.data.current
|
||||
@@ -313,6 +354,9 @@ export function useTabDragSplit({
|
||||
}
|
||||
|
||||
setActiveDrag(dragData)
|
||||
tabDragActiveRef.current = true
|
||||
dragGeometryRef.current = captureTabGroupPanelGeometrySnapshot(worktreeId)
|
||||
preDragActivationSnapshotRef.current = captureTabDragActivationSnapshot(worktreeId)
|
||||
acquireWebviewDragPassthrough()
|
||||
},
|
||||
[acquireWebviewDragPassthrough, clearDragState, worktreeId]
|
||||
@@ -320,41 +364,77 @@ export function useTabDragSplit({
|
||||
|
||||
const onDragMove = useCallback(
|
||||
(event: DragMoveEvent) => {
|
||||
updateHoveredPane(event)
|
||||
tabInsertion.update(event)
|
||||
handleDragUpdate(event)
|
||||
},
|
||||
[updateHoveredPane, tabInsertion]
|
||||
[handleDragUpdate]
|
||||
)
|
||||
|
||||
const onDragOver = useCallback(
|
||||
(event: DragOverEvent) => {
|
||||
updateHoveredPane(event)
|
||||
tabInsertion.update(event)
|
||||
},
|
||||
[updateHoveredPane, tabInsertion]
|
||||
)
|
||||
const onDragOver = useCallback((_event: DragOverEvent) => {
|
||||
// Why: onDragMove already carries over + delta; skipping duplicate work here
|
||||
// avoids running split/insertion resolution twice in the same frame.
|
||||
}, [])
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const activeData = event.active.data.current
|
||||
const overData = event.over?.data.current
|
||||
let shouldRestorePreDragActivation = true
|
||||
|
||||
if (!event.over || !isTabDragData(activeData) || activeData.worktreeId !== worktreeId) {
|
||||
clearDragState()
|
||||
if (!isTabDragData(activeData) || activeData.worktreeId !== worktreeId) {
|
||||
finishDrag(true)
|
||||
return
|
||||
}
|
||||
|
||||
const state = useAppStore.getState()
|
||||
const paneColumnSplit = resolveActivePaneColumnSplitTarget({
|
||||
event,
|
||||
groupsByWorktree: state.groupsByWorktree,
|
||||
layoutByWorktree: state.layoutByWorktree,
|
||||
worktreeId,
|
||||
getDragPointer,
|
||||
geometry: dragGeometryRef.current
|
||||
})
|
||||
if (paneColumnSplit) {
|
||||
const moved = dropUnifiedTab(activeData.unifiedTabId, {
|
||||
groupId: paneColumnSplit.groupId,
|
||||
splitDirection: paneColumnSplit.zone
|
||||
})
|
||||
if (moved) {
|
||||
shouldRestorePreDragActivation = false
|
||||
mirrorWebRuntimeTabMove({
|
||||
kind: 'split',
|
||||
worktreeId,
|
||||
tabId: activeData.unifiedTabId,
|
||||
targetGroupId: paneColumnSplit.groupId,
|
||||
splitDirection: paneColumnSplit.zone
|
||||
})
|
||||
}
|
||||
finishDrag(
|
||||
shouldRestorePreDragActivation,
|
||||
resolveSourceGroupRestoreOnDrop(
|
||||
activeData,
|
||||
paneColumnSplit.groupId,
|
||||
shouldRestorePreDragActivation
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.over) {
|
||||
finishDrag(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (isTabDragData(overData)) {
|
||||
if (activeData.unifiedTabId === overData.unifiedTabId) {
|
||||
clearDragState()
|
||||
finishDrag(true)
|
||||
return
|
||||
}
|
||||
|
||||
const state = useAppStore.getState()
|
||||
const groups = state.groupsByWorktree[worktreeId] ?? []
|
||||
const targetGroup = groups.find((group) => group.id === overData.groupId)
|
||||
if (!targetGroup) {
|
||||
clearDragState()
|
||||
finishDrag(true)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -362,9 +442,14 @@ export function useTabDragSplit({
|
||||
// insertion point depends on which side of that tab the cursor sits.
|
||||
// Using the bar's computed side (re-derived here to avoid stale
|
||||
// closures) means the drop always lands where the blue bar was drawn.
|
||||
const insertion = resolveTabInsertion(event, isTabDragData, getDragCenter)
|
||||
const insertion = resolveTabInsertion(event, isTabDragData, getDragPointer)
|
||||
if (!insertion) {
|
||||
finishDrag(true)
|
||||
return
|
||||
}
|
||||
|
||||
const overIndex = targetGroup.tabOrder.indexOf(overData.unifiedTabId)
|
||||
const rawInsertIndex = overIndex + (insertion?.side === 'right' ? 1 : 0)
|
||||
const rawInsertIndex = overIndex + (insertion.side === 'right' ? 1 : 0)
|
||||
|
||||
if (activeData.groupId === overData.groupId) {
|
||||
const oldIndex = targetGroup.tabOrder.indexOf(activeData.unifiedTabId)
|
||||
@@ -391,6 +476,7 @@ export function useTabDragSplit({
|
||||
index
|
||||
})
|
||||
if (moved) {
|
||||
shouldRestorePreDragActivation = false
|
||||
mirrorWebRuntimeTabMove({
|
||||
kind: 'move-to-group',
|
||||
worktreeId,
|
||||
@@ -401,62 +487,46 @@ export function useTabDragSplit({
|
||||
}
|
||||
}
|
||||
|
||||
clearDragState()
|
||||
finishDrag(
|
||||
shouldRestorePreDragActivation,
|
||||
resolveSourceGroupRestoreOnDrop(
|
||||
activeData,
|
||||
overData.groupId,
|
||||
shouldRestorePreDragActivation
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (isPaneDropData(overData)) {
|
||||
if (
|
||||
!canDropTabIntoPaneBody({
|
||||
activeDrag: activeData,
|
||||
groupsByWorktree: useAppStore.getState().groupsByWorktree,
|
||||
overGroupId: overData.groupId,
|
||||
worktreeId
|
||||
if (activeData.groupId !== overData.groupId) {
|
||||
const moved = dropUnifiedTab(activeData.unifiedTabId, {
|
||||
groupId: overData.groupId
|
||||
})
|
||||
) {
|
||||
clearDragState()
|
||||
return
|
||||
}
|
||||
|
||||
const center = getDragCenter(event)
|
||||
if (center) {
|
||||
const zone = resolveDropZone(event.over.rect, center)
|
||||
// Why: a center drop onto the tab's own pane body is a no-op in the
|
||||
// store (non-split same-group drops are ignored), but
|
||||
// canDropTabIntoPaneBody still allows it when the source group has
|
||||
// >1 tab — so the overlay advertises "center" as a valid target.
|
||||
// Skip the call in that case to avoid misleading the user via a
|
||||
// drop that silently does nothing.
|
||||
if (zone !== 'center' || activeData.groupId !== overData.groupId) {
|
||||
const moved = dropUnifiedTab(activeData.unifiedTabId, {
|
||||
groupId: overData.groupId,
|
||||
splitDirection: zone === 'center' ? undefined : zone
|
||||
if (moved) {
|
||||
shouldRestorePreDragActivation = false
|
||||
mirrorWebRuntimeTabMove({
|
||||
kind: 'move-to-group',
|
||||
worktreeId,
|
||||
tabId: activeData.unifiedTabId,
|
||||
targetGroupId: overData.groupId
|
||||
})
|
||||
if (moved) {
|
||||
if (zone === 'center') {
|
||||
mirrorWebRuntimeTabMove({
|
||||
kind: 'move-to-group',
|
||||
worktreeId,
|
||||
tabId: activeData.unifiedTabId,
|
||||
targetGroupId: overData.groupId
|
||||
})
|
||||
} else {
|
||||
mirrorWebRuntimeTabMove({
|
||||
kind: 'split',
|
||||
worktreeId,
|
||||
tabId: activeData.unifiedTabId,
|
||||
targetGroupId: overData.groupId,
|
||||
splitDirection: zone
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearDragState()
|
||||
finishDrag(
|
||||
shouldRestorePreDragActivation,
|
||||
isPaneDropData(overData)
|
||||
? resolveSourceGroupRestoreOnDrop(
|
||||
activeData,
|
||||
overData.groupId,
|
||||
shouldRestorePreDragActivation
|
||||
)
|
||||
: undefined
|
||||
)
|
||||
},
|
||||
[clearDragState, dropUnifiedTab, reorderUnifiedTabs, worktreeId]
|
||||
[dropUnifiedTab, finishDrag, reorderUnifiedTabs, worktreeId]
|
||||
)
|
||||
|
||||
// Why: dnd-kit fires onDragCancel (not onDragEnd) when the user presses
|
||||
@@ -464,14 +534,15 @@ export function useTabDragSplit({
|
||||
// activeDrag and hoveredDropTarget state would remain stale, leaving the
|
||||
// drop overlay visible indefinitely.
|
||||
const onDragCancel = useCallback(() => {
|
||||
clearDragState()
|
||||
}, [clearDragState])
|
||||
finishDrag(true)
|
||||
}, [finishDrag])
|
||||
|
||||
return {
|
||||
activeDrag,
|
||||
collisionDetection,
|
||||
hoveredDropTarget,
|
||||
hoveredTabInsertion: tabInsertion.hoveredTabInsertion,
|
||||
isTabDragActiveRef: tabDragActiveRef,
|
||||
onDragCancel,
|
||||
onDragEnd,
|
||||
onDragMove,
|
||||
|
||||
@@ -214,11 +214,54 @@ describe('useTabGroupWorkspaceModel terminal activation focus', () => {
|
||||
|
||||
expect(mocks.createEmptySplitGroup).toHaveBeenCalledWith('wt-1', 'group-1', 'right')
|
||||
expect(mocks.createTab).toHaveBeenCalledWith('wt-1', 'group-2')
|
||||
expect(mocks.dropUnifiedTab).not.toHaveBeenCalled()
|
||||
expect(mocks.recordFeatureInteraction).toHaveBeenCalledWith('terminal-pane-split')
|
||||
expect(mocks.setActiveTab).toHaveBeenCalledWith('terminal-2')
|
||||
expect(mocks.setActiveTabType).toHaveBeenCalledWith('terminal')
|
||||
})
|
||||
|
||||
it('seeds a new terminal instead of moving the active tab when the group has multiple tabs', async () => {
|
||||
const secondUnifiedTab = {
|
||||
id: 'unified-terminal-2',
|
||||
entityId: 'terminal-2',
|
||||
groupId: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'terminal',
|
||||
label: 'Terminal 2',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 1,
|
||||
createdAt: 1
|
||||
}
|
||||
storeBox.state = {
|
||||
...storeBox.state,
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: secondUnifiedTab.id,
|
||||
tabOrder: ['unified-terminal-1', secondUnifiedTab.id]
|
||||
}
|
||||
]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [...(storeBox.state?.unifiedTabsByWorktree?.['wt-1'] ?? []), secondUnifiedTab]
|
||||
}
|
||||
}
|
||||
mocks.createEmptySplitGroup.mockReturnValue('group-2')
|
||||
mocks.createTab.mockReturnValue({ id: 'terminal-3' })
|
||||
const { useTabGroupWorkspaceModel } = await import('./useTabGroupWorkspaceModel')
|
||||
const model = useTabGroupWorkspaceModel({ groupId: 'group-1', worktreeId: 'wt-1' })
|
||||
|
||||
model.commands.createSplitGroup('right')
|
||||
|
||||
expect(mocks.createEmptySplitGroup).toHaveBeenCalledWith('wt-1', 'group-1', 'right')
|
||||
expect(mocks.createTab).toHaveBeenCalledWith('wt-1', 'group-2')
|
||||
expect(mocks.dropUnifiedTab).not.toHaveBeenCalled()
|
||||
expect(mocks.setActiveTab).toHaveBeenCalledWith('terminal-3')
|
||||
})
|
||||
|
||||
it('closes client-local browser fallback tabs locally in remote workspaces', async () => {
|
||||
mocks.isWebRuntimeSessionActive.mockReturnValue(true)
|
||||
const browserTab = {
|
||||
|
||||
@@ -97,7 +97,6 @@ export function useTabGroupWorkspaceModel({
|
||||
const closeBrowserTab = useAppStore((state) => state.closeBrowserTab)
|
||||
const setActiveBrowserTab = useAppStore((state) => state.setActiveBrowserTab)
|
||||
const setActiveWorktree = useAppStore((state) => state.setActiveWorktree)
|
||||
const dropUnifiedTab = useAppStore((state) => state.dropUnifiedTab)
|
||||
const createEmptySplitGroup = useAppStore((state) => state.createEmptySplitGroup)
|
||||
const setTabCustomTitle = useAppStore((state) => state.setTabCustomTitle)
|
||||
const setTabColor = useAppStore((state) => state.setTabColor)
|
||||
@@ -456,52 +455,25 @@ export function useTabGroupWorkspaceModel({
|
||||
)
|
||||
|
||||
const createSplitGroup = useCallback(
|
||||
(direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId?: string) => {
|
||||
const sourceTab =
|
||||
groupTabs.find((candidate) =>
|
||||
candidate.contentType === 'terminal' || candidate.contentType === 'browser'
|
||||
? candidate.entityId === sourceVisibleTabId
|
||||
: candidate.id === sourceVisibleTabId
|
||||
) ?? activeTab
|
||||
|
||||
(direction: 'left' | 'right' | 'up' | 'down') => {
|
||||
focusGroup(worktreeId, groupId)
|
||||
if (!sourceTab) {
|
||||
const newGroupId = createEmptySplitGroup(worktreeId, groupId, direction)
|
||||
if (!newGroupId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: for terminals specifically, splitting a single-tab group should
|
||||
// still produce a useful split — spawn a fresh terminal in the new pane
|
||||
// and leave the existing one behind. Moving the only tab would collapse
|
||||
// the split immediately (see the same-group guard in dropUnifiedTab),
|
||||
// giving the user nothing; a new terminal preserves the old shortcut
|
||||
// flow without duplicating a persistent tab like editors/browsers would.
|
||||
if (sourceTab.contentType === 'terminal' && groupTabs.length <= 1) {
|
||||
const newGroupId = createEmptySplitGroup(worktreeId, groupId, direction)
|
||||
if (!newGroupId) {
|
||||
return
|
||||
}
|
||||
const terminal = createTab(worktreeId, newGroupId)
|
||||
recordTerminalTabGroupSplit(terminal)
|
||||
setActiveTab(terminal.id)
|
||||
setActiveTabType('terminal')
|
||||
return
|
||||
}
|
||||
|
||||
// Why: split actions MOVE the source tab into the new pane rather than
|
||||
// leaving a duplicate in the origin. Delegating to dropUnifiedTab reuses
|
||||
// the same split+move path as drag-to-split so keyboard/menu splits and
|
||||
// drag splits stay behaviorally identical, including collapsing the
|
||||
// origin group if its last tab is the one we just moved.
|
||||
dropUnifiedTab(sourceTab.id, { groupId, splitDirection: direction })
|
||||
// Why: the tab-strip Split pane control adds a split pane to the right of
|
||||
// the group that owns the button. Dragging tabs can still open other
|
||||
// directions; this entry point always seeds a fresh terminal.
|
||||
const terminal = createTab(worktreeId, newGroupId)
|
||||
recordTerminalTabGroupSplit(terminal)
|
||||
setActiveTab(terminal.id)
|
||||
setActiveTabType('terminal')
|
||||
},
|
||||
[
|
||||
activeTab,
|
||||
createEmptySplitGroup,
|
||||
createTab,
|
||||
dropUnifiedTab,
|
||||
focusGroup,
|
||||
groupId,
|
||||
groupTabs,
|
||||
setActiveTab,
|
||||
setActiveTabType,
|
||||
worktreeId
|
||||
|
||||
@@ -3,11 +3,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
import { X } from 'lucide-react'
|
||||
import { useAppStore } from '../../store'
|
||||
import { isUnifiedTabPinned } from '@/store/pinned-tab-close-guard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useLinkRoutingPreferenceDialog } from '@/components/link-routing-preference-dialog'
|
||||
import { DaemonActionDialog, useDaemonActions } from '@/components/shared/useDaemonActions'
|
||||
import {
|
||||
@@ -41,6 +38,8 @@ import { MobileDriverOverlay } from './MobileDriverOverlay'
|
||||
import { TerminalErrorToast } from './TerminalErrorToast'
|
||||
import { TerminalSessionStateSaveFailureDialog } from './TerminalSessionStateSaveFailureDialog'
|
||||
import TerminalContextMenu from './TerminalContextMenu'
|
||||
import TerminalPaneHeaderOverlay from './TerminalPaneHeaderOverlay'
|
||||
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
|
||||
import { TerminalAgentSessionForkDialog } from './TerminalAgentSessionForkDialog'
|
||||
import { SessionRestoredBannerPortals } from './SessionRestoredBannerPortals'
|
||||
import { useSessionRestoredBannerDismiss } from './useSessionRestoredBannerDismiss'
|
||||
@@ -140,7 +139,6 @@ import {
|
||||
import { getCachedTerminalTabForWorktree } from './terminal-tab-lookup'
|
||||
import { getCachedTerminalGroupIdForWorktree } from './terminal-unified-tab-lookup'
|
||||
import { useRepoById } from '@/store/selectors'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type TerminalPaneProps = {
|
||||
tabId: string
|
||||
@@ -1764,12 +1762,22 @@ export default function TerminalPane({
|
||||
panes: manager.getPanes(),
|
||||
paneTitles,
|
||||
renamingPaneId,
|
||||
sessionRestoredBannerPaneIds
|
||||
sessionRestoredBannerPaneIds,
|
||||
reservePaneHeaderSpace: isActive && (isVisible || shouldMeasureHiddenStartup)
|
||||
})
|
||||
if (needsFit) {
|
||||
fitPanes(manager)
|
||||
}
|
||||
}, [paneCount, paneLayoutRevision, paneTitles, renamingPaneId, sessionRestoredBannerPaneIds])
|
||||
}, [
|
||||
paneCount,
|
||||
paneLayoutRevision,
|
||||
paneTitles,
|
||||
renamingPaneId,
|
||||
sessionRestoredBannerPaneIds,
|
||||
isActive,
|
||||
isVisible,
|
||||
shouldMeasureHiddenStartup
|
||||
])
|
||||
|
||||
const syncPaneTitleOverlayRects = useCallback((): void => {
|
||||
const manager = managerRef.current
|
||||
@@ -2300,6 +2308,33 @@ export default function TerminalPane({
|
||||
managerRef.current?.setActivePane(paneId, { focus: false })
|
||||
}, [])
|
||||
|
||||
const splitTerminalPaneFromHeader = useCallback(
|
||||
(pane: ManagedPane, direction: 'vertical' | 'horizontal') => {
|
||||
const manager = managerRef.current
|
||||
if (!manager) {
|
||||
return
|
||||
}
|
||||
splitTerminalPaneWithInheritedCwd({
|
||||
manager,
|
||||
getManager: () => managerRef.current,
|
||||
paneTransports: paneTransportsRef.current,
|
||||
paneCwdMap: paneCwdRef.current,
|
||||
fallbackCwd: cwd ?? '',
|
||||
pane,
|
||||
direction,
|
||||
source: 'context_menu'
|
||||
})
|
||||
},
|
||||
[cwd]
|
||||
)
|
||||
|
||||
const beginPaneDragFromHeader = useCallback(
|
||||
(paneId: number, handle: HTMLElement, event: PointerEvent) => {
|
||||
managerRef.current?.beginPaneDragFromPointerDown(paneId, handle, event)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const effectiveAppearance = settings
|
||||
? resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
|
||||
: null
|
||||
@@ -2339,13 +2374,13 @@ export default function TerminalPane({
|
||||
}
|
||||
|
||||
const activePane = managerRef.current?.getActivePane()
|
||||
const managedPanes = managerRef.current?.getPanes() ?? []
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={setContainerRef}
|
||||
className="absolute inset-0 min-h-0 min-w-0"
|
||||
data-native-file-drop-target="terminal"
|
||||
data-contextual-tour-target="terminal-pane-split-target"
|
||||
data-terminal-tab-id={tabId}
|
||||
data-pane-title-surface={titleUsesLightSurface ? 'light' : 'dark'}
|
||||
style={terminalContainerStyle}
|
||||
@@ -2465,159 +2500,38 @@ export default function TerminalPane({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* Title bars live in Orca-owned React DOM rather than xterm's pane
|
||||
subtree. The pane still reserves title height for terminal layout,
|
||||
while editor controls stay outside xterm's helper textarea focus zone. */}
|
||||
<div
|
||||
className="pane-title-overlay-layer"
|
||||
data-pane-title-surface={titleUsesLightSurface ? 'light' : 'dark'}
|
||||
style={{
|
||||
display: terminalContentVisible ? undefined : 'none',
|
||||
['--orca-pane-title-bg' as string]: paneTitleBackground,
|
||||
...hiddenStartupStyle
|
||||
}}
|
||||
>
|
||||
{(managerRef.current?.getPanes() ?? []).map((pane) => {
|
||||
const title = paneTitles[pane.id]
|
||||
const isEditing = renamingPaneId === pane.id
|
||||
const overlayRect = paneTitleOverlayRects[pane.id]
|
||||
if ((!title && !isEditing) || !overlayRect) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`pane-title-${pane.leafId}`}
|
||||
className="pane-title-bar"
|
||||
data-native-file-drop-target="terminal"
|
||||
data-terminal-tab-id={tabId}
|
||||
data-pane-prevent-terminal-focus=""
|
||||
{...(isEditing ? { 'data-editing': '' } : {})}
|
||||
onPointerDownCapture={() => activatePaneTitleInteraction(pane.id)}
|
||||
onDragOver={(event) => {
|
||||
activatePaneTitleInteraction(pane.id)
|
||||
if (
|
||||
event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) ||
|
||||
event.dataTransfer.types.includes(WORKSPACE_FILE_PATHS_MIME)
|
||||
) {
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (
|
||||
!event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) &&
|
||||
!event.dataTransfer.types.includes(WORKSPACE_FILE_PATHS_MIME)
|
||||
) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
activatePaneTitleInteraction(pane.id)
|
||||
const manager = managerRef.current
|
||||
if (!manager) {
|
||||
return
|
||||
}
|
||||
void handleInternalTerminalFileDrop({
|
||||
manager,
|
||||
paneTransports: paneTransportsRef.current,
|
||||
worktreeId,
|
||||
tabId,
|
||||
cwd,
|
||||
dataTransfer: event.dataTransfer,
|
||||
dropTarget: event.target
|
||||
})
|
||||
}}
|
||||
onContextMenuCapture={(event) => contextMenu.onPaneTitleContextMenu(event, pane.id)}
|
||||
style={{
|
||||
left: overlayRect.left,
|
||||
top: overlayRect.top,
|
||||
width: overlayRect.width
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
className="pane-title-input"
|
||||
aria-label={translate(
|
||||
'auto.components.terminal.pane.TerminalPane.7dbbfcbecc',
|
||||
'Pane title'
|
||||
)}
|
||||
placeholder={translate(
|
||||
'auto.components.terminal.pane.TerminalPane.7dbbfcbecc',
|
||||
'Pane title'
|
||||
)}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleRenameSubmit()
|
||||
} else if (e.key === 'Escape') {
|
||||
handleRenameCancel()
|
||||
}
|
||||
}}
|
||||
onBlur={handleRenameBlur}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{paneCount > 1 && (
|
||||
<div
|
||||
className="pane-title-drag-handle"
|
||||
aria-hidden="true"
|
||||
onPointerDown={(event) => {
|
||||
managerRef.current?.beginPaneDragFromPointerDown(
|
||||
pane.id,
|
||||
event.currentTarget,
|
||||
event.nativeEvent
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="pane-title-text"
|
||||
onClick={() => handleStartRename(pane.id)}
|
||||
aria-label={translate(
|
||||
'auto.components.terminal.pane.TerminalPane.cc5a2dc706',
|
||||
'Edit pane title: {{value0}}',
|
||||
{ value0: title }
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="pane-title-close"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemoveTitle(pane.id)
|
||||
}}
|
||||
aria-label={translate(
|
||||
'auto.components.terminal.pane.TerminalPane.f984ab2a30',
|
||||
'Remove pane title: {{value0}}',
|
||||
{ value0: title }
|
||||
)}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.terminal.pane.TerminalPane.ac112e9036',
|
||||
'Remove title'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{(managerRef.current?.getPanes() ?? []).map((pane) => {
|
||||
<TerminalPaneHeaderOverlay
|
||||
tabId={tabId}
|
||||
worktreeId={worktreeId}
|
||||
cwd={cwd ?? ''}
|
||||
showAlwaysOnHeaders={isActive && terminalContentVisible}
|
||||
paneCount={paneCount}
|
||||
activePaneId={activePane?.id}
|
||||
panes={managedPanes}
|
||||
paneTitles={paneTitles}
|
||||
paneTitleOverlayRects={paneTitleOverlayRects}
|
||||
renamingPaneId={renamingPaneId}
|
||||
renameValue={renameValue}
|
||||
renameInputRef={renameInputRef}
|
||||
titleUsesLightSurface={titleUsesLightSurface}
|
||||
paneTitleBackground={paneTitleBackground}
|
||||
terminalContentVisible={terminalContentVisible}
|
||||
hiddenStartupStyle={hiddenStartupStyle}
|
||||
managerRef={managerRef}
|
||||
paneTransportsRef={paneTransportsRef}
|
||||
onSplitPane={splitTerminalPaneFromHeader}
|
||||
onBeginPaneDrag={beginPaneDragFromHeader}
|
||||
onActivatePaneTitleInteraction={activatePaneTitleInteraction}
|
||||
onPaneTitleContextMenu={contextMenu.onPaneTitleContextMenu}
|
||||
onStartRename={handleStartRename}
|
||||
onRemoveTitle={handleRemoveTitle}
|
||||
onClosePane={handleRequestClosePane}
|
||||
onRenameValueChange={setRenameValue}
|
||||
onRenameSubmit={handleRenameSubmit}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
onRenameBlur={handleRenameBlur}
|
||||
/>
|
||||
{managedPanes.map((pane) => {
|
||||
// Why: pane IDs can collide across tabs (e.g. tab 0 pane 1 and tab 1
|
||||
// pane 1). Using the transport's actual ptyId avoids showing banners
|
||||
// on the wrong pane when IDs overlap.
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { act, createRef, type ReactNode, type RefObject } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import TerminalPaneHeaderOverlay from './TerminalPaneHeaderOverlay'
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children?: ReactNode }) => children,
|
||||
TooltipTrigger: ({ children }: { children?: ReactNode }) => children,
|
||||
TooltipContent: ({ children }: { children?: ReactNode }) => <span>{children}</span>
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
|
||||
Object.entries(values ?? {}).reduce(
|
||||
(text, [key, value]) => text.replace(`{{${key}}}`, value),
|
||||
fallback
|
||||
)
|
||||
}))
|
||||
|
||||
const mounted: { container: HTMLDivElement; root: Root }[] = []
|
||||
|
||||
function makePane(id: number): ManagedPane {
|
||||
const leafId = `leaf-${id}` as ManagedPane['leafId']
|
||||
return {
|
||||
id,
|
||||
leafId,
|
||||
stablePaneId: leafId,
|
||||
container: document.createElement('div'),
|
||||
linkTooltip: document.createElement('div'),
|
||||
terminal: {} as ManagedPane['terminal'],
|
||||
fitAddon: {} as ManagedPane['fitAddon'],
|
||||
searchAddon: {} as ManagedPane['searchAddon'],
|
||||
serializeAddon: {} as ManagedPane['serializeAddon']
|
||||
}
|
||||
}
|
||||
|
||||
function renderOverlay({
|
||||
paneTitles,
|
||||
paneCount = 2,
|
||||
showAlwaysOnHeaders = true,
|
||||
onClosePane = vi.fn(),
|
||||
onRemoveTitle = vi.fn()
|
||||
}: {
|
||||
paneTitles: Record<number, string>
|
||||
paneCount?: number
|
||||
showAlwaysOnHeaders?: boolean
|
||||
onClosePane?: ReturnType<typeof vi.fn>
|
||||
onRemoveTitle?: ReturnType<typeof vi.fn>
|
||||
}): {
|
||||
container: HTMLDivElement
|
||||
onClosePane: ReturnType<typeof vi.fn>
|
||||
onRemoveTitle: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const panes = [makePane(1), makePane(2)]
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(
|
||||
<TerminalPaneHeaderOverlay
|
||||
tabId="tab-1"
|
||||
worktreeId="wt-1"
|
||||
cwd={path.join(path.sep, 'tmp')}
|
||||
showAlwaysOnHeaders={showAlwaysOnHeaders}
|
||||
paneCount={paneCount}
|
||||
activePaneId={1}
|
||||
panes={panes}
|
||||
paneTitles={paneTitles}
|
||||
paneTitleOverlayRects={{
|
||||
1: { left: 0, top: 0, width: 200 },
|
||||
2: { left: 220, top: 0, width: 200 }
|
||||
}}
|
||||
renamingPaneId={null}
|
||||
renameValue=""
|
||||
renameInputRef={createRef<HTMLInputElement>()}
|
||||
titleUsesLightSurface={false}
|
||||
paneTitleBackground="transparent"
|
||||
terminalContentVisible
|
||||
hiddenStartupStyle={{}}
|
||||
managerRef={{ current: null } as RefObject<PaneManager | null>}
|
||||
paneTransportsRef={{ current: new Map() } as RefObject<Map<number, PtyTransport>>}
|
||||
onSplitPane={vi.fn()}
|
||||
onBeginPaneDrag={vi.fn()}
|
||||
onActivatePaneTitleInteraction={vi.fn()}
|
||||
onPaneTitleContextMenu={vi.fn()}
|
||||
onStartRename={vi.fn()}
|
||||
onRemoveTitle={onRemoveTitle as (paneId: number) => void}
|
||||
onClosePane={onClosePane as (paneId: number) => void}
|
||||
onRenameValueChange={vi.fn()}
|
||||
onRenameSubmit={vi.fn()}
|
||||
onRenameCancel={vi.fn()}
|
||||
onRenameBlur={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
mounted.push({ container, root })
|
||||
return { container, onClosePane, onRemoveTitle }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { container, root } of mounted.splice(0)) {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
}
|
||||
})
|
||||
|
||||
describe('TerminalPaneHeaderOverlay', () => {
|
||||
it('keeps the titled-pane close affordance as remove-title while headers are always on', () => {
|
||||
const { container, onClosePane, onRemoveTitle } = renderOverlay({
|
||||
paneTitles: { 1: 'server', 2: '' }
|
||||
})
|
||||
|
||||
const removeTitle = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Remove pane title: server"]'
|
||||
)
|
||||
expect(removeTitle).not.toBeNull()
|
||||
|
||||
act(() => removeTitle?.click())
|
||||
|
||||
expect(onRemoveTitle).toHaveBeenCalledWith(1)
|
||||
expect(onClosePane).not.toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('keeps close-pane available for untitled split pane headers', () => {
|
||||
const { container, onClosePane, onRemoveTitle } = renderOverlay({
|
||||
paneTitles: { 1: '', 2: '' }
|
||||
})
|
||||
|
||||
const closePane = container.querySelector<HTMLButtonElement>('button[aria-label="Close Pane"]')
|
||||
expect(closePane).not.toBeNull()
|
||||
|
||||
act(() => closePane?.click())
|
||||
|
||||
expect(onClosePane).toHaveBeenCalledWith(1)
|
||||
expect(onRemoveTitle).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,298 @@
|
||||
import type { CSSProperties, RefObject } from 'react'
|
||||
import { SquareSplitVertical, X } from 'lucide-react'
|
||||
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import { handleInternalTerminalFileDrop } from './terminal-drop-handler'
|
||||
|
||||
export type PaneTitleOverlayRect = {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
}
|
||||
|
||||
type TerminalPaneHeaderOverlayProps = {
|
||||
tabId: string
|
||||
worktreeId: string
|
||||
cwd: string
|
||||
showAlwaysOnHeaders: boolean
|
||||
paneCount: number
|
||||
activePaneId: number | null | undefined
|
||||
panes: readonly ManagedPane[]
|
||||
paneTitles: Readonly<Record<number, string>>
|
||||
paneTitleOverlayRects: Readonly<Record<number, PaneTitleOverlayRect>>
|
||||
renamingPaneId: number | null
|
||||
renameValue: string
|
||||
renameInputRef: RefObject<HTMLInputElement | null>
|
||||
titleUsesLightSurface: boolean
|
||||
paneTitleBackground: string
|
||||
terminalContentVisible: boolean
|
||||
hiddenStartupStyle: CSSProperties
|
||||
managerRef: RefObject<PaneManager | null>
|
||||
paneTransportsRef: RefObject<Map<number, PtyTransport>>
|
||||
onSplitPane: (pane: ManagedPane, direction: 'vertical' | 'horizontal') => void
|
||||
onBeginPaneDrag: (paneId: number, handle: HTMLElement, event: PointerEvent) => void
|
||||
onActivatePaneTitleInteraction: (paneId: number) => void
|
||||
onPaneTitleContextMenu: (event: React.MouseEvent<HTMLElement>, paneId: number) => void
|
||||
onStartRename: (paneId: number) => void
|
||||
onRemoveTitle: (paneId: number) => void
|
||||
onClosePane: (paneId: number) => void
|
||||
onRenameValueChange: (value: string) => void
|
||||
onRenameSubmit: () => void
|
||||
onRenameCancel: () => void
|
||||
onRenameBlur: () => void
|
||||
}
|
||||
|
||||
export default function TerminalPaneHeaderOverlay({
|
||||
tabId,
|
||||
worktreeId,
|
||||
cwd,
|
||||
showAlwaysOnHeaders,
|
||||
paneCount,
|
||||
activePaneId,
|
||||
panes,
|
||||
paneTitles,
|
||||
paneTitleOverlayRects,
|
||||
renamingPaneId,
|
||||
renameValue,
|
||||
renameInputRef,
|
||||
titleUsesLightSurface,
|
||||
paneTitleBackground,
|
||||
terminalContentVisible,
|
||||
hiddenStartupStyle,
|
||||
managerRef,
|
||||
paneTransportsRef,
|
||||
onSplitPane,
|
||||
onBeginPaneDrag,
|
||||
onActivatePaneTitleInteraction,
|
||||
onPaneTitleContextMenu,
|
||||
onStartRename,
|
||||
onRemoveTitle,
|
||||
onClosePane,
|
||||
onRenameValueChange,
|
||||
onRenameSubmit,
|
||||
onRenameCancel,
|
||||
onRenameBlur
|
||||
}: TerminalPaneHeaderOverlayProps): React.JSX.Element {
|
||||
const splitRightLabel = translate(
|
||||
'auto.components.terminal.pane.TerminalContextMenu.20e565d865',
|
||||
'Split Terminal Right'
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pane-title-overlay-layer"
|
||||
data-pane-title-surface={titleUsesLightSurface ? 'light' : 'dark'}
|
||||
style={{
|
||||
display: terminalContentVisible ? undefined : 'none',
|
||||
['--orca-pane-title-bg' as string]: paneTitleBackground,
|
||||
...hiddenStartupStyle
|
||||
}}
|
||||
>
|
||||
{panes.map((pane) => {
|
||||
const title = paneTitles[pane.id]
|
||||
const isEditing = renamingPaneId === pane.id
|
||||
const overlayRect = paneTitleOverlayRects[pane.id]
|
||||
const isActivePane = activePaneId === pane.id
|
||||
const isChromeless = showAlwaysOnHeaders && !title && !isEditing
|
||||
const showHeader = overlayRect && (showAlwaysOnHeaders || Boolean(title) || isEditing)
|
||||
if (!showHeader || !overlayRect) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`pane-title-${pane.leafId}`}
|
||||
className="pane-title-bar"
|
||||
data-native-file-drop-target="terminal"
|
||||
data-terminal-tab-id={tabId}
|
||||
data-pane-prevent-terminal-focus=""
|
||||
{...(isActivePane ? { 'data-active-pane': '' } : {})}
|
||||
{...(isChromeless ? { 'data-chromeless': '' } : {})}
|
||||
{...(isEditing ? { 'data-editing': '' } : {})}
|
||||
onPointerDownCapture={
|
||||
title || isEditing ? () => onActivatePaneTitleInteraction(pane.id) : undefined
|
||||
}
|
||||
onDragOver={(event) => {
|
||||
onActivatePaneTitleInteraction(pane.id)
|
||||
if (
|
||||
event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) ||
|
||||
event.dataTransfer.types.includes(WORKSPACE_FILE_PATHS_MIME)
|
||||
) {
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (
|
||||
!event.dataTransfer.types.includes(WORKSPACE_FILE_PATH_MIME) &&
|
||||
!event.dataTransfer.types.includes(WORKSPACE_FILE_PATHS_MIME)
|
||||
) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onActivatePaneTitleInteraction(pane.id)
|
||||
const manager = managerRef.current
|
||||
if (!manager) {
|
||||
return
|
||||
}
|
||||
void handleInternalTerminalFileDrop({
|
||||
manager,
|
||||
paneTransports: paneTransportsRef.current,
|
||||
worktreeId,
|
||||
tabId,
|
||||
cwd,
|
||||
dataTransfer: event.dataTransfer,
|
||||
dropTarget: event.target
|
||||
})
|
||||
}}
|
||||
onContextMenuCapture={(event) => onPaneTitleContextMenu(event, pane.id)}
|
||||
style={{
|
||||
left: overlayRect.left,
|
||||
top: overlayRect.top,
|
||||
width: overlayRect.width
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
className="pane-title-input"
|
||||
aria-label={translate(
|
||||
'auto.components.terminal.pane.TerminalPane.7dbbfcbecc',
|
||||
'Pane title'
|
||||
)}
|
||||
placeholder={translate(
|
||||
'auto.components.terminal.pane.TerminalPane.7dbbfcbecc',
|
||||
'Pane title'
|
||||
)}
|
||||
value={renameValue}
|
||||
onChange={(event) => onRenameValueChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
onRenameSubmit()
|
||||
} else if (event.key === 'Escape') {
|
||||
onRenameCancel()
|
||||
}
|
||||
}}
|
||||
onBlur={onRenameBlur}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{paneCount > 1 && (
|
||||
<div
|
||||
className="pane-title-drag-handle"
|
||||
aria-hidden="true"
|
||||
onPointerDown={(event) => {
|
||||
onBeginPaneDrag(pane.id, event.currentTarget, event.nativeEvent)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{title ? (
|
||||
<button
|
||||
type="button"
|
||||
className="pane-title-text"
|
||||
onClick={() => onStartRename(pane.id)}
|
||||
aria-label={translate(
|
||||
'auto.components.terminal.pane.TerminalPane.cc5a2dc706',
|
||||
'Edit pane title: {{value0}}',
|
||||
{ value0: title }
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="pane-title-actions ml-auto flex shrink-0 items-center gap-0.5">
|
||||
{showAlwaysOnHeaders ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="pane-title-split-trigger"
|
||||
data-contextual-tour-target={
|
||||
isActivePane ? 'terminal-pane-split-target' : undefined
|
||||
}
|
||||
aria-label={splitRightLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onSplitPane(pane, 'vertical')
|
||||
}}
|
||||
>
|
||||
<SquareSplitVertical className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{splitRightLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{title ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="pane-title-close"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onRemoveTitle(pane.id)
|
||||
}}
|
||||
aria-label={translate(
|
||||
'auto.components.terminal.pane.TerminalPane.f984ab2a30',
|
||||
'Remove pane title: {{value0}}',
|
||||
{ value0: title }
|
||||
)}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.terminal.pane.TerminalPane.ac112e9036',
|
||||
'Remove title'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : paneCount > 1 && showAlwaysOnHeaders ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="pane-title-close"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onClosePane(pane.id)
|
||||
}}
|
||||
aria-label={translate(
|
||||
'auto.components.terminal.pane.TerminalContextMenu.8c17d6786d',
|
||||
'Close Pane'
|
||||
)}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate(
|
||||
'auto.components.terminal.pane.TerminalContextMenu.8c17d6786d',
|
||||
'Close Pane'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,13 +12,13 @@ import {
|
||||
type KeybindingPlatform,
|
||||
type TerminalShortcutPolicy
|
||||
} from '../../../../shared/keybindings'
|
||||
import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd'
|
||||
import { type PaneCwdMap } from './resolve-split-cwd'
|
||||
import { keyboardEventBelongsToScope } from './terminal-keyboard-scope'
|
||||
import { normalizeSelectedTextForFileSearch } from '@/lib/file-search-selection'
|
||||
import { isFindQueryTooLarge } from '@/lib/find-query-bounds'
|
||||
import { splitWebRuntimeTerminal } from '@/runtime/web-runtime-session'
|
||||
import { handleEmptyFloatingWorkspacePanelCloseShortcut } from '@/lib/floating-workspace-terminal-actions'
|
||||
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
|
||||
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
|
||||
import { useAppStore } from '@/store'
|
||||
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
|
||||
|
||||
@@ -411,41 +411,16 @@ export function useTerminalKeyboardShortcuts({
|
||||
if (!pane) {
|
||||
return
|
||||
}
|
||||
const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null
|
||||
const telemetrySource = getKeyboardSplitTelemetrySource()
|
||||
if (splitWebRuntimeTerminal(ptyId, action.direction, telemetrySource)) {
|
||||
return
|
||||
}
|
||||
// Split-pane CWD inheritance (docs/ssh-split-pane-inherit-cwd.md):
|
||||
// if we have a confirmed live OSC 7 for the source pane, split
|
||||
// synchronously to preserve chaining on rapid Cmd+D. Otherwise fall
|
||||
// back to an async resolve that queries pty.getCwd.
|
||||
const cached = paneCwdRef.current.get(pane.id)
|
||||
if (cached?.confirmed && cached.cwd) {
|
||||
const createdPane = manager.splitPane(pane.id, action.direction, { cwd: cached.cwd })
|
||||
recordKeyboardCreatedTerminalPaneSplit(createdPane, {
|
||||
source: telemetrySource,
|
||||
direction: action.direction
|
||||
})
|
||||
return
|
||||
}
|
||||
const paneIdAtDispatch = pane.id
|
||||
const directionAtDispatch = action.direction
|
||||
void (async () => {
|
||||
const cwd = await resolveSplitCwd({
|
||||
paneCwdMap: paneCwdRef.current,
|
||||
sourcePaneId: paneIdAtDispatch,
|
||||
sourcePtyId: ptyId,
|
||||
fallbackCwd
|
||||
})
|
||||
const createdPane = managerRef.current?.splitPane(paneIdAtDispatch, directionAtDispatch, {
|
||||
cwd
|
||||
})
|
||||
recordKeyboardCreatedTerminalPaneSplit(createdPane, {
|
||||
source: telemetrySource,
|
||||
direction: directionAtDispatch
|
||||
})
|
||||
})()
|
||||
splitTerminalPaneWithInheritedCwd({
|
||||
manager,
|
||||
getManager: () => managerRef.current,
|
||||
paneTransports: paneTransportsRef.current,
|
||||
paneCwdMap: paneCwdRef.current,
|
||||
fallbackCwd,
|
||||
pane,
|
||||
direction: action.direction,
|
||||
source: getKeyboardSplitTelemetrySource()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,23 @@ describe('session restored banner pane state', () => {
|
||||
expect(paneText(createdPane)).toBe(SESSION_RESTORED_BANNER_TEXT)
|
||||
})
|
||||
|
||||
it('reserves title space for always-on pane headers on the active tab', () => {
|
||||
const activePane = createPane(1)
|
||||
const secondPane = createPane(2)
|
||||
|
||||
const needsFit = syncSessionRestoredBannerTitleSpace({
|
||||
panes: [activePane, secondPane],
|
||||
paneTitles: {},
|
||||
renamingPaneId: null,
|
||||
sessionRestoredBannerPaneIds: new Set(),
|
||||
reservePaneHeaderSpace: true
|
||||
})
|
||||
|
||||
expect(needsFit).toBe(true)
|
||||
expect(activePane.container.hasAttribute('data-has-title')).toBe(true)
|
||||
expect(secondPane.container.hasAttribute('data-has-title')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders and reserves title space only on the restored inactive split pane', async () => {
|
||||
const activePane = createPane(1)
|
||||
const inactiveRestoredPane = createPane(2)
|
||||
|
||||
@@ -88,10 +88,12 @@ export function syncSessionRestoredBannerTitleSpace(args: {
|
||||
paneTitles: Readonly<Record<number, string>>
|
||||
renamingPaneId: number | null
|
||||
sessionRestoredBannerPaneIds: ReadonlySet<number>
|
||||
reservePaneHeaderSpace?: boolean
|
||||
}): boolean {
|
||||
let needsFit = false
|
||||
for (const pane of args.panes) {
|
||||
const shouldShow =
|
||||
args.reservePaneHeaderSpace === true ||
|
||||
!!args.paneTitles[pane.id] ||
|
||||
args.renamingPaneId === pane.id ||
|
||||
args.sessionRestoredBannerPaneIds.has(pane.id)
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
recordCreatedTerminalPaneSplit: vi.fn(),
|
||||
resolveSplitCwd: vi.fn(),
|
||||
splitWebRuntimeTerminal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/web-runtime-session', () => ({
|
||||
splitWebRuntimeTerminal: mocks.splitWebRuntimeTerminal
|
||||
}))
|
||||
|
||||
vi.mock('./resolve-split-cwd', () => ({
|
||||
resolveSplitCwd: mocks.resolveSplitCwd
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-pane-split-completion', () => ({
|
||||
recordCreatedTerminalPaneSplit: mocks.recordCreatedTerminalPaneSplit
|
||||
}))
|
||||
|
||||
function makeManager(splitPane: ReturnType<typeof vi.fn>): PaneManager {
|
||||
return { splitPane } as unknown as PaneManager
|
||||
}
|
||||
|
||||
async function flushAsyncSplit(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('splitTerminalPaneWithInheritedCwd', () => {
|
||||
beforeEach(() => {
|
||||
mocks.recordCreatedTerminalPaneSplit.mockReset()
|
||||
mocks.resolveSplitCwd.mockReset()
|
||||
mocks.splitWebRuntimeTerminal.mockReset()
|
||||
mocks.splitWebRuntimeTerminal.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('uses the live manager after async cwd resolution', async () => {
|
||||
const staleSplitPane = vi.fn()
|
||||
const liveSplitPane = vi.fn(() => ({ id: 2 }))
|
||||
mocks.resolveSplitCwd.mockResolvedValue('/resolved')
|
||||
|
||||
splitTerminalPaneWithInheritedCwd({
|
||||
manager: makeManager(staleSplitPane),
|
||||
getManager: () => makeManager(liveSplitPane),
|
||||
paneTransports: new Map<number, PtyTransport>(),
|
||||
paneCwdMap: new Map(),
|
||||
fallbackCwd: '/fallback',
|
||||
pane: { id: 1 } as ManagedPane,
|
||||
direction: 'vertical',
|
||||
source: 'context_menu'
|
||||
})
|
||||
|
||||
await flushAsyncSplit()
|
||||
|
||||
expect(staleSplitPane).not.toHaveBeenCalled()
|
||||
expect(liveSplitPane).toHaveBeenCalledWith(1, 'vertical', { cwd: '/resolved' })
|
||||
expect(mocks.recordCreatedTerminalPaneSplit).toHaveBeenCalledWith(
|
||||
{ id: 2 },
|
||||
{ source: 'context_menu', direction: 'vertical' }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not split a stale manager when the live manager is gone', async () => {
|
||||
const staleSplitPane = vi.fn()
|
||||
mocks.resolveSplitCwd.mockResolvedValue('/resolved')
|
||||
|
||||
splitTerminalPaneWithInheritedCwd({
|
||||
manager: makeManager(staleSplitPane),
|
||||
getManager: () => null,
|
||||
paneTransports: new Map<number, PtyTransport>(),
|
||||
paneCwdMap: new Map(),
|
||||
fallbackCwd: '/fallback',
|
||||
pane: { id: 1 } as ManagedPane,
|
||||
direction: 'horizontal',
|
||||
source: 'context_menu'
|
||||
})
|
||||
|
||||
await flushAsyncSplit()
|
||||
|
||||
expect(staleSplitPane).not.toHaveBeenCalled()
|
||||
expect(mocks.recordCreatedTerminalPaneSplit).toHaveBeenCalledWith(undefined, {
|
||||
source: 'context_menu',
|
||||
direction: 'horizontal'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { TerminalPaneSplitSource } from '../../../../shared/feature-education-telemetry'
|
||||
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { splitWebRuntimeTerminal } from '@/runtime/web-runtime-session'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd'
|
||||
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
|
||||
|
||||
export function splitTerminalPaneWithInheritedCwd(args: {
|
||||
manager: PaneManager
|
||||
getManager?: () => PaneManager | null
|
||||
paneTransports: Map<number, PtyTransport>
|
||||
paneCwdMap: PaneCwdMap
|
||||
fallbackCwd: string
|
||||
pane: ManagedPane
|
||||
direction: 'vertical' | 'horizontal'
|
||||
source: TerminalPaneSplitSource
|
||||
}): void {
|
||||
const ptyId = args.paneTransports.get(args.pane.id)?.getPtyId() ?? null
|
||||
if (splitWebRuntimeTerminal(ptyId, args.direction, args.source)) {
|
||||
return
|
||||
}
|
||||
const cached = args.paneCwdMap.get(args.pane.id)
|
||||
if (cached?.confirmed && cached.cwd) {
|
||||
const createdPane = args.manager.splitPane(args.pane.id, args.direction, { cwd: cached.cwd })
|
||||
recordCreatedTerminalPaneSplit(createdPane, {
|
||||
source: args.source,
|
||||
direction: args.direction
|
||||
})
|
||||
return
|
||||
}
|
||||
const paneId = args.pane.id
|
||||
const resolveManager = (): PaneManager | null =>
|
||||
args.getManager ? args.getManager() : args.manager
|
||||
void (async () => {
|
||||
const cwd = await resolveSplitCwd({
|
||||
paneCwdMap: args.paneCwdMap,
|
||||
sourcePaneId: paneId,
|
||||
sourcePtyId: ptyId,
|
||||
fallbackCwd: args.fallbackCwd
|
||||
})
|
||||
const createdPane = resolveManager()?.splitPane(paneId, args.direction, { cwd })
|
||||
recordCreatedTerminalPaneSplit(createdPane, {
|
||||
source: args.source,
|
||||
direction: args.direction
|
||||
})
|
||||
})()
|
||||
}
|
||||
@@ -5,11 +5,10 @@ import { toast } from 'sonner'
|
||||
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd'
|
||||
import type { PaneCwdMap } from './resolve-split-cwd'
|
||||
import type { TerminalQuickCommand } from '../../../../shared/types'
|
||||
import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands'
|
||||
import { sendTerminalQuickCommandToPane } from './terminal-quick-command-dispatch'
|
||||
import { splitWebRuntimeTerminal } from '@/runtime/web-runtime-session'
|
||||
import { pasteTerminalText } from './terminal-bracketed-paste'
|
||||
import { pasteTerminalClipboard } from './terminal-clipboard-paste'
|
||||
import {
|
||||
@@ -35,6 +34,7 @@ import {
|
||||
type PreparedAgentSessionFork
|
||||
} from './terminal-agent-session-fork'
|
||||
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
|
||||
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
|
||||
@@ -310,39 +310,26 @@ export function useTerminalPaneContextMenu({
|
||||
|
||||
const onPaste = async (): Promise<void> => pasteResolvedPane('context-menu')
|
||||
|
||||
// Split-pane CWD inheritance (docs/ssh-split-pane-inherit-cwd.md):
|
||||
// mirror the Cmd+D path — sync split on confirmed OSC 7 cache hit,
|
||||
// otherwise fall back to async resolveSplitCwd.
|
||||
const splitWithInheritedCwd = useCallback(
|
||||
(
|
||||
direction: 'vertical' | 'horizontal',
|
||||
source: 'contextual_tour' | 'context_menu' = 'context_menu'
|
||||
): void => {
|
||||
const pane = resolveMenuPane()
|
||||
if (!pane) {
|
||||
const manager = managerRef.current
|
||||
if (!pane || !manager) {
|
||||
return
|
||||
}
|
||||
const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null
|
||||
if (splitWebRuntimeTerminal(ptyId, direction, source)) {
|
||||
return
|
||||
}
|
||||
const cached = paneCwdRef.current.get(pane.id)
|
||||
if (cached?.confirmed && cached.cwd) {
|
||||
const createdPane = managerRef.current?.splitPane(pane.id, direction, { cwd: cached.cwd })
|
||||
recordContextMenuCreatedTerminalPaneSplit(createdPane, { source, direction })
|
||||
return
|
||||
}
|
||||
const paneId = pane.id
|
||||
void (async () => {
|
||||
const cwd = await resolveSplitCwd({
|
||||
paneCwdMap: paneCwdRef.current,
|
||||
sourcePaneId: paneId,
|
||||
sourcePtyId: ptyId,
|
||||
fallbackCwd
|
||||
})
|
||||
const createdPane = managerRef.current?.splitPane(paneId, direction, { cwd })
|
||||
recordContextMenuCreatedTerminalPaneSplit(createdPane, { source, direction })
|
||||
})()
|
||||
splitTerminalPaneWithInheritedCwd({
|
||||
manager,
|
||||
getManager: () => managerRef.current,
|
||||
paneTransports: paneTransportsRef.current,
|
||||
paneCwdMap: paneCwdRef.current,
|
||||
fallbackCwd,
|
||||
pane,
|
||||
direction,
|
||||
source
|
||||
})
|
||||
},
|
||||
[fallbackCwd, managerRef, paneCwdRef, paneTransportsRef, resolveMenuPane]
|
||||
)
|
||||
|
||||
@@ -2436,7 +2436,9 @@
|
||||
"9acaf92093": "Pane Actions",
|
||||
"1bce81dba6": "simulator",
|
||||
"1ff1c77616": "browser",
|
||||
"586d2ac445": "terminal"
|
||||
"586d2ac445": "terminal",
|
||||
"addSplitPane": "Add split pane",
|
||||
"closePaneColumn": "Close split pane"
|
||||
},
|
||||
"AiVaultSessionDropLayer": {
|
||||
"dropOntoTerminalPane": "Drop onto a terminal pane to resume this session.",
|
||||
@@ -2444,6 +2446,9 @@
|
||||
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
|
||||
"openLocalWorkspace": "Open a local workspace before resuming a session.",
|
||||
"sessionQueued": "Session queued"
|
||||
},
|
||||
"TabGroupDropOverlay": {
|
||||
"paneColumnLabel": "New split"
|
||||
}
|
||||
},
|
||||
"bar": {
|
||||
@@ -2514,7 +2519,9 @@
|
||||
"cb3eadefd2": "Blue",
|
||||
"20baa43c05": "None",
|
||||
"60f958ec75": "Pin Tab",
|
||||
"417722e9c2": "Unpin Tab"
|
||||
"417722e9c2": "Unpin Tab",
|
||||
"splitTerminalRight": "Split terminal right",
|
||||
"splitTerminalDown": "Split terminal down"
|
||||
},
|
||||
"TabBar": {
|
||||
"b1a132357f": "New tab",
|
||||
@@ -2606,6 +2613,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TabWorkspaceLayoutMenuSection": {
|
||||
"right": "Right",
|
||||
"left": "Left",
|
||||
"down": "Down",
|
||||
"up": "Up",
|
||||
"moveToPaneColumn": "Move Tab to Split"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2436,7 +2436,9 @@
|
||||
"9acaf92093": "Acciones del panel",
|
||||
"1bce81dba6": "simulador",
|
||||
"1ff1c77616": "navegador",
|
||||
"586d2ac445": "terminal"
|
||||
"586d2ac445": "terminal",
|
||||
"addSplitPane": "Agregar panel dividido",
|
||||
"closePaneColumn": "Cerrar panel dividido"
|
||||
},
|
||||
"AiVaultSessionDropLayer": {
|
||||
"dropOntoTerminalPane": "Drop onto a terminal pane to resume this session.",
|
||||
@@ -2444,6 +2446,9 @@
|
||||
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
|
||||
"openLocalWorkspace": "Open a local workspace before resuming a session.",
|
||||
"sessionQueued": "Session queued"
|
||||
},
|
||||
"TabGroupDropOverlay": {
|
||||
"paneColumnLabel": "Nueva división"
|
||||
}
|
||||
},
|
||||
"bar": {
|
||||
@@ -2514,7 +2519,9 @@
|
||||
"cb3eadefd2": "Azul",
|
||||
"20baa43c05": "Ninguno",
|
||||
"60f958ec75": "Pestaña Fijar",
|
||||
"417722e9c2": "Desanclar pestaña"
|
||||
"417722e9c2": "Desanclar pestaña",
|
||||
"splitTerminalRight": "Dividir terminal a la derecha",
|
||||
"splitTerminalDown": "Dividir terminal hacia abajo"
|
||||
},
|
||||
"TabBar": {
|
||||
"b1a132357f": "Nueva pestaña",
|
||||
@@ -2606,6 +2613,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TabWorkspaceLayoutMenuSection": {
|
||||
"right": "Derecha",
|
||||
"left": "Izquierda",
|
||||
"down": "Abajo",
|
||||
"up": "Arriba",
|
||||
"moveToPaneColumn": "Mover pestaña a división"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -9127,15 +9141,15 @@
|
||||
"monthsAgo": "{{value0}}mo ago",
|
||||
"yearsAgo": "{{value0}}y ago",
|
||||
"sessionId": "ID de sesión",
|
||||
"originalAsk": "Original ask",
|
||||
"latestTurns": "Latest turns",
|
||||
"noPreviewAvailable": "No conversation preview available",
|
||||
"messageCount": "{{value0}} msgs",
|
||||
"userRole": "You",
|
||||
"agentRole": "Agent",
|
||||
"toolRole": "Tool",
|
||||
"systemRole": "System",
|
||||
"sessionRole": "Session"
|
||||
"originalAsk": "Solicitud original",
|
||||
"latestTurns": "Últimos turnos",
|
||||
"noPreviewAvailable": "No hay vista previa de conversación disponible",
|
||||
"messageCount": "{{value0}} mensajes",
|
||||
"userRole": "Tú",
|
||||
"agentRole": "Agente",
|
||||
"toolRole": "Herramienta",
|
||||
"systemRole": "Sistema",
|
||||
"sessionRole": "Sesión"
|
||||
},
|
||||
"AiVaultSessionRow": {
|
||||
"resumeAgentSession": "Resume {{value0}} session",
|
||||
@@ -9153,13 +9167,13 @@
|
||||
"showDetails": "Mostrar detalles",
|
||||
"moreSessionActions": "Más acciones de sesión",
|
||||
"moreActions": "Más acciones",
|
||||
"noPreviewAvailable": "No conversation preview available",
|
||||
"dragToResume": "Drag to resume in a new tab",
|
||||
"userRole": "You",
|
||||
"agentRole": "Agent",
|
||||
"toolRole": "Tool",
|
||||
"systemRole": "System",
|
||||
"sessionRole": "Session"
|
||||
"noPreviewAvailable": "No hay vista previa de conversación disponible",
|
||||
"dragToResume": "Arrastra para reanudar en una pestaña nueva",
|
||||
"userRole": "Tú",
|
||||
"agentRole": "Agente",
|
||||
"toolRole": "Herramienta",
|
||||
"systemRole": "Sistema",
|
||||
"sessionRole": "Sesión"
|
||||
},
|
||||
"FileExplorerNameFilter": {
|
||||
"26fb73c6e3": "Buscar archivos",
|
||||
|
||||
@@ -2436,7 +2436,9 @@
|
||||
"9acaf92093": "ペインの操作",
|
||||
"1bce81dba6": "シミュレータ",
|
||||
"1ff1c77616": "ブラウザ",
|
||||
"586d2ac445": "terminal"
|
||||
"586d2ac445": "terminal",
|
||||
"addSplitPane": "分割ペインを追加",
|
||||
"closePaneColumn": "分割ペインを閉じる"
|
||||
},
|
||||
"AiVaultSessionDropLayer": {
|
||||
"dropOntoTerminalPane": "Drop onto a terminal pane to resume this session.",
|
||||
@@ -2444,6 +2446,9 @@
|
||||
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
|
||||
"openLocalWorkspace": "Open a local workspace before resuming a session.",
|
||||
"sessionQueued": "Session queued"
|
||||
},
|
||||
"TabGroupDropOverlay": {
|
||||
"paneColumnLabel": "新しい分割"
|
||||
}
|
||||
},
|
||||
"bar": {
|
||||
@@ -2514,7 +2519,9 @@
|
||||
"cb3eadefd2": "青",
|
||||
"20baa43c05": "なし",
|
||||
"60f958ec75": "ピンタブ",
|
||||
"417722e9c2": "タブの固定を解除する"
|
||||
"417722e9c2": "タブの固定を解除する",
|
||||
"splitTerminalRight": "ターミナルを右に分割",
|
||||
"splitTerminalDown": "ターミナルを下に分割"
|
||||
},
|
||||
"TabBar": {
|
||||
"b1a132357f": "新規タブ",
|
||||
@@ -2606,6 +2613,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TabWorkspaceLayoutMenuSection": {
|
||||
"right": "右",
|
||||
"left": "左",
|
||||
"down": "下",
|
||||
"up": "上",
|
||||
"moveToPaneColumn": "タブを分割へ移動"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -9127,15 +9141,15 @@
|
||||
"monthsAgo": "{{value0}}mo ago",
|
||||
"yearsAgo": "{{value0}}y ago",
|
||||
"sessionId": "セッション ID",
|
||||
"originalAsk": "Original ask",
|
||||
"latestTurns": "Latest turns",
|
||||
"noPreviewAvailable": "No conversation preview available",
|
||||
"messageCount": "{{value0}} msgs",
|
||||
"userRole": "You",
|
||||
"agentRole": "Agent",
|
||||
"toolRole": "Tool",
|
||||
"systemRole": "System",
|
||||
"sessionRole": "Session"
|
||||
"originalAsk": "最初の依頼",
|
||||
"latestTurns": "最新のやり取り",
|
||||
"noPreviewAvailable": "会話プレビューはありません",
|
||||
"messageCount": "{{value0}} 件のメッセージ",
|
||||
"userRole": "あなた",
|
||||
"agentRole": "エージェント",
|
||||
"toolRole": "ツール",
|
||||
"systemRole": "システム",
|
||||
"sessionRole": "セッション"
|
||||
},
|
||||
"AiVaultSessionRow": {
|
||||
"resumeAgentSession": "Resume {{value0}} session",
|
||||
@@ -9153,13 +9167,13 @@
|
||||
"showDetails": "詳細を表示",
|
||||
"moreSessionActions": "セッションのその他の操作",
|
||||
"moreActions": "その他の操作",
|
||||
"noPreviewAvailable": "No conversation preview available",
|
||||
"dragToResume": "Drag to resume in a new tab",
|
||||
"userRole": "You",
|
||||
"agentRole": "Agent",
|
||||
"toolRole": "Tool",
|
||||
"systemRole": "System",
|
||||
"sessionRole": "Session"
|
||||
"noPreviewAvailable": "会話プレビューはありません",
|
||||
"dragToResume": "新しいタブで再開するにはドラッグ",
|
||||
"userRole": "あなた",
|
||||
"agentRole": "エージェント",
|
||||
"toolRole": "ツール",
|
||||
"systemRole": "システム",
|
||||
"sessionRole": "セッション"
|
||||
},
|
||||
"FileExplorerNameFilter": {
|
||||
"26fb73c6e3": "ファイルを検索",
|
||||
|
||||
@@ -2436,7 +2436,9 @@
|
||||
"9acaf92093": "창 작업",
|
||||
"1bce81dba6": "시뮬레이터",
|
||||
"1ff1c77616": "브라우저",
|
||||
"586d2ac445": "terminal"
|
||||
"586d2ac445": "terminal",
|
||||
"addSplitPane": "분할 창 추가",
|
||||
"closePaneColumn": "분할 창 닫기"
|
||||
},
|
||||
"AiVaultSessionDropLayer": {
|
||||
"dropOntoTerminalPane": "이 세션을 재개하려면 terminal 창에 놓으세요.",
|
||||
@@ -2444,6 +2446,9 @@
|
||||
"localWorkspacesOnly": "기록에서 재개하기는 로컬 워크스페이스에서만 사용할 수 있습니다.",
|
||||
"openLocalWorkspace": "세션을 재개하기 전에 로컬 워크스페이스를 여세요.",
|
||||
"sessionQueued": "세션이 대기열에 추가됨"
|
||||
},
|
||||
"TabGroupDropOverlay": {
|
||||
"paneColumnLabel": "새 분할"
|
||||
}
|
||||
},
|
||||
"bar": {
|
||||
@@ -2514,7 +2519,9 @@
|
||||
"cb3eadefd2": "파란색",
|
||||
"20baa43c05": "없음",
|
||||
"60f958ec75": "탭 고정",
|
||||
"417722e9c2": "탭 고정 해제"
|
||||
"417722e9c2": "탭 고정 해제",
|
||||
"splitTerminalRight": "터미널을 오른쪽으로 분할",
|
||||
"splitTerminalDown": "터미널을 아래로 분할"
|
||||
},
|
||||
"TabBar": {
|
||||
"b1a132357f": "새 탭",
|
||||
@@ -2606,6 +2613,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TabWorkspaceLayoutMenuSection": {
|
||||
"right": "오른쪽",
|
||||
"left": "왼쪽",
|
||||
"down": "아래",
|
||||
"up": "위",
|
||||
"moveToPaneColumn": "탭을 분할로 이동"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -9127,15 +9141,15 @@
|
||||
"monthsAgo": "{{value0}}개월 전",
|
||||
"yearsAgo": "{{value0}}년 전",
|
||||
"sessionId": "세션 ID",
|
||||
"originalAsk": "Original ask",
|
||||
"latestTurns": "Latest turns",
|
||||
"noPreviewAvailable": "No conversation preview available",
|
||||
"messageCount": "{{value0}} msgs",
|
||||
"userRole": "You",
|
||||
"agentRole": "Agent",
|
||||
"toolRole": "Tool",
|
||||
"systemRole": "System",
|
||||
"sessionRole": "Session"
|
||||
"originalAsk": "원래 요청",
|
||||
"latestTurns": "최근 대화",
|
||||
"noPreviewAvailable": "대화 미리보기를 사용할 수 없습니다",
|
||||
"messageCount": "메시지 {{value0}}개",
|
||||
"userRole": "나",
|
||||
"agentRole": "에이전트",
|
||||
"toolRole": "도구",
|
||||
"systemRole": "시스템",
|
||||
"sessionRole": "세션"
|
||||
},
|
||||
"AiVaultSessionRow": {
|
||||
"resumeAgentSession": "{{value0}} 세션 재개",
|
||||
@@ -9153,13 +9167,13 @@
|
||||
"showDetails": "세부정보 표시",
|
||||
"moreSessionActions": "세션 추가 작업",
|
||||
"moreActions": "추가 작업",
|
||||
"noPreviewAvailable": "No conversation preview available",
|
||||
"dragToResume": "Drag to resume in a new tab",
|
||||
"userRole": "You",
|
||||
"agentRole": "Agent",
|
||||
"toolRole": "Tool",
|
||||
"systemRole": "System",
|
||||
"sessionRole": "Session"
|
||||
"noPreviewAvailable": "대화 미리보기를 사용할 수 없습니다",
|
||||
"dragToResume": "새 탭에서 재개하려면 드래그",
|
||||
"userRole": "나",
|
||||
"agentRole": "에이전트",
|
||||
"toolRole": "도구",
|
||||
"systemRole": "시스템",
|
||||
"sessionRole": "세션"
|
||||
},
|
||||
"FileExplorerNameFilter": {
|
||||
"26fb73c6e3": "파일 찾기",
|
||||
|
||||
@@ -2436,7 +2436,9 @@
|
||||
"9acaf92093": "窗格操作",
|
||||
"1bce81dba6": "模拟器",
|
||||
"1ff1c77616": "浏览器",
|
||||
"586d2ac445": "terminal"
|
||||
"586d2ac445": "terminal",
|
||||
"addSplitPane": "添加拆分窗格",
|
||||
"closePaneColumn": "关闭拆分窗格"
|
||||
},
|
||||
"AiVaultSessionDropLayer": {
|
||||
"dropOntoTerminalPane": "拖放到 terminal 面板以恢复此会话。",
|
||||
@@ -2444,6 +2446,9 @@
|
||||
"localWorkspacesOnly": "从历史恢复仅支持本地工作区。",
|
||||
"openLocalWorkspace": "恢复会话前请先打开一个本地工作区。",
|
||||
"sessionQueued": "会话已排队"
|
||||
},
|
||||
"TabGroupDropOverlay": {
|
||||
"paneColumnLabel": "新建拆分"
|
||||
}
|
||||
},
|
||||
"bar": {
|
||||
@@ -2514,7 +2519,9 @@
|
||||
"cb3eadefd2": "Blue",
|
||||
"20baa43c05": "没有任何",
|
||||
"60f958ec75": "引脚标签",
|
||||
"417722e9c2": "取消固定标签"
|
||||
"417722e9c2": "取消固定标签",
|
||||
"splitTerminalRight": "向右拆分终端",
|
||||
"splitTerminalDown": "向下拆分终端"
|
||||
},
|
||||
"TabBar": {
|
||||
"b1a132357f": "新标签页",
|
||||
@@ -2606,6 +2613,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TabWorkspaceLayoutMenuSection": {
|
||||
"right": "右",
|
||||
"left": "左",
|
||||
"down": "下",
|
||||
"up": "上",
|
||||
"moveToPaneColumn": "将标签页移至拆分"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -9127,15 +9141,15 @@
|
||||
"monthsAgo": "{{value0}} 个月前",
|
||||
"yearsAgo": "{{value0}} 年前",
|
||||
"sessionId": "会话 ID",
|
||||
"originalAsk": "Original ask",
|
||||
"latestTurns": "Latest turns",
|
||||
"noPreviewAvailable": "No conversation preview available",
|
||||
"messageCount": "{{value0}} msgs",
|
||||
"userRole": "You",
|
||||
"agentRole": "Agent",
|
||||
"toolRole": "Tool",
|
||||
"systemRole": "System",
|
||||
"sessionRole": "Session"
|
||||
"originalAsk": "原始请求",
|
||||
"latestTurns": "最近轮次",
|
||||
"noPreviewAvailable": "没有可用的对话预览",
|
||||
"messageCount": "{{value0}} 条消息",
|
||||
"userRole": "你",
|
||||
"agentRole": "智能体",
|
||||
"toolRole": "工具",
|
||||
"systemRole": "系统",
|
||||
"sessionRole": "会话"
|
||||
},
|
||||
"AiVaultSessionRow": {
|
||||
"resumeAgentSession": "恢复 {{value0}} 会话",
|
||||
@@ -9153,13 +9167,13 @@
|
||||
"showDetails": "显示详情",
|
||||
"moreSessionActions": "更多会话操作",
|
||||
"moreActions": "更多操作",
|
||||
"noPreviewAvailable": "No conversation preview available",
|
||||
"dragToResume": "Drag to resume in a new tab",
|
||||
"userRole": "You",
|
||||
"agentRole": "Agent",
|
||||
"toolRole": "Tool",
|
||||
"systemRole": "System",
|
||||
"sessionRole": "Session"
|
||||
"noPreviewAvailable": "没有可用的对话预览",
|
||||
"dragToResume": "拖动以在新标签页中恢复",
|
||||
"userRole": "你",
|
||||
"agentRole": "智能体",
|
||||
"toolRole": "工具",
|
||||
"systemRole": "系统",
|
||||
"sessionRole": "会话"
|
||||
},
|
||||
"FileExplorerNameFilter": {
|
||||
"26fb73c6e3": "查找文件",
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { DropZone, ManagedPaneInternal } from './pane-manager-types'
|
||||
import type { DragReorderCallbacks, DragReorderState } from './pane-drag-reorder'
|
||||
import { handlePaneDrop, hideDropOverlay, showDropOverlay } from './pane-drag-reorder'
|
||||
import {
|
||||
handlePaneDrop,
|
||||
hideDropOverlay,
|
||||
isPaneDropNoOp,
|
||||
showDropOverlay
|
||||
} from './pane-drag-reorder'
|
||||
|
||||
const DRAG_THRESHOLD = 5
|
||||
|
||||
@@ -161,6 +166,15 @@ function updateDropTarget(
|
||||
|
||||
const rect = targetPane.container.getBoundingClientRect()
|
||||
const zone = resolveDropZone(clientX, clientY, rect)
|
||||
const sourcePaneId = state.dragSourcePaneId
|
||||
if (
|
||||
sourcePaneId !== null &&
|
||||
isPaneDropNoOp(sourcePaneId, targetPane.id, zone, callbacks.getPanes())
|
||||
) {
|
||||
overlay.style.display = 'none'
|
||||
state.currentDropTarget = null
|
||||
return
|
||||
}
|
||||
state.currentDropTarget = { paneId: targetPane.id, zone }
|
||||
positionDropOverlay(overlay, rect, zone)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import type * as PaneTreeOpsModule from './pane-tree-ops'
|
||||
import { attachPaneDrag } from './pane-drag-pointer'
|
||||
import { createDragReorderState } from './pane-drag-reorder'
|
||||
import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
@@ -7,10 +8,14 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
const detachPaneFromTree = vi.hoisted(() => vi.fn())
|
||||
const insertPaneNextTo = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./pane-tree-ops', () => ({
|
||||
detachPaneFromTree,
|
||||
insertPaneNextTo
|
||||
}))
|
||||
vi.mock('./pane-tree-ops', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof PaneTreeOpsModule>()
|
||||
return {
|
||||
...actual,
|
||||
detachPaneFromTree,
|
||||
insertPaneNextTo
|
||||
}
|
||||
})
|
||||
|
||||
type FakeListener = (event: PointerEvent) => void
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DropZone, ManagedPane, ManagedPaneInternal } from './pane-manager-types'
|
||||
import type { PaneStyleOptions } from './pane-manager-types'
|
||||
import { detachPaneFromTree, insertPaneNextTo } from './pane-tree-ops'
|
||||
import { detachPaneFromTree, findPaneChildren, insertPaneNextTo } from './pane-tree-ops'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drag-to-reorder panes
|
||||
@@ -46,6 +46,47 @@ export function cancelActivePaneDrag(state: DragReorderState): void {
|
||||
state.currentDropTarget = null
|
||||
}
|
||||
|
||||
/** True when dropping source onto target in zone would leave pane order unchanged. */
|
||||
export function isPaneDropNoOp(
|
||||
sourcePaneId: number,
|
||||
targetPaneId: number,
|
||||
zone: DropZone,
|
||||
panes: Map<number, ManagedPaneInternal>
|
||||
): boolean {
|
||||
if (sourcePaneId === targetPaneId) {
|
||||
return true
|
||||
}
|
||||
const source = panes.get(sourcePaneId)
|
||||
const target = panes.get(targetPaneId)
|
||||
if (!source || !target) {
|
||||
return true
|
||||
}
|
||||
|
||||
const parent = target.container.parentElement
|
||||
if (!parent?.classList.contains('pane-split')) {
|
||||
return false
|
||||
}
|
||||
if (source.container.parentElement !== parent) {
|
||||
return false
|
||||
}
|
||||
|
||||
const children = findPaneChildren(parent)
|
||||
const targetIndex = children.indexOf(target.container)
|
||||
const sourceIndex = children.indexOf(source.container)
|
||||
if (targetIndex === -1 || sourceIndex === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isVerticalSplit = parent.classList.contains('is-vertical')
|
||||
const zoneIsHorizontal = zone === 'top' || zone === 'bottom'
|
||||
if (zoneIsHorizontal === isVerticalSplit) {
|
||||
return false
|
||||
}
|
||||
return zone === 'right' || zone === 'bottom'
|
||||
? sourceIndex === targetIndex + 1
|
||||
: sourceIndex === targetIndex - 1
|
||||
}
|
||||
|
||||
/** Move a pane from its current position to a new position relative to a target pane. */
|
||||
export function handlePaneDrop(
|
||||
sourcePaneId: number,
|
||||
@@ -58,6 +99,9 @@ export function handlePaneDrop(
|
||||
return
|
||||
}
|
||||
const panes = callbacks.getPanes()
|
||||
if (isPaneDropNoOp(sourcePaneId, targetPaneId, zone, panes)) {
|
||||
return
|
||||
}
|
||||
const source = panes.get(sourcePaneId)
|
||||
const target = panes.get(targetPaneId)
|
||||
if (!source || !target) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import { isPaneDropNoOp } from './pane-drag-reorder'
|
||||
import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
|
||||
function makeLeafId(id: number): TerminalLeafId {
|
||||
return `${id}${id}${id}${id}${id}${id}${id}${id}-${id}${id}${id}${id}-4${id}${id}${id}-8${id}${id}${id}-${id}${id}${id}${id}${id}${id}${id}${id}${id}${id}${id}${id}` as TerminalLeafId
|
||||
}
|
||||
|
||||
function createPane(id: number, container: HTMLElement): ManagedPaneInternal {
|
||||
const leafId = makeLeafId(id)
|
||||
container.classList.add('pane')
|
||||
container.dataset.paneId = String(id)
|
||||
container.dataset.leafId = leafId
|
||||
return {
|
||||
id,
|
||||
leafId,
|
||||
stablePaneId: leafId,
|
||||
terminal: {} as never,
|
||||
container,
|
||||
xtermContainer: {} as never,
|
||||
linkTooltip: {} as never,
|
||||
terminalGpuAcceleration: 'auto',
|
||||
gpuRenderingEnabled: true,
|
||||
webglAttachmentDeferred: false,
|
||||
webglDisabledAfterContextLoss: false,
|
||||
hasComplexScriptOutput: false,
|
||||
webglAddon: null,
|
||||
ligaturesAddon: null,
|
||||
fitResizeObserver: null,
|
||||
pendingObservedFitRafId: null,
|
||||
fitAddon: {} as never,
|
||||
searchAddon: {} as never,
|
||||
serializeAddon: {} as never,
|
||||
unicode11Addon: {} as never,
|
||||
webLinksAddon: {} as never,
|
||||
compositionHandler: null,
|
||||
pendingSplitScrollState: null,
|
||||
debugLabel: null
|
||||
}
|
||||
}
|
||||
|
||||
function createVerticalSplit(paneIds: readonly number[]): Map<number, ManagedPaneInternal> {
|
||||
const split = document.createElement('div')
|
||||
split.className = 'pane-split is-vertical'
|
||||
document.body.appendChild(split)
|
||||
|
||||
const panes = new Map<number, ManagedPaneInternal>()
|
||||
for (const id of paneIds) {
|
||||
const container = document.createElement('div')
|
||||
split.appendChild(container)
|
||||
panes.set(id, createPane(id, container))
|
||||
}
|
||||
return panes
|
||||
}
|
||||
|
||||
describe('isPaneDropNoOp', () => {
|
||||
it('treats dropping an already-right sibling onto the left pane as a no-op', () => {
|
||||
const panes = createVerticalSplit([1, 2])
|
||||
expect(isPaneDropNoOp(2, 1, 'right', panes)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats dropping an already-left sibling onto the right pane as a no-op', () => {
|
||||
const panes = createVerticalSplit([1, 2])
|
||||
expect(isPaneDropNoOp(1, 2, 'left', panes)).toBe(true)
|
||||
})
|
||||
|
||||
it('allows reordering when a third pane sits between source and target', () => {
|
||||
const panes = createVerticalSplit([1, 2, 3])
|
||||
expect(isPaneDropNoOp(3, 1, 'right', panes)).toBe(false)
|
||||
})
|
||||
|
||||
it('allows swapping adjacent vertical panes via the opposite edge', () => {
|
||||
const panes = createVerticalSplit([1, 2])
|
||||
expect(isPaneDropNoOp(2, 1, 'left', panes)).toBe(false)
|
||||
expect(isPaneDropNoOp(1, 2, 'right', panes)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TabGroupLayoutNode } from '../../../../shared/types'
|
||||
import {
|
||||
findLayoutSiblingOnSplitSide,
|
||||
isPaneColumnSplitDropNoOp
|
||||
} from './pane-column-split-drop-no-op'
|
||||
|
||||
function horizontalSplit(firstGroupId: string, secondGroupId: string): TabGroupLayoutNode {
|
||||
return {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
ratio: 0.5,
|
||||
first: { type: 'leaf', groupId: firstGroupId },
|
||||
second: { type: 'leaf', groupId: secondGroupId }
|
||||
}
|
||||
}
|
||||
|
||||
describe('findLayoutSiblingOnSplitSide', () => {
|
||||
const layout = horizontalSplit('group-left', 'group-right')
|
||||
|
||||
it('finds the right sibling when splitting the left column to the right', () => {
|
||||
expect(findLayoutSiblingOnSplitSide(layout, 'group-left', 'right')).toBe('group-right')
|
||||
})
|
||||
|
||||
it('finds the left sibling when splitting the right column to the left', () => {
|
||||
expect(findLayoutSiblingOnSplitSide(layout, 'group-right', 'left')).toBe('group-left')
|
||||
})
|
||||
|
||||
it('returns null for split directions that would not target the adjacent sibling', () => {
|
||||
expect(findLayoutSiblingOnSplitSide(layout, 'group-left', 'left')).toBeNull()
|
||||
expect(findLayoutSiblingOnSplitSide(layout, 'group-right', 'right')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPaneColumnSplitDropNoOp', () => {
|
||||
const layout = horizontalSplit('group-left', 'group-right')
|
||||
|
||||
it('treats splitting the only tab in a group onto itself as a no-op', () => {
|
||||
expect(
|
||||
isPaneColumnSplitDropNoOp({
|
||||
sourceGroupId: 'group-left',
|
||||
targetGroupId: 'group-left',
|
||||
splitDirection: 'right',
|
||||
sourceTabCount: 1,
|
||||
layout
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('treats dragging the only tab onto the adjacent sibling edge as a no-op', () => {
|
||||
expect(
|
||||
isPaneColumnSplitDropNoOp({
|
||||
sourceGroupId: 'group-right',
|
||||
targetGroupId: 'group-left',
|
||||
splitDirection: 'right',
|
||||
sourceTabCount: 1,
|
||||
layout
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isPaneColumnSplitDropNoOp({
|
||||
sourceGroupId: 'group-left',
|
||||
targetGroupId: 'group-right',
|
||||
splitDirection: 'left',
|
||||
sourceTabCount: 1,
|
||||
layout
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('allows splits when the source group would still have other tabs', () => {
|
||||
expect(
|
||||
isPaneColumnSplitDropNoOp({
|
||||
sourceGroupId: 'group-right',
|
||||
targetGroupId: 'group-left',
|
||||
splitDirection: 'right',
|
||||
sourceTabCount: 2,
|
||||
layout
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { TabGroupLayoutNode } from '../../../../shared/types'
|
||||
import type { TabSplitDirection } from './tabs'
|
||||
|
||||
function getDirectLayoutSiblingOnSplitSide(
|
||||
split: Extract<TabGroupLayoutNode, { type: 'split' }>,
|
||||
targetGroupId: string,
|
||||
splitDirection: TabSplitDirection
|
||||
): string | null {
|
||||
const { first, second, direction } = split
|
||||
|
||||
if (first.type === 'leaf' && first.groupId === targetGroupId) {
|
||||
if (direction === 'horizontal' && splitDirection === 'right' && second.type === 'leaf') {
|
||||
return second.groupId
|
||||
}
|
||||
if (direction === 'vertical' && splitDirection === 'down' && second.type === 'leaf') {
|
||||
return second.groupId
|
||||
}
|
||||
}
|
||||
|
||||
if (second.type === 'leaf' && second.groupId === targetGroupId) {
|
||||
if (direction === 'horizontal' && splitDirection === 'left' && first.type === 'leaf') {
|
||||
return first.groupId
|
||||
}
|
||||
if (direction === 'vertical' && splitDirection === 'up' && first.type === 'leaf') {
|
||||
return first.groupId
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function findLayoutSiblingOnSplitSide(
|
||||
root: TabGroupLayoutNode,
|
||||
targetGroupId: string,
|
||||
splitDirection: TabSplitDirection
|
||||
): string | null {
|
||||
if (root.type === 'leaf') {
|
||||
return null
|
||||
}
|
||||
|
||||
const directSibling = getDirectLayoutSiblingOnSplitSide(root, targetGroupId, splitDirection)
|
||||
if (directSibling) {
|
||||
return directSibling
|
||||
}
|
||||
|
||||
return (
|
||||
findLayoutSiblingOnSplitSide(root.first, targetGroupId, splitDirection) ??
|
||||
findLayoutSiblingOnSplitSide(root.second, targetGroupId, splitDirection)
|
||||
)
|
||||
}
|
||||
|
||||
/** True when a pane-column split drop would collapse back to the current layout. */
|
||||
export function isPaneColumnSplitDropNoOp(args: {
|
||||
sourceGroupId: string
|
||||
targetGroupId: string
|
||||
splitDirection: TabSplitDirection
|
||||
sourceTabCount: number
|
||||
layout: TabGroupLayoutNode | undefined
|
||||
}): boolean {
|
||||
if (args.sourceGroupId === args.targetGroupId && args.sourceTabCount <= 1) {
|
||||
return true
|
||||
}
|
||||
if (args.sourceTabCount !== 1 || !args.layout) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
findLayoutSiblingOnSplitSide(args.layout, args.targetGroupId, args.splitDirection) ===
|
||||
args.sourceGroupId
|
||||
)
|
||||
}
|
||||
@@ -975,9 +975,7 @@ describe('TabsSlice', () => {
|
||||
const state = store.getState()
|
||||
const moved = state.unifiedTabsByWorktree[WT].find((item) => item.id === tab.id)
|
||||
expect(moved?.groupId).toBe(targetGroupId)
|
||||
expect(
|
||||
state.groupsByWorktree[WT].find((group) => group.id === sourceGroupId)?.tabOrder
|
||||
).toEqual([])
|
||||
expect(state.groupsByWorktree[WT].find((group) => group.id === sourceGroupId)).toBeUndefined()
|
||||
expect(
|
||||
state.groupsByWorktree[WT].find((group) => group.id === targetGroupId)?.tabOrder
|
||||
).toEqual([tab.id])
|
||||
@@ -1200,6 +1198,40 @@ describe('TabsSlice', () => {
|
||||
expect(state.groupsByWorktree[WT][0].tabOrder).toEqual([onlyTab.id])
|
||||
expect(state.layoutByWorktree[WT]).toEqual({ type: 'leaf', groupId: sourceGroupId })
|
||||
})
|
||||
|
||||
it('treats splitting the only tab onto the adjacent sibling edge as a no-op', () => {
|
||||
store.getState().createUnifiedTab(WT, 'editor', {
|
||||
id: 'file-a.ts',
|
||||
label: 'file-a.ts'
|
||||
})
|
||||
const right = store.getState().createUnifiedTab(WT, 'terminal', {
|
||||
id: 'terminal-1',
|
||||
label: 'Terminal 1'
|
||||
})
|
||||
const leftGroupId = store.getState().groupsByWorktree[WT][0].id
|
||||
|
||||
expect(
|
||||
store.getState().dropUnifiedTab(right.id, {
|
||||
groupId: leftGroupId,
|
||||
splitDirection: 'right'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
const rightGroupId = store
|
||||
.getState()
|
||||
.unifiedTabsByWorktree[WT].find((tab) => tab.id === right.id)?.groupId
|
||||
expect(rightGroupId).toBeTruthy()
|
||||
|
||||
const moved = store.getState().dropUnifiedTab(right.id, {
|
||||
groupId: leftGroupId,
|
||||
splitDirection: 'right'
|
||||
})
|
||||
|
||||
expect(moved).toBe(false)
|
||||
expect(
|
||||
store.getState().unifiedTabsByWorktree[WT].find((tab) => tab.id === right.id)?.groupId
|
||||
).toBe(rightGroupId)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── setTabLabel / setTabCustomLabel / setUnifiedTabColor ─────────
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
sanitizeRecentTabIds,
|
||||
updateGroup
|
||||
} from './tab-group-state'
|
||||
import { isPaneColumnSplitDropNoOp } from './pane-column-split-drop-no-op'
|
||||
import { buildHydratedTabState, pruneTabGroupLayoutForGroups } from './tabs-hydration'
|
||||
import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
@@ -1378,18 +1379,49 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
}
|
||||
return group
|
||||
})
|
||||
let nextLayoutByWorktree = state.layoutByWorktree
|
||||
let nextActiveGroupIdByWorktreeResolved = nextActiveGroupIdByWorktree
|
||||
let filteredGroups = nextGroups
|
||||
if (sourceOrder.length === 0) {
|
||||
filteredGroups = nextGroups.filter((group) => group.id !== sourceGroup.id)
|
||||
const collapsedState = collapseGroupLayout(
|
||||
nextLayoutByWorktree,
|
||||
nextActiveGroupIdByWorktreeResolved,
|
||||
worktreeId,
|
||||
sourceGroup.id,
|
||||
targetGroupId
|
||||
)
|
||||
nextLayoutByWorktree = collapsedState.layoutByWorktree
|
||||
nextActiveGroupIdByWorktreeResolved = collapsedState.activeGroupIdByWorktree
|
||||
}
|
||||
const nextGroupsByWorktree = {
|
||||
...state.groupsByWorktree,
|
||||
[worktreeId]: filteredGroups
|
||||
}
|
||||
const nextUnifiedTabsByWorktree = {
|
||||
...state.unifiedTabsByWorktree,
|
||||
[worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) =>
|
||||
candidate.id === tabId ? { ...candidate, groupId: targetGroupId } : candidate
|
||||
)
|
||||
}
|
||||
return {
|
||||
unifiedTabsByWorktree: {
|
||||
...state.unifiedTabsByWorktree,
|
||||
[worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) =>
|
||||
candidate.id === tabId ? { ...candidate, groupId: targetGroupId } : candidate
|
||||
)
|
||||
},
|
||||
groupsByWorktree: {
|
||||
...state.groupsByWorktree,
|
||||
[worktreeId]: nextGroups
|
||||
},
|
||||
activeGroupIdByWorktree: nextActiveGroupIdByWorktree
|
||||
unifiedTabsByWorktree: nextUnifiedTabsByWorktree,
|
||||
groupsByWorktree: nextGroupsByWorktree,
|
||||
layoutByWorktree: nextLayoutByWorktree,
|
||||
activeGroupIdByWorktree: nextActiveGroupIdByWorktreeResolved,
|
||||
...(state.activeWorktreeId === worktreeId
|
||||
? buildActiveSurfacePatch(
|
||||
{
|
||||
...state,
|
||||
unifiedTabsByWorktree: nextUnifiedTabsByWorktree,
|
||||
groupsByWorktree: nextGroupsByWorktree,
|
||||
layoutByWorktree: nextLayoutByWorktree,
|
||||
activeGroupIdByWorktree: nextActiveGroupIdByWorktreeResolved
|
||||
},
|
||||
worktreeId,
|
||||
nextActiveGroupIdByWorktreeResolved[worktreeId] ?? null
|
||||
)
|
||||
: {})
|
||||
}
|
||||
})
|
||||
if (moved && opts?.recordInteraction !== false) {
|
||||
@@ -1418,11 +1450,20 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
if (!isSplitDrop && tab.groupId === target.groupId) {
|
||||
return {}
|
||||
}
|
||||
if (isSplitDrop && tab.groupId === target.groupId && sourceGroup.tabOrder.length <= 1) {
|
||||
// Why: dragging the final tab in a group onto that same group's edge
|
||||
// would create a transient sibling only to collapse the source
|
||||
// immediately, leaving the layout unchanged while still churning focus
|
||||
// and group IDs. Treat that as a no-op instead of faking a split.
|
||||
const layout = state.layoutByWorktree[worktreeId]
|
||||
if (
|
||||
isSplitDrop &&
|
||||
isPaneColumnSplitDropNoOp({
|
||||
sourceGroupId: sourceGroup.id,
|
||||
targetGroupId: target.groupId,
|
||||
splitDirection: target.splitDirection!,
|
||||
sourceTabCount: sourceGroup.tabOrder.length,
|
||||
layout
|
||||
})
|
||||
) {
|
||||
// Why: dragging the final tab in a group onto that same group's edge,
|
||||
// or onto the adjacent sibling's matching edge, creates a transient
|
||||
// column only to collapse the emptied source immediately.
|
||||
return {}
|
||||
}
|
||||
|
||||
|
||||
@@ -844,16 +844,12 @@ test.describe('Terminal Panes', () => {
|
||||
await expect(orcaPage.locator('.pane-title-text', { hasText: title })).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Set Title remove button hover stays transparent', async ({ orcaPage }) => {
|
||||
const title = `Remove hover title ${Date.now()}`
|
||||
test('Always-on pane header split button hover stays transparent', async ({ orcaPage }) => {
|
||||
const splitButton = orcaPage.getByRole('button', { name: 'Split Terminal Right' })
|
||||
await expect(splitButton).toBeVisible()
|
||||
await splitButton.hover()
|
||||
|
||||
await setPaneTitleFromTerminalMenu(orcaPage, title)
|
||||
const removeButton = orcaPage.getByRole('button', { name: `Remove pane title: ${title}` })
|
||||
await orcaPage.locator('.pane-title-bar', { hasText: title }).hover()
|
||||
await removeButton.hover()
|
||||
await expect(orcaPage.getByText('Remove title', { exact: true })).toBeVisible()
|
||||
|
||||
const hoverStyle = await removeButton.evaluate((element) => {
|
||||
const hoverStyle = await splitButton.evaluate((element) => {
|
||||
const style = getComputedStyle(element)
|
||||
return {
|
||||
backgroundColor: style.backgroundColor,
|
||||
@@ -907,8 +903,7 @@ test.describe('Terminal Panes', () => {
|
||||
await expectSavedLayoutNotToContainTitle(orcaPage, tabId, paneTitle)
|
||||
|
||||
await setPaneTitleFromTerminalMenu(orcaPage, removeButtonTitle)
|
||||
await orcaPage.locator('.pane-title-bar', { hasText: removeButtonTitle }).hover()
|
||||
await orcaPage.getByRole('button', { name: `Remove pane title: ${removeButtonTitle}` }).click()
|
||||
await setPaneTitleFromTerminalMenu(orcaPage, '')
|
||||
await expect(orcaPage.locator('.pane-title-text', { hasText: removeButtonTitle })).toBeHidden()
|
||||
await expectSavedLayoutNotToContainTitle(orcaPage, tabId, removeButtonTitle)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user