From bfb778570a87834ffbc35017fdc192ac145f934a Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Sun, 21 Jun 2026 23:09:01 -0700 Subject: [PATCH] feat(tabs): redesign tab splits and terminal pane discoverability (#5927) Co-authored-by: Cursor Co-authored-by: Orca Co-authored-by: brennanb2025 --- src/renderer/src/assets/terminal.css | 48 +- .../ContextualTourOverlay.tsx | 14 +- .../components/tab-bar/BrowserTab.test.tsx | 23 +- .../src/components/tab-bar/BrowserTab.tsx | 48 +- .../components/tab-bar/EditorFileTab.test.tsx | 31 +- .../src/components/tab-bar/EditorFileTab.tsx | 41 +- .../tab-bar/EditorFileTabContextMenu.test.tsx | 45 +- .../tab-bar/EditorFileTabContextMenu.tsx | 28 +- .../SortableTab.rename-shortcut.test.tsx | 90 ++- .../src/components/tab-bar/SortableTab.tsx | 43 +- .../tab-bar/SortableTabContextMenu.test.tsx | 246 +++++++ .../tab-bar/SortableTabContextMenu.tsx | 61 +- .../tab-bar/TabBar.context-menu.test.ts | 21 + .../src/components/tab-bar/TabBar.tsx | 36 +- .../TabBar.windows-shell-launch.test.ts | 21 + .../tab-bar/TabWorkspaceLayoutMenuSection.tsx | 63 ++ .../components/tab-bar/drop-indicator.test.ts | 7 + .../src/components/tab-bar/drop-indicator.ts | 8 +- .../request-active-terminal-pane-split.ts | 13 + .../tab-bar/tab-move-to-pane-column.test.ts | 118 ++++ .../tab-bar/tab-move-to-pane-column.ts | 65 ++ .../tab-bar/tab-strip-drag-scroll.ts | 68 ++ .../tab-strip-overflow-navigation.test.ts | 19 + .../tab-bar/tab-strip-overflow-navigation.ts | 41 +- .../tab-strip-pointer-activation.test.tsx | 112 +++ .../tab-bar/tab-strip-pointer-activation.ts | 72 ++ .../tab-bar/tab-title-tooltip.test.tsx | 12 +- .../tab-bar/web-runtime-tab-move-mirror.ts | 22 + .../tab-group/AiVaultSessionDropLayer.tsx | 3 +- .../tab-group/TabGroupDropOverlay.tsx | 24 +- .../components/tab-group/TabGroupPanel.tsx | 168 +++-- .../tab-group/TabGroupSplitLayout.test.ts | 70 +- .../tab-group/TabGroupSplitLayout.tsx | 110 +-- .../TabPaneColumnSplitDragOverlay.test.tsx | 61 ++ .../TabPaneColumnSplitDragOverlay.tsx | 56 ++ .../components/tab-group/tab-drag-context.tsx | 37 + .../tab-group/tab-drag-pointer.test.ts | 14 + .../components/tab-group/tab-drag-pointer.ts | 36 + .../tab-drag-preview-activation.test.ts | 83 +++ .../tab-group/tab-drag-preview-activation.ts | 205 ++++++ .../tab-group/tab-drag-preview-target.ts | 57 ++ .../tab-group/tab-drop-zone.test.ts | 32 + .../src/components/tab-group/tab-drop-zone.ts | 94 +++ .../tab-group-panel-split-target.test.ts | 638 ++++++++++++++++++ .../tab-group/tab-group-panel-split-target.ts | 264 ++++++++ .../tab-group/tab-insertion.test.ts | 39 +- .../tab-group/useTabDragSplit.test.ts | 223 +++++- .../components/tab-group/useTabDragSplit.ts | 437 +++++++----- .../useTabGroupWorkspaceModel.focus.test.ts | 43 ++ .../tab-group/useTabGroupWorkspaceModel.ts | 48 +- .../components/terminal-pane/TerminalPane.tsx | 234 ++----- .../TerminalPaneHeaderOverlay.test.tsx | 143 ++++ .../TerminalPaneHeaderOverlay.tsx | 298 ++++++++ .../terminal-pane/keyboard-handlers.ts | 49 +- ...ession-restored-banner-pane-state.test.tsx | 17 + .../session-restored-banner-pane-state.ts | 2 + ...inal-pane-split-with-inherited-cwd.test.ts | 90 +++ .../terminal-pane-split-with-inherited-cwd.ts | 47 ++ .../use-terminal-pane-context-menu.ts | 41 +- src/renderer/src/i18n/locales/en.json | 18 +- src/renderer/src/i18n/locales/es.json | 50 +- src/renderer/src/i18n/locales/ja.json | 50 +- src/renderer/src/i18n/locales/ko.json | 50 +- src/renderer/src/i18n/locales/zh.json | 50 +- .../src/lib/pane-manager/pane-drag-pointer.ts | 16 +- .../pane-manager/pane-drag-reorder.test.ts | 13 +- .../src/lib/pane-manager/pane-drag-reorder.ts | 46 +- .../lib/pane-manager/pane-drop-no-op.test.ts | 81 +++ .../pane-column-split-drop-no-op.test.ts | 82 +++ .../slices/pane-column-split-drop-no-op.ts | 71 ++ src/renderer/src/store/slices/tabs.test.ts | 38 +- src/renderer/src/store/slices/tabs.ts | 73 +- tests/e2e/terminal-panes.spec.ts | 17 +- 73 files changed, 4801 insertions(+), 933 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx create mode 100644 src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx create mode 100644 src/renderer/src/components/tab-bar/request-active-terminal-pane-split.ts create mode 100644 src/renderer/src/components/tab-bar/tab-move-to-pane-column.test.ts create mode 100644 src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts create mode 100644 src/renderer/src/components/tab-bar/tab-strip-drag-scroll.ts create mode 100644 src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.test.ts create mode 100644 src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx create mode 100644 src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts create mode 100644 src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts create mode 100644 src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.test.tsx create mode 100644 src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.tsx create mode 100644 src/renderer/src/components/tab-group/tab-drag-context.tsx create mode 100644 src/renderer/src/components/tab-group/tab-drag-pointer.test.ts create mode 100644 src/renderer/src/components/tab-group/tab-drag-pointer.ts create mode 100644 src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts create mode 100644 src/renderer/src/components/tab-group/tab-drag-preview-activation.ts create mode 100644 src/renderer/src/components/tab-group/tab-drag-preview-target.ts create mode 100644 src/renderer/src/components/tab-group/tab-drop-zone.test.ts create mode 100644 src/renderer/src/components/tab-group/tab-drop-zone.ts create mode 100644 src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts create mode 100644 src/renderer/src/components/tab-group/tab-group-panel-split-target.ts create mode 100644 src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts create mode 100644 src/renderer/src/store/slices/pane-column-split-drop-no-op.test.ts create mode 100644 src/renderer/src/store/slices/pane-column-split-drop-no-op.ts diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 0e4587f6a17..c78145379ca 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -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)); } diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx index faa1d8eb24b..8129a6f20d8 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlay.tsx @@ -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( - REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT, - { detail } - ) - ) - }, + dispatchTerminalPaneSplit: requestActiveTerminalPaneSplit, schedule: (callback) => { window.setTimeout(callback, 0) } diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index 1c40881b691..1c8b183821c 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -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) { 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 { onActivate: () => {}, onClose: () => {}, onCloseToRight: () => {}, - onSplitGroup: () => {}, onDuplicate: () => {}, onTogglePin: () => {}, dragData: { diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 9e3e63ce809..04eb73649f5 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -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 = (
{ - if (e.button !== 0) { - return - } - onActivate() - listeners?.onPointerDown?.(e) + onTabPointerDown( + e, + listeners?.onPointerDown as ((event: React.PointerEvent) => void) | undefined + ) }} onMouseDown={(e) => { if (e.button === 1) { @@ -202,7 +207,7 @@ export default function BrowserTab({ } }} > - {isActive && } + {showsSelectionChrome && } {/* 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 && ( + ), + DropdownMenuLabel: ({ children }: { children?: ReactNode }) =>
{children}
, + DropdownMenuSeparator: () => null, + DropdownMenuSub: ({ children }: { children?: ReactNode }) => children, + DropdownMenuSubContent: ({ children }: { children?: ReactNode }) => children, + DropdownMenuSubTrigger: ({ children }: { children?: ReactNode }) => ( + + ), + 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) => unknown) => selector(storeMock.state), + { + getState: () => storeMock.state + } + ) +})) + +const mounted: { container: HTMLDivElement; root: Root }[] = [] + +function renderMenu(overrides: Partial> = {}): { + container: HTMLDivElement + root: Root + onActivate: ReturnType +} { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const onActivate = vi.fn() + act(() => { + root.render( + + ) + }) + 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): 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') + }) +}) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 00b1678c954..2997ab2a29c 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -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 ( @@ -115,23 +137,24 @@ export function SortableTabContextMenu({ style={{ left: point.x, top: point.y }} /> - - onSplitGroup('up', tab.id)}> - - {translate('auto.components.tab.bar.SortableTabContextMenu.591f9b12c1', 'Split Up')} + + splitActiveTerminalPane('vertical')}> + + {translate( + 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalRight', + 'Split terminal right' + )} + {splitRightShortcut} - onSplitGroup('down', tab.id)}> - - {translate('auto.components.tab.bar.SortableTabContextMenu.af80ed83c1', 'Split Down')} - - onSplitGroup('left', tab.id)}> - - {translate('auto.components.tab.bar.SortableTabContextMenu.0ce4bae39d', 'Split Left')} - - onSplitGroup('right', tab.id)}> - - {translate('auto.components.tab.bar.SortableTabContextMenu.21132389e9', 'Split Right')} + splitActiveTerminalPane('horizontal')}> + + {translate( + 'auto.components.tab.bar.SortableTabContextMenu.splitTerminalDown', + 'Split terminal down' + )} + {splitDownShortcut} + {isPinned ? : } diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index 6e694be58d4..fd5d8ca9a90 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -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[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 } } diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 7282692ddc5..313186f46f2 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -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 (
scrollTabStrip('start')} + onPointerEnter={tabStripDragScroll.onDragScrollStartEnter} + onPointerLeave={tabStripDragScroll.onDragScrollLeave} > @@ -1069,6 +1074,8 @@ function TabBarInner({ 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} > diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index 4c8c34168f5..4a7da1c89de 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -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[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 } } diff --git a/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx b/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx new file mode 100644 index 00000000000..819bb6d328f --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabWorkspaceLayoutMenuSection.tsx @@ -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 ( + <> + + + + {translate( + 'auto.components.tab.bar.TabWorkspaceLayoutMenuSection.moveToPaneColumn', + 'Move Tab to Split' + )} + + + {PANE_COLUMN_DIRECTIONS.map((direction) => ( + { + moveTabToNewPaneColumn({ unifiedTabId, groupId, direction }) + }} + > + {paneColumnDirectionLabel(direction)} + + ))} + + + + ) +} diff --git a/src/renderer/src/components/tab-bar/drop-indicator.test.ts b/src/renderer/src/components/tab-bar/drop-indicator.test.ts index db3a8140c91..4a1ea418bcb 100644 --- a/src/renderer/src/components/tab-bar/drop-indicator.test.ts +++ b/src/renderer/src/components/tab-bar/drop-indicator.test.ts @@ -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') + }) }) diff --git a/src/renderer/src/components/tab-bar/drop-indicator.ts b/src/renderer/src/components/tab-bar/drop-indicator.ts index ab9f0ef5623..fd2d0977116 100644 --- a/src/renderer/src/components/tab-bar/drop-indicator.ts +++ b/src/renderer/src/components/tab-bar/drop-indicator.ts @@ -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' } diff --git a/src/renderer/src/components/tab-bar/request-active-terminal-pane-split.ts b/src/renderer/src/components/tab-bar/request-active-terminal-pane-split.ts new file mode 100644 index 00000000000..b8c6837b8bb --- /dev/null +++ b/src/renderer/src/components/tab-bar/request-active-terminal-pane-split.ts @@ -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( + REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT, + { detail } + ) + ) +} diff --git a/src/renderer/src/components/tab-bar/tab-move-to-pane-column.test.ts b/src/renderer/src/components/tab-bar/tab-move-to-pane-column.test.ts new file mode 100644 index 00000000000..5c9abb972a7 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-move-to-pane-column.test.ts @@ -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>) + + 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>) + + expect( + moveTabToNewPaneColumn({ unifiedTabId: 'tab-b', groupId: 'group-1', direction: 'right' }) + ).toBe(false) + expect(mocks.mirrorWebRuntimeTabMove).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts b/src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts new file mode 100644 index 00000000000..1d3de0bb916 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-move-to-pane-column.ts @@ -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, + '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 +} diff --git a/src/renderer/src/components/tab-bar/tab-strip-drag-scroll.ts b/src/renderer/src/components/tab-bar/tab-strip-drag-scroll.ts new file mode 100644 index 00000000000..2b5c95434cc --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-drag-scroll.ts @@ -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(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 + } +} diff --git a/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.test.ts b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.test.ts new file mode 100644 index 00000000000..4ec5fcfbb76 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.test.ts @@ -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' + }) + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts index 0c2da0521df..72d66783239 100644 --- a/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts +++ b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts @@ -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 tabStripOverflowState: TabStripScrollMetrics - scrollTabStrip: (direction: 'start' | 'end') => void + scrollTabStrip: (direction: 'start' | 'end', behavior?: ScrollBehavior) => void } { const tabStripRef = useRef(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 diff --git a/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx new file mode 100644 index 00000000000..1dacc025d7b --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.test.tsx @@ -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 ( + + + + ) +} + +function ProbeButton({ + onActivate, + onDragActiveChange +}: { + onActivate: () => void + onDragActiveChange: (active: boolean) => void +}): React.JSX.Element { + const { isPressed, onPointerDown } = useTabStripPointerActivation({ onActivate }) + return ( + <> + + + + ) +} + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function renderProbe(onActivate = vi.fn()): { + onActivate: ReturnType + tabButton: HTMLButtonElement + dragButton: HTMLButtonElement +} { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root?.render() + }) + 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() + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts new file mode 100644 index 00000000000..e4ce56597ca --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts @@ -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) => 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) => void) => { + if (disabled || event.button !== 0) { + return + } + pendingActivationRef.current = true + setIsPressed(true) + dragListener?.(event) + }, + [disabled] + ) + + return { isPressed, onPointerDown } +} diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index de2cb6ca348..f6fa605e219 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -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( { 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( { 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')} /> ) diff --git a/src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts b/src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts new file mode 100644 index 00000000000..fac4ecb6672 --- /dev/null +++ b/src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts @@ -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 + }) +} diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index 0427a2916c8..91587d9d0e5 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -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 = { diff --git a/src/renderer/src/components/tab-group/TabGroupDropOverlay.tsx b/src/renderer/src/components/tab-group/TabGroupDropOverlay.tsx index 48bfcf3b5ba..07d73077fad 100644 --- a/src/renderer/src/components/tab-group/TabGroupDropOverlay.tsx +++ b/src/renderer/src/components/tab-group/TabGroupDropOverlay.tsx @@ -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 ( - @@ -207,8 +202,6 @@ function SplitNode({ touchesRightEdge={touchesRightEdge} touchesLeftEdge={isHorizontal ? false : touchesLeftEdge} isTabDragActive={isTabDragActive} - activeDropGroupId={activeDropGroupId} - activeDropZone={activeDropZone} hoveredTabInsertion={hoveredTabInsertion} />
@@ -231,22 +224,26 @@ export default function TabGroupSplitLayout({ const hasSplits = layout.type === 'split' return ( - - {/* Why: the 10px drag strip sits ABOVE the split layout — lifted out of + + {/* 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. */} -
-
- +
+
+ +
-
- {/* 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. */} - - {dragSplit.activeDrag ? : null} - - + + {dragSplit.activeDrag ? : null} + + {dragSplit.hoveredDropTarget && + dragSplit.hoveredDropTarget.zone !== 'center' && + dragSplit.hoveredDropTarget.panelRect ? ( + + ) : null} + + ) } diff --git a/src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.test.tsx b/src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.test.tsx new file mode 100644 index 00000000000..72ec9216560 --- /dev/null +++ b/src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.test.tsx @@ -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 +} + +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() + }) +}) diff --git a/src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.tsx b/src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.tsx new file mode 100644 index 00000000000..3437f08236b --- /dev/null +++ b/src/renderer/src/components/tab-group/TabPaneColumnSplitDragOverlay.tsx @@ -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 +): Pick { + 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 +}): React.JSX.Element | null { + const bounds = getOverlayBounds(panelRect, zone) + return createPortal( + , + document.body + ) +} diff --git a/src/renderer/src/components/tab-group/tab-drag-context.tsx b/src/renderer/src/components/tab-group/tab-drag-context.tsx new file mode 100644 index 00000000000..3cf10ee7e1a --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-context.tsx @@ -0,0 +1,37 @@ +import { createContext, useContext, useMemo, type RefObject } from 'react' + +type TabDragContextValue = { + isTabDragActive: boolean + isTabDragActiveRef: RefObject +} + +const defaultRef: RefObject = { current: false } + +const TabDragContext = createContext({ + isTabDragActive: false, + isTabDragActiveRef: defaultRef +}) + +export function TabDragProvider({ + isTabDragActive, + isTabDragActiveRef, + children +}: { + isTabDragActive: boolean + isTabDragActiveRef: RefObject + children: React.ReactNode +}): React.JSX.Element { + const value = useMemo( + () => ({ isTabDragActive, isTabDragActiveRef }), + [isTabDragActive, isTabDragActiveRef] + ) + return {children} +} + +export function useTabDragActive(): boolean { + return useContext(TabDragContext).isTabDragActive +} + +export function useTabDragActiveRef(): RefObject { + return useContext(TabDragContext).isTabDragActiveRef +} diff --git a/src/renderer/src/components/tab-group/tab-drag-pointer.test.ts b/src/renderer/src/components/tab-group/tab-drag-pointer.test.ts new file mode 100644 index 00000000000..f2bf11ea8e4 --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-pointer.test.ts @@ -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[0]) + ).toEqual({ x: 350, y: 50 }) + }) +}) diff --git a/src/renderer/src/components/tab-group/tab-drag-pointer.ts b/src/renderer/src/components/tab-group/tab-drag-pointer.ts new file mode 100644 index 00000000000..9310a2f810a --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-pointer.ts @@ -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 + } +} diff --git a/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts b/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts new file mode 100644 index 00000000000..d6a373fc865 --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-preview-activation.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts b/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts new file mode 100644 index 00000000000..a0060271c99 --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-preview-activation.ts @@ -0,0 +1,205 @@ +import { useAppStore } from '../../store' +import type { AppState } from '../../store/types' + +export type TabDragActivationSnapshot = { + activeGroupId: string | null + activeTabIdByGroup: Record +} + +function previewActiveSurfacePatch( + state: AppState, + worktreeId: string, + groupId: string, + tabId: string | null +): Partial { + 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 => { + 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 = { ...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 => { + 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 = {} + 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 => { + 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 + ) + } + } + }) +} diff --git a/src/renderer/src/components/tab-group/tab-drag-preview-target.ts b/src/renderer/src/components/tab-group/tab-drag-preview-target.ts new file mode 100644 index 00000000000..b4c36a0dcdc --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-preview-target.ts @@ -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 + 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 +} diff --git a/src/renderer/src/components/tab-group/tab-drop-zone.test.ts b/src/renderer/src/components/tab-group/tab-drop-zone.test.ts new file mode 100644 index 00000000000..2690d7d97fe --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drop-zone.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/components/tab-group/tab-drop-zone.ts b/src/renderer/src/components/tab-group/tab-drop-zone.ts new file mode 100644 index 00000000000..10291e2389c --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drop-zone.ts @@ -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 | 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 +} diff --git a/src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts b/src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts new file mode 100644 index 00000000000..ceca4d16962 --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-group-panel-split-target.test.ts @@ -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 { + 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[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 } { + 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 + layoutByWorktree: Record + queryAll: ReturnType +} { + 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) + }) +}) diff --git a/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts b/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts new file mode 100644 index 00000000000..b78f383f26c --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-group-panel-split-target.ts @@ -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 +} + +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( + `[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( + `[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( + `[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 + layoutByWorktree: Record + 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 + layoutByWorktree: Record + 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 +} diff --git a/src/renderer/src/components/tab-group/tab-insertion.test.ts b/src/renderer/src/components/tab-group/tab-insertion.test.ts index ab2719a4738..5acc6569b46 100644 --- a/src/renderer/src/components/tab-group/tab-insertion.test.ts +++ b/src/renderer/src/components/tab-group/tab-insertion.test.ts @@ -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' }) }) }) diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts index 3ab2add57ea..6a1efe52481 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.test.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.test.ts @@ -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 { + let result: ReturnType | 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>) + + 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' + }) + }) }) diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index 86deb40852d..83d6b598163 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -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 + 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 -): { 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 onDragCancel: () => void onDragEnd: (event: DragEndEvent) => void onDragMove: (event: DragMoveEvent) => void @@ -212,7 +173,12 @@ export function useTabDragSplit({ const [activeDrag, setActiveDrag] = useState(null) const [hoveredDropTarget, setHoveredDropTarget] = useState(null) const releaseWebviewDragPassthroughRef = useRef<(() => void) | null>(null) - const tabInsertion = useHoveredTabInsertion(isTabDragData, getDragCenter) + const preDragActivationSnapshotRef = useRef(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(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, diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts index d5d0c908717..be86887517d 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts @@ -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 = { diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index 5cc355b55ac..a723a457667 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index c5f4c005b81..e68821654bf 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -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 ( <>
- {/* 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. */} -
- {(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 ( -
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 ? ( - setRenameValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleRenameSubmit() - } else if (e.key === 'Escape') { - handleRenameCancel() - } - }} - onBlur={handleRenameBlur} - /> - ) : ( - <> - {paneCount > 1 && ( - - ) - })} -
- {(managerRef.current?.getPanes() ?? []).map((pane) => { + + {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. diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx new file mode 100644 index 00000000000..6178db32516 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.test.tsx @@ -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 }) => {children} +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + 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 + paneCount?: number + showAlwaysOnHeaders?: boolean + onClosePane?: ReturnType + onRemoveTitle?: ReturnType +}): { + container: HTMLDivElement + onClosePane: ReturnType + onRemoveTitle: ReturnType +} { + const panes = [makePane(1), makePane(2)] + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render( + ()} + titleUsesLightSurface={false} + paneTitleBackground="transparent" + terminalContentVisible + hiddenStartupStyle={{}} + managerRef={{ current: null } as RefObject} + paneTransportsRef={{ current: new Map() } as RefObject>} + 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( + '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('button[aria-label="Close Pane"]') + expect(closePane).not.toBeNull() + + act(() => closePane?.click()) + + expect(onClosePane).toHaveBeenCalledWith(1) + expect(onRemoveTitle).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx new file mode 100644 index 00000000000..ca10b1ac38b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx @@ -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> + paneTitleOverlayRects: Readonly> + renamingPaneId: number | null + renameValue: string + renameInputRef: RefObject + titleUsesLightSurface: boolean + paneTitleBackground: string + terminalContentVisible: boolean + hiddenStartupStyle: CSSProperties + managerRef: RefObject + paneTransportsRef: RefObject> + onSplitPane: (pane: ManagedPane, direction: 'vertical' | 'horizontal') => void + onBeginPaneDrag: (paneId: number, handle: HTMLElement, event: PointerEvent) => void + onActivatePaneTitleInteraction: (paneId: number) => void + onPaneTitleContextMenu: (event: React.MouseEvent, 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 ( +
+ {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 ( +
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 ? ( + onRenameValueChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + onRenameSubmit() + } else if (event.key === 'Escape') { + onRenameCancel() + } + }} + onBlur={onRenameBlur} + /> + ) : ( + <> + {paneCount > 1 && ( + + ) + })} +
+ ) +} diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 6b54e46875c..7ea67c0d99b 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -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() + }) } } diff --git a/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx b/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx index db8a13faada..1683b4fa5e5 100644 --- a/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx +++ b/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.test.tsx @@ -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) diff --git a/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.ts b/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.ts index 03fd87ad600..969225d8365 100644 --- a/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.ts +++ b/src/renderer/src/components/terminal-pane/session-restored-banner-pane-state.ts @@ -88,10 +88,12 @@ export function syncSessionRestoredBannerTitleSpace(args: { paneTitles: Readonly> renamingPaneId: number | null sessionRestoredBannerPaneIds: ReadonlySet + 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) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts new file mode 100644 index 00000000000..53155fa786f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts @@ -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): PaneManager { + return { splitPane } as unknown as PaneManager +} + +async function flushAsyncSplit(): Promise { + 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(), + 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(), + 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' + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts new file mode 100644 index 00000000000..5fefdb78edd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts @@ -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 + 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 + }) + })() +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 6762282fae0..0fc54a533f2 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -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 => 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] ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 05d8a111b76..91f9a7a7aea 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 79012408eea..159eeebe980 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index ff0909bf734..067b720b72b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": "ファイルを検索", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 66435772ac5..04172df8fa4 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": "파일 찾기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e07d3e89490..688829e7a64 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": "查找文件", diff --git a/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts b/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts index adfc43809fb..e09553f4993 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts @@ -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) } diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts index 9a19ef94729..83a0eb1dacc 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts @@ -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() + return { + ...actual, + detachPaneFromTree, + insertPaneNextTo + } +}) type FakeListener = (event: PointerEvent) => void diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts index ad730523ca1..6b08dc4b48e 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.ts @@ -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 +): 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) { diff --git a/src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts b/src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts new file mode 100644 index 00000000000..fc63ebd9ad6 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-drop-no-op.test.ts @@ -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 { + const split = document.createElement('div') + split.className = 'pane-split is-vertical' + document.body.appendChild(split) + + const panes = new Map() + 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) + }) +}) diff --git a/src/renderer/src/store/slices/pane-column-split-drop-no-op.test.ts b/src/renderer/src/store/slices/pane-column-split-drop-no-op.test.ts new file mode 100644 index 00000000000..f5c4d68b309 --- /dev/null +++ b/src/renderer/src/store/slices/pane-column-split-drop-no-op.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/store/slices/pane-column-split-drop-no-op.ts b/src/renderer/src/store/slices/pane-column-split-drop-no-op.ts new file mode 100644 index 00000000000..9f87e4a20a5 --- /dev/null +++ b/src/renderer/src/store/slices/pane-column-split-drop-no-op.ts @@ -0,0 +1,71 @@ +import type { TabGroupLayoutNode } from '../../../../shared/types' +import type { TabSplitDirection } from './tabs' + +function getDirectLayoutSiblingOnSplitSide( + split: Extract, + 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 + ) +} diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index 792c722f558..358d9b9598c 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -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 ───────── diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 0500aa1f9a5..04e83253973 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -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 = (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 = (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 {} } diff --git a/tests/e2e/terminal-panes.spec.ts b/tests/e2e/terminal-panes.spec.ts index ee0a2d3d12d..6efc29f837f 100644 --- a/tests/e2e/terminal-panes.spec.ts +++ b/tests/e2e/terminal-panes.spec.ts @@ -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)