mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(terminals): restore pane detach tab placement (#7215)
* fix(tabs): restore deferred tab activation so dragging a terminal tab doesn't switch panes mid-gesture
PR #5927 shipped terminal-pane drag (reorder tabs, move a tab into another
pane, edge-split into a new column). It deliberately DEFERRED tab activation
to pointer-up and suppressed it during a drag, so pressing a tab to drag it
never switched the active tab or stole terminal focus mid-gesture.
PR #6395 (d32d62a395) deleted tab-strip-pointer-activation.ts and made
SortableTab/EditorFileTab/BrowserTab activate eagerly on pointerdown — fixing
click-to-switch-after-reorder but regressing the drag: grabbing a tab now
flips the active tab + focused pane and yanks xterm keyboard focus before the
drag is even recognized (aggravated by PR #6210 raising the dnd-kit threshold
5px->12px). The move itself still lands, but the gesture feels broken.
Restore the deferred-activation hook, but gate it on measured pointer
DISPLACEMENT instead of the drag-active context ref the old hook used — that
ref clears asynchronously relative to the drop's pointerup, which is exactly
what made #6395's click-after-reorder misfire. Displacement mirrors dnd-kit's
own activation threshold: a release within TAB_DRAG_ACTIVATION_DISTANCE_PX is
a click (activate); crossing it is a drag (suppress). Because each press
measures its own gesture, a click after a reorder always activates.
- Recreate src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts
(displacement-gated) + unit tests, incl. an explicit #6395 regression case.
- Rewire onPointerDown in SortableTab, EditorFileTab, BrowserTab to use it.
- BrowserTab.test.tsx shallow-renders via mocked React hooks; add useRef/
useCallback stubs so the new hook works under that harness.
Verified end-to-end in a dev build via CDP: pressing a tab no longer
activates/steals focus on pointerdown; a click still activates on release;
dragging a tab into another pane still moves it; clicking tabs after a drag
still switches (the #6395 guard in tests/e2e/tabs.spec.ts stays green because
it uses zero-displacement clicks). 233 unit tests pass; oxlint + renderer
typecheck clean.
* fix: restore pane detach tab placement
This commit is contained in:
@@ -219,6 +219,12 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pane-drop-overlay[data-pane-drop-overlay-kind='insertion'] {
|
||||
background: var(--chart-2);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.tab-drop-overlay__label {
|
||||
font-size: 11px;
|
||||
color: var(--primary-foreground);
|
||||
|
||||
@@ -11,6 +11,11 @@ vi.mock('react', async () => {
|
||||
return {
|
||||
...actual,
|
||||
useEffect: () => {},
|
||||
// Why: this shallow harness calls the component as a plain function (no React
|
||||
// render), so ref/callback hooks must be stubbed like useState/useEffect. The
|
||||
// favicon tests never fire pointer events, so non-persistent refs are fine.
|
||||
useRef: <T,>(initial: T) => ({ current: initial }),
|
||||
useCallback: <T,>(fn: T) => fn,
|
||||
useState<T>(initial: T | (() => T)) {
|
||||
const stateIndex = reactHookRuntime.index++
|
||||
if (!(stateIndex in reactHookRuntime.states)) {
|
||||
|
||||
@@ -26,6 +26,7 @@ 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 { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection'
|
||||
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
|
||||
|
||||
function formatBrowserTabUrlLabel(url: string): string {
|
||||
if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') {
|
||||
@@ -169,6 +170,10 @@ export default function BrowserTab({
|
||||
return () => window.removeEventListener('blur', dismiss)
|
||||
}, [menuOpen])
|
||||
|
||||
// Why: defer activation to pointer-up so dragging the tab (reorder / move into
|
||||
// another pane / split) does not switch the active tab mid-gesture.
|
||||
const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({ onActivate })
|
||||
|
||||
const tabRoot = (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
@@ -178,11 +183,10 @@ export default function BrowserTab({
|
||||
{...listeners}
|
||||
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
}
|
||||
onActivate()
|
||||
listeners?.onPointerDown?.(e)
|
||||
onTabPointerDown(
|
||||
e,
|
||||
listeners?.onPointerDown as ((event: React.PointerEvent<Element>) => void) | undefined
|
||||
)
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1) {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { EditorFileTabContextMenu } from './EditorFileTabContextMenu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules'
|
||||
import { EditorFileTabCloseButton } from './EditorFileTabCloseButton'
|
||||
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
|
||||
|
||||
export default function EditorFileTab({
|
||||
file,
|
||||
@@ -201,6 +202,12 @@ export default function EditorFileTab({
|
||||
}, [menuOpen])
|
||||
|
||||
const dragListeners = isRenaming ? undefined : listeners
|
||||
// Why: defer activation to pointer-up so dragging the tab (reorder / move into
|
||||
// another pane / split) does not switch the active tab mid-gesture.
|
||||
const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({
|
||||
onActivate,
|
||||
disabled: isRenaming
|
||||
})
|
||||
|
||||
const tabRoot = (
|
||||
<div
|
||||
@@ -211,11 +218,10 @@ export default function EditorFileTab({
|
||||
{...dragListeners}
|
||||
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`}
|
||||
onPointerDown={(e) => {
|
||||
if (isRenaming || e.button !== 0) {
|
||||
return
|
||||
}
|
||||
onActivate()
|
||||
dragListeners?.onPointerDown?.(e)
|
||||
onTabPointerDown(
|
||||
e,
|
||||
dragListeners?.onPointerDown as ((event: React.PointerEvent<Element>) => void) | undefined
|
||||
)
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (file.isPreview && onMakePermanent) {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { SortableTabContextMenu } from './SortableTabContextMenu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules'
|
||||
import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
|
||||
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
|
||||
|
||||
type SortableTabProps = {
|
||||
tab: TerminalTab
|
||||
@@ -210,6 +211,16 @@ export default function SortableTab({
|
||||
// so dnd-kit's a11y attributes (aria-roledescription, etc.) remain on the element — only
|
||||
// the pointer listeners are gated so a drag can't start while typing.
|
||||
const dragListeners = isEditing ? undefined : listeners
|
||||
const handleActivate = useCallback(() => {
|
||||
onActivate(tab.id)
|
||||
}, [onActivate, tab.id])
|
||||
// Why: defer activation to pointer-up so pressing a tab to drag it (reorder /
|
||||
// move into another pane / split) does not switch the active tab or steal
|
||||
// terminal focus mid-gesture. See tab-strip-pointer-activation.
|
||||
const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({
|
||||
onActivate: handleActivate,
|
||||
disabled: isEditing
|
||||
})
|
||||
const closeShortcut = useShortcutKeyDetails('tab.close')
|
||||
const tabTitle = tab.customTitle ?? tab.title
|
||||
const tabRoot = (
|
||||
@@ -243,11 +254,10 @@ export default function SortableTab({
|
||||
handleRenameOpen()
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (isEditing || e.button !== 0) {
|
||||
return
|
||||
}
|
||||
onActivate(tab.id)
|
||||
dragListeners?.onPointerDown?.(e)
|
||||
onTabPointerDown(
|
||||
e,
|
||||
dragListeners?.onPointerDown as ((event: React.PointerEvent<Element>) => void) | undefined
|
||||
)
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
// Why: prevent default browser middle-click behavior (auto-scroll)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TAB_DRAG_ACTIVATION_DISTANCE_PX } from '../tab-group/useTabDragSplit'
|
||||
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
|
||||
|
||||
function pointerDownEvent(clientX: number, clientY: number, button = 0): React.PointerEvent {
|
||||
return { button, clientX, clientY } as unknown as React.PointerEvent
|
||||
}
|
||||
|
||||
function firePointer(type: string, clientX: number, clientY: number): void {
|
||||
act(() => {
|
||||
window.dispatchEvent(new PointerEvent(type, { clientX, clientY, bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useTabStripPointerActivation', () => {
|
||||
it('activates on a release that never crossed the drag threshold (a click)', () => {
|
||||
const onActivate = vi.fn()
|
||||
const dragListener = vi.fn()
|
||||
const { result } = renderHook(() => useTabStripPointerActivation({ onActivate }))
|
||||
|
||||
act(() => result.current.onPointerDown(pointerDownEvent(10, 10), dragListener))
|
||||
// Why: the dnd-kit gesture must start on pointerdown even though activation
|
||||
// is deferred.
|
||||
expect(dragListener).toHaveBeenCalledTimes(1)
|
||||
expect(onActivate).not.toHaveBeenCalled()
|
||||
|
||||
// Release within the threshold -> click -> activate.
|
||||
firePointer('pointerup', 12, 11)
|
||||
expect(onActivate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('suppresses activation when the pointer travels past the drag threshold', () => {
|
||||
const onActivate = vi.fn()
|
||||
const { result } = renderHook(() => useTabStripPointerActivation({ onActivate }))
|
||||
|
||||
act(() => result.current.onPointerDown(pointerDownEvent(10, 10)))
|
||||
firePointer('pointermove', 10 + TAB_DRAG_ACTIVATION_DISTANCE_PX + 5, 10)
|
||||
firePointer('pointerup', 300, 40)
|
||||
|
||||
expect(onActivate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays a drag even if the pointer returns near the start before release', () => {
|
||||
const onActivate = vi.fn()
|
||||
const { result } = renderHook(() => useTabStripPointerActivation({ onActivate }))
|
||||
|
||||
act(() => result.current.onPointerDown(pointerDownEvent(10, 10)))
|
||||
// Cross the threshold, then come back over the origin and release.
|
||||
firePointer('pointermove', 200, 200)
|
||||
firePointer('pointerup', 11, 11)
|
||||
|
||||
expect(onActivate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not activate when the press is cancelled', () => {
|
||||
const onActivate = vi.fn()
|
||||
const { result } = renderHook(() => useTabStripPointerActivation({ onActivate }))
|
||||
|
||||
act(() => result.current.onPointerDown(pointerDownEvent(10, 10)))
|
||||
firePointer('pointercancel', 10, 10)
|
||||
firePointer('pointerup', 10, 10)
|
||||
|
||||
expect(onActivate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores non-left buttons and disabled presses', () => {
|
||||
const onActivate = vi.fn()
|
||||
const dragListener = vi.fn()
|
||||
const { result, rerender } = renderHook(
|
||||
({ disabled }: { disabled: boolean }) =>
|
||||
useTabStripPointerActivation({ onActivate, disabled }),
|
||||
{ initialProps: { disabled: false } }
|
||||
)
|
||||
|
||||
// Right-click: ignored.
|
||||
act(() => result.current.onPointerDown(pointerDownEvent(10, 10, 2), dragListener))
|
||||
firePointer('pointerup', 10, 10)
|
||||
expect(onActivate).not.toHaveBeenCalled()
|
||||
expect(dragListener).not.toHaveBeenCalled()
|
||||
|
||||
// Disabled: ignored.
|
||||
rerender({ disabled: true })
|
||||
act(() => result.current.onPointerDown(pointerDownEvent(10, 10), dragListener))
|
||||
firePointer('pointerup', 10, 10)
|
||||
expect(onActivate).not.toHaveBeenCalled()
|
||||
expect(dragListener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('activates a click that lands after a prior drag gesture (regression #6395)', () => {
|
||||
const onActivate = vi.fn()
|
||||
const { result } = renderHook(() => useTabStripPointerActivation({ onActivate }))
|
||||
|
||||
// First gesture: a drag (reorder). No activation.
|
||||
act(() => result.current.onPointerDown(pointerDownEvent(10, 10)))
|
||||
firePointer('pointermove', 300, 10)
|
||||
firePointer('pointerup', 300, 10)
|
||||
expect(onActivate).not.toHaveBeenCalled()
|
||||
|
||||
// Second gesture: a plain click. Must activate.
|
||||
act(() => result.current.onPointerDown(pointerDownEvent(400, 10)))
|
||||
firePointer('pointerup', 400, 10)
|
||||
expect(onActivate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { TAB_DRAG_ACTIVATION_DISTANCE_PX } from '../tab-group/useTabDragSplit'
|
||||
|
||||
/**
|
||||
* Defer tab activation to pointer-up and suppress it when the press turns into a
|
||||
* drag. PR #5927 shipped this so dragging a tab (to reorder, move into another
|
||||
* pane, or split) never switched the active tab or stole terminal focus
|
||||
* mid-gesture; #6395 removed it (activating eagerly on pointerdown) to fix
|
||||
* click-to-switch-after-reorder, which regressed the drag feature.
|
||||
*
|
||||
* We gate on measured pointer DISPLACEMENT, not the drag-active context ref the
|
||||
* old hook used — that ref clears asynchronously relative to the drop's
|
||||
* pointerup, which is what made #6395's click-after-reorder misfire. Displacement
|
||||
* mirrors dnd-kit's own activation threshold exactly: once the pointer travels
|
||||
* past it the gesture is a drag (activation suppressed); a release within it is a
|
||||
* click (activate). Because each press measures its own gesture, a click after a
|
||||
* reorder always activates.
|
||||
*/
|
||||
export function useTabStripPointerActivation({
|
||||
onActivate,
|
||||
disabled = false
|
||||
}: {
|
||||
onActivate: () => void
|
||||
disabled?: boolean
|
||||
}): {
|
||||
onPointerDown: (
|
||||
event: React.PointerEvent,
|
||||
dragListener?: (event: React.PointerEvent<Element>) => void
|
||||
) => void
|
||||
} {
|
||||
const onActivateRef = useRef(onActivate)
|
||||
onActivateRef.current = onActivate
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
|
||||
// Why: a press still holding when the tab unmounts (tab closed mid-drag, group
|
||||
// collapse) would otherwise leak its window listeners and later fire activation
|
||||
// on a dead closure.
|
||||
useEffect(() => () => cleanupRef.current?.(), [])
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(event: React.PointerEvent, dragListener?: (event: React.PointerEvent<Element>) => void) => {
|
||||
if (disabled || event.button !== 0) {
|
||||
return
|
||||
}
|
||||
// Why: start the dnd-kit gesture immediately on pointerdown; only the
|
||||
// activation decision is deferred to release.
|
||||
dragListener?.(event)
|
||||
|
||||
cleanupRef.current?.()
|
||||
const startX = event.clientX
|
||||
const startY = event.clientY
|
||||
let draggedPastThreshold = false
|
||||
|
||||
const cleanup = (): void => {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', onPointerCancel)
|
||||
cleanupRef.current = null
|
||||
}
|
||||
const onPointerMove = (moveEvent: PointerEvent): void => {
|
||||
if (
|
||||
Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) >=
|
||||
TAB_DRAG_ACTIVATION_DISTANCE_PX
|
||||
) {
|
||||
draggedPastThreshold = true
|
||||
}
|
||||
}
|
||||
const onPointerUp = (): void => {
|
||||
const wasDrag = draggedPastThreshold
|
||||
cleanup()
|
||||
// Why: only a release that never crossed the drag threshold is a click.
|
||||
// Activating after a real drag would yank the just-dropped tab's pane
|
||||
// back to the source selection.
|
||||
if (!wasDrag) {
|
||||
onActivateRef.current()
|
||||
}
|
||||
}
|
||||
const onPointerCancel = (): void => {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('pointercancel', onPointerCancel)
|
||||
cleanupRef.current = cleanup
|
||||
},
|
||||
[disabled]
|
||||
)
|
||||
|
||||
return { onPointerDown }
|
||||
}
|
||||
@@ -225,7 +225,9 @@ export default function TabGroupPanel({
|
||||
user can only drag from the tiny left-sidebar header strip. */}
|
||||
<div
|
||||
className="h-[32px] shrink-0 border-b border-border bg-card"
|
||||
data-tab-group-strip-id={groupId}
|
||||
data-terminal-focus-release-surface="true"
|
||||
data-worktree-id={worktreeId}
|
||||
>
|
||||
<div className="flex h-full items-stretch pr-1.5">
|
||||
{/* Why: Electron's native drag hit-test only respects no-drag on DOM
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
resolveOpaqueTerminalBackground,
|
||||
resolveEffectiveTerminalAppearance
|
||||
} from '@/lib/terminal-theme'
|
||||
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type {
|
||||
ManagedPane,
|
||||
PaneExternalDropTarget,
|
||||
PaneManager
|
||||
} from '@/lib/pane-manager/pane-manager'
|
||||
import TerminalSearch from '@/components/TerminalSearch'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import { fitPanes, isWindowsUserAgent } from './pane-helpers'
|
||||
@@ -62,6 +66,11 @@ import { useSystemPrefersDark } from './use-system-prefers-dark'
|
||||
import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects'
|
||||
import { useTerminalPaneLifecycle } from './use-terminal-pane-lifecycle'
|
||||
import { useTerminalPaneContextMenu } from './use-terminal-pane-context-menu'
|
||||
import {
|
||||
detachTerminalPaneToTab,
|
||||
isTerminalTabStripDropTarget,
|
||||
resolveTerminalTabStripDropTarget
|
||||
} from './terminal-pane-tab-detach'
|
||||
import type { PreparedAgentSessionFork } from './terminal-agent-session-fork'
|
||||
import { useNotificationDispatch } from './use-notification-dispatch'
|
||||
import { connectPanePty } from './pty-connection'
|
||||
@@ -1298,6 +1307,54 @@ export default function TerminalPane({
|
||||
setPendingCloseConfirmation(null)
|
||||
}, [])
|
||||
|
||||
const resolveExternalPaneDropTarget = useCallback(
|
||||
({
|
||||
sourcePaneId,
|
||||
clientX,
|
||||
clientY
|
||||
}: {
|
||||
sourcePaneId: number
|
||||
clientX: number
|
||||
clientY: number
|
||||
}): PaneExternalDropTarget | null => {
|
||||
const manager = managerRef.current
|
||||
const panes = manager?.getPanes() ?? []
|
||||
if (panes.length <= 1 || !panes.some((pane) => pane.id === sourcePaneId)) {
|
||||
return null
|
||||
}
|
||||
return resolveTerminalTabStripDropTarget({
|
||||
clientX,
|
||||
clientY,
|
||||
groupsByWorktree: useAppStore.getState().groupsByWorktree,
|
||||
worktreeId
|
||||
})
|
||||
},
|
||||
[worktreeId]
|
||||
)
|
||||
|
||||
const handleExternalPaneDrop = useCallback(
|
||||
(sourcePaneId: number, target: PaneExternalDropTarget): boolean => {
|
||||
if (!isTerminalTabStripDropTarget(target)) {
|
||||
return false
|
||||
}
|
||||
const fallbackPtyId = paneTransportsRef.current.get(sourcePaneId)?.getPtyId() ?? null
|
||||
return (
|
||||
detachTerminalPaneToTab({
|
||||
fallbackPtyId,
|
||||
getStore: useAppStore.getState,
|
||||
manager: managerRef.current,
|
||||
persistLayoutSnapshot,
|
||||
sourcePaneId,
|
||||
sourceTabId: tabId,
|
||||
targetGroupId: target.groupId,
|
||||
targetIndex: target.insertionIndex,
|
||||
worktreeId
|
||||
}) !== null
|
||||
)
|
||||
},
|
||||
[persistLayoutSnapshot, tabId, worktreeId]
|
||||
)
|
||||
|
||||
useTerminalPaneLifecycle({
|
||||
tabId,
|
||||
worktreeId,
|
||||
@@ -1354,7 +1411,9 @@ export default function TerminalPane({
|
||||
paneTitlesRef,
|
||||
setRenamingPaneId,
|
||||
setPaneCount,
|
||||
setPaneLayoutRevision
|
||||
setPaneLayoutRevision,
|
||||
resolveExternalPaneDropTarget,
|
||||
onExternalPaneDrop: handleExternalPaneDrop
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/types'
|
||||
import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach'
|
||||
|
||||
const LEAF_1 = '11111111-1111-4111-8111-111111111111'
|
||||
const LEAF_2 = '22222222-2222-4222-8222-222222222222'
|
||||
const LEAF_3 = '33333333-3333-4333-8333-333333333333'
|
||||
|
||||
function splitLayout(): TerminalLayoutSnapshot {
|
||||
return {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
ratio: 0.25,
|
||||
first: { type: 'leaf', leafId: LEAF_1 },
|
||||
second: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
ratio: 0.7,
|
||||
first: { type: 'leaf', leafId: LEAF_2 },
|
||||
second: { type: 'leaf', leafId: LEAF_3 }
|
||||
}
|
||||
},
|
||||
activeLeafId: LEAF_2,
|
||||
expandedLeafId: LEAF_3,
|
||||
ptyIdsByLeafId: {
|
||||
[LEAF_1]: 'pty-1',
|
||||
[LEAF_2]: 'remote:env-1@@terminal-1',
|
||||
[LEAF_3]: 'pty-3'
|
||||
},
|
||||
buffersByLeafId: {
|
||||
[LEAF_1]: 'buffer-1',
|
||||
[LEAF_2]: 'buffer-2',
|
||||
[LEAF_3]: 'buffer-3'
|
||||
},
|
||||
scrollbackRefsByLeafId: {
|
||||
[LEAF_1]: 'scrollback-1',
|
||||
[LEAF_2]: 'scrollback-2',
|
||||
[LEAF_3]: 'scrollback-3'
|
||||
},
|
||||
titlesByLeafId: {
|
||||
[LEAF_1]: 'one',
|
||||
[LEAF_2]: 'remote shell',
|
||||
[LEAF_3]: 'three'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('detachTerminalLayoutLeaf', () => {
|
||||
it('extracts a nested leaf into a single-pane layout while preserving SSH PTY state', () => {
|
||||
const detached = detachTerminalLayoutLeaf(splitLayout(), LEAF_2)
|
||||
|
||||
expect(detached?.ptyId).toBe('remote:env-1@@terminal-1')
|
||||
expect(detached?.detachedLayout).toEqual({
|
||||
root: { type: 'leaf', leafId: LEAF_2 },
|
||||
activeLeafId: LEAF_2,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' },
|
||||
buffersByLeafId: { [LEAF_2]: 'buffer-2' },
|
||||
scrollbackRefsByLeafId: { [LEAF_2]: 'scrollback-2' },
|
||||
titlesByLeafId: { [LEAF_2]: 'remote shell' }
|
||||
})
|
||||
expect(detached?.sourceLayout).toEqual({
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
ratio: 0.25,
|
||||
first: { type: 'leaf', leafId: LEAF_1 },
|
||||
second: { type: 'leaf', leafId: LEAF_3 }
|
||||
},
|
||||
activeLeafId: LEAF_1,
|
||||
expandedLeafId: LEAF_3,
|
||||
ptyIdsByLeafId: {
|
||||
[LEAF_1]: 'pty-1',
|
||||
[LEAF_3]: 'pty-3'
|
||||
},
|
||||
buffersByLeafId: {
|
||||
[LEAF_1]: 'buffer-1',
|
||||
[LEAF_3]: 'buffer-3'
|
||||
},
|
||||
scrollbackRefsByLeafId: {
|
||||
[LEAF_1]: 'scrollback-1',
|
||||
[LEAF_3]: 'scrollback-3'
|
||||
},
|
||||
titlesByLeafId: {
|
||||
[LEAF_1]: 'one',
|
||||
[LEAF_3]: 'three'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('clears source expanded selection when the expanded leaf is detached', () => {
|
||||
const detached = detachTerminalLayoutLeaf(splitLayout(), LEAF_3)
|
||||
|
||||
expect(detached?.sourceLayout.expandedLeafId).toBeNull()
|
||||
expect(detached?.sourceLayout.root).toEqual({
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
ratio: 0.25,
|
||||
first: { type: 'leaf', leafId: LEAF_1 },
|
||||
second: { type: 'leaf', leafId: LEAF_2 }
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for missing or only leaf layouts', () => {
|
||||
expect(detachTerminalLayoutLeaf(splitLayout(), 'missing')).toBeNull()
|
||||
expect(
|
||||
detachTerminalLayoutLeaf(
|
||||
{
|
||||
root: { type: 'leaf', leafId: LEAF_1 },
|
||||
activeLeafId: LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_1]: 'pty-1' }
|
||||
},
|
||||
LEAF_1
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types'
|
||||
import {
|
||||
collectLeafIdsInOrder,
|
||||
normalizeTerminalLayoutSnapshot,
|
||||
resolveTerminalLayoutActiveLeafId
|
||||
} from './terminal-layout-leaf-ids'
|
||||
|
||||
export type DetachedTerminalLayoutLeaf = {
|
||||
sourceLayout: TerminalLayoutSnapshot
|
||||
detachedLayout: TerminalLayoutSnapshot
|
||||
ptyId: string | null
|
||||
}
|
||||
|
||||
function removeLeafFromTree(
|
||||
node: TerminalPaneLayoutNode,
|
||||
leafId: string
|
||||
): { node: TerminalPaneLayoutNode | null; removed: boolean } {
|
||||
if (node.type === 'leaf') {
|
||||
return node.leafId === leafId ? { node: null, removed: true } : { node, removed: false }
|
||||
}
|
||||
|
||||
const first = removeLeafFromTree(node.first, leafId)
|
||||
const second = removeLeafFromTree(node.second, leafId)
|
||||
if (!first.removed && !second.removed) {
|
||||
return { node, removed: false }
|
||||
}
|
||||
if (!first.node) {
|
||||
return { node: second.node, removed: true }
|
||||
}
|
||||
if (!second.node) {
|
||||
return { node: first.node, removed: true }
|
||||
}
|
||||
return {
|
||||
node: {
|
||||
...node,
|
||||
first: first.node,
|
||||
second: second.node
|
||||
},
|
||||
removed: true
|
||||
}
|
||||
}
|
||||
|
||||
function omitLeafRecord(
|
||||
source: Record<string, string> | undefined,
|
||||
leafId: string
|
||||
): Record<string, string> | undefined {
|
||||
if (!source || !Object.prototype.hasOwnProperty.call(source, leafId)) {
|
||||
return source
|
||||
}
|
||||
const next = { ...source }
|
||||
delete next[leafId]
|
||||
return Object.keys(next).length > 0 ? next : undefined
|
||||
}
|
||||
|
||||
function singleLeafRecord(
|
||||
source: Record<string, string> | undefined,
|
||||
leafId: string
|
||||
): Record<string, string> | undefined {
|
||||
const value = source?.[leafId]
|
||||
return value ? { [leafId]: value } : undefined
|
||||
}
|
||||
|
||||
export function detachTerminalLayoutLeaf(
|
||||
snapshot: TerminalLayoutSnapshot | null | undefined,
|
||||
leafId: string
|
||||
): DetachedTerminalLayoutLeaf | null {
|
||||
const layout = normalizeTerminalLayoutSnapshot(snapshot).snapshot
|
||||
if (!layout.root) {
|
||||
return null
|
||||
}
|
||||
|
||||
const originalLeafIds = collectLeafIdsInOrder(layout.root)
|
||||
if (!originalLeafIds.includes(leafId) || originalLeafIds.length <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const removal = removeLeafFromTree(layout.root, leafId)
|
||||
if (!removal.removed || !removal.node) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ptyIdsByLeafId = omitLeafRecord(layout.ptyIdsByLeafId, leafId)
|
||||
const buffersByLeafId = omitLeafRecord(layout.buffersByLeafId, leafId)
|
||||
const scrollbackRefsByLeafId = omitLeafRecord(layout.scrollbackRefsByLeafId, leafId)
|
||||
const titlesByLeafId = omitLeafRecord(layout.titlesByLeafId, leafId)
|
||||
const sourceLayout: TerminalLayoutSnapshot = {
|
||||
root: removal.node,
|
||||
activeLeafId: resolveTerminalLayoutActiveLeafId({
|
||||
root: removal.node,
|
||||
activeLeafId: layout.activeLeafId === leafId ? null : layout.activeLeafId,
|
||||
ptyIdsByLeafId
|
||||
}),
|
||||
expandedLeafId: layout.expandedLeafId === leafId ? null : layout.expandedLeafId,
|
||||
...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}),
|
||||
...(buffersByLeafId ? { buffersByLeafId } : {}),
|
||||
...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}),
|
||||
...(titlesByLeafId ? { titlesByLeafId } : {})
|
||||
}
|
||||
|
||||
const detachedPtyIdsByLeafId = singleLeafRecord(layout.ptyIdsByLeafId, leafId)
|
||||
const detachedBuffersByLeafId = singleLeafRecord(layout.buffersByLeafId, leafId)
|
||||
const detachedScrollbackRefsByLeafId = singleLeafRecord(layout.scrollbackRefsByLeafId, leafId)
|
||||
const detachedTitlesByLeafId = singleLeafRecord(layout.titlesByLeafId, leafId)
|
||||
return {
|
||||
sourceLayout,
|
||||
detachedLayout: {
|
||||
root: { type: 'leaf', leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
...(detachedPtyIdsByLeafId ? { ptyIdsByLeafId: detachedPtyIdsByLeafId } : {}),
|
||||
...(detachedBuffersByLeafId ? { buffersByLeafId: detachedBuffersByLeafId } : {}),
|
||||
...(detachedScrollbackRefsByLeafId
|
||||
? { scrollbackRefsByLeafId: detachedScrollbackRefsByLeafId }
|
||||
: {}),
|
||||
...(detachedTitlesByLeafId ? { titlesByLeafId: detachedTitlesByLeafId } : {})
|
||||
},
|
||||
ptyId: detachedPtyIdsByLeafId?.[leafId] ?? null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '@/store'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types'
|
||||
import {
|
||||
detachTerminalPaneToTab,
|
||||
resolveTerminalTabStripDropTarget,
|
||||
type TerminalPaneTabDetachStore
|
||||
} from './terminal-pane-tab-detach'
|
||||
|
||||
const WORKTREE_ID = 'repo-1::/worktree'
|
||||
const SOURCE_TAB_ID = 'tab-source'
|
||||
const TARGET_GROUP_ID = 'group-target'
|
||||
const EXISTING_TAB_1 = 'tab-existing-1'
|
||||
const EXISTING_TAB_2 = 'tab-existing-2'
|
||||
const LEAF_1 = '11111111-1111-4111-8111-111111111111'
|
||||
const LEAF_2 = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function rect(args: { left: number; top: number; width: number; height: number }): DOMRect {
|
||||
return {
|
||||
left: args.left,
|
||||
top: args.top,
|
||||
right: args.left + args.width,
|
||||
bottom: args.top + args.height,
|
||||
width: args.width,
|
||||
height: args.height
|
||||
} as DOMRect
|
||||
}
|
||||
|
||||
function splitLayout(): TerminalLayoutSnapshot {
|
||||
return {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: LEAF_1 },
|
||||
second: { type: 'leaf', leafId: LEAF_2 }
|
||||
},
|
||||
activeLeafId: LEAF_2,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {
|
||||
[LEAF_1]: 'pty-left',
|
||||
[LEAF_2]: 'remote:env-1@@terminal-1'
|
||||
},
|
||||
buffersByLeafId: {
|
||||
[LEAF_2]: 'remote-buffer'
|
||||
},
|
||||
titlesByLeafId: {
|
||||
[LEAF_2]: 'remote shell'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createTerminalTab(id: string, ptyId: string | null): TerminalTab {
|
||||
return {
|
||||
id,
|
||||
ptyId,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Terminal 2',
|
||||
defaultTitle: 'Terminal 2',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 1,
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function createStore(
|
||||
layout: TerminalLayoutSnapshot = splitLayout(),
|
||||
targetTabOrder: string[] = [EXISTING_TAB_1, EXISTING_TAB_2]
|
||||
): TerminalPaneTabDetachStore {
|
||||
const store = {
|
||||
createTab: vi.fn((_worktreeId, _targetGroupId, _shellOverride, options) => {
|
||||
const tab = createTerminalTab('tab-detached', options?.initialPtyId ?? null)
|
||||
const group = store.groupsByWorktree[WORKTREE_ID]?.find(
|
||||
(candidate) => candidate.id === TARGET_GROUP_ID
|
||||
)
|
||||
if (group && !group.tabOrder.includes(tab.id)) {
|
||||
group.tabOrder = [...group.tabOrder, tab.id]
|
||||
}
|
||||
return tab
|
||||
}),
|
||||
groupsByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
id: TARGET_GROUP_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
activeTabId: targetTabOrder[0] ?? null,
|
||||
tabOrder: targetTabOrder,
|
||||
recentTabIds: []
|
||||
}
|
||||
]
|
||||
},
|
||||
reorderUnifiedTabs: vi.fn((groupId: string, tabIds: string[]) => {
|
||||
const group = store.groupsByWorktree[WORKTREE_ID]?.find(
|
||||
(candidate) => candidate.id === groupId
|
||||
)
|
||||
if (group) {
|
||||
group.tabOrder = tabIds
|
||||
}
|
||||
}),
|
||||
setActiveTab: vi.fn(),
|
||||
setActiveTabType: vi.fn(),
|
||||
setTabLayout: vi.fn((tabId: string, nextLayout: TerminalLayoutSnapshot | null) => {
|
||||
if (nextLayout) {
|
||||
store.terminalLayoutsByTabId[tabId] = nextLayout
|
||||
} else {
|
||||
delete store.terminalLayoutsByTabId[tabId]
|
||||
}
|
||||
}),
|
||||
syncPaneDetachPtyOwnership: vi.fn(),
|
||||
terminalLayoutsByTabId: {
|
||||
[SOURCE_TAB_ID]: layout
|
||||
}
|
||||
}
|
||||
return store as unknown as TerminalPaneTabDetachStore
|
||||
}
|
||||
|
||||
describe('resolveTerminalTabStripDropTarget', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('finds a same-worktree tab strip under overlay elements', () => {
|
||||
const stripRect = rect({ left: 0, top: 0, width: 300, height: 32 })
|
||||
const strip = {
|
||||
dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: WORKTREE_ID },
|
||||
getBoundingClientRect: () => stripRect,
|
||||
querySelectorAll: () => []
|
||||
}
|
||||
const overlay = { closest: () => null }
|
||||
const child = { closest: () => strip }
|
||||
vi.stubGlobal('document', {
|
||||
elementsFromPoint: vi.fn(() => [overlay, child]),
|
||||
elementFromPoint: vi.fn()
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveTerminalTabStripDropTarget({
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
groupsByWorktree: {
|
||||
[WORKTREE_ID]: [{ id: TARGET_GROUP_ID } as AppState['groupsByWorktree'][string][number]]
|
||||
},
|
||||
worktreeId: WORKTREE_ID
|
||||
})
|
||||
).toEqual({
|
||||
id: TARGET_GROUP_ID,
|
||||
groupId: TARGET_GROUP_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
rect: stripRect
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves the insertion slot from the hovered tab side', () => {
|
||||
const stripRect = rect({ left: 0, top: 0, width: 300, height: 32 })
|
||||
const firstTabRect = rect({ left: 0, top: 0, width: 80, height: 32 })
|
||||
const secondTabRect = rect({ left: 80, top: 0, width: 80, height: 32 })
|
||||
const firstTab = {
|
||||
dataset: { tabId: EXISTING_TAB_1 },
|
||||
getBoundingClientRect: () => firstTabRect
|
||||
}
|
||||
const secondTab = {
|
||||
dataset: { tabId: EXISTING_TAB_2 },
|
||||
getBoundingClientRect: () => secondTabRect
|
||||
}
|
||||
const strip = {
|
||||
dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: WORKTREE_ID },
|
||||
getBoundingClientRect: () => stripRect,
|
||||
querySelectorAll: () => [firstTab, secondTab]
|
||||
}
|
||||
vi.stubGlobal('document', {
|
||||
elementsFromPoint: vi.fn(() => [{ closest: () => firstTab }, { closest: () => strip }]),
|
||||
elementFromPoint: vi.fn()
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveTerminalTabStripDropTarget({
|
||||
clientX: 60,
|
||||
clientY: 10,
|
||||
groupsByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
id: TARGET_GROUP_ID,
|
||||
activeTabId: EXISTING_TAB_1,
|
||||
tabOrder: [EXISTING_TAB_1, EXISTING_TAB_2],
|
||||
worktreeId: WORKTREE_ID
|
||||
} as AppState['groupsByWorktree'][string][number]
|
||||
]
|
||||
},
|
||||
worktreeId: WORKTREE_ID
|
||||
})
|
||||
).toMatchObject({
|
||||
groupId: TARGET_GROUP_ID,
|
||||
insertionIndex: 1,
|
||||
overlayKind: 'insertion',
|
||||
rect: rect({ left: 80, top: 0, width: 2, height: 32 })
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores strips from another worktree', () => {
|
||||
const strip = {
|
||||
dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: 'other-worktree' },
|
||||
getBoundingClientRect: () =>
|
||||
({ left: 0, top: 0, right: 300, bottom: 32, width: 300, height: 32 }) as DOMRect
|
||||
}
|
||||
vi.stubGlobal('document', {
|
||||
elementsFromPoint: vi.fn(() => [{ closest: () => strip }]),
|
||||
elementFromPoint: vi.fn()
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveTerminalTabStripDropTarget({
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
groupsByWorktree: {
|
||||
[WORKTREE_ID]: [{ id: TARGET_GROUP_ID } as AppState['groupsByWorktree'][string][number]]
|
||||
},
|
||||
worktreeId: WORKTREE_ID
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detachTerminalPaneToTab', () => {
|
||||
it('creates a new terminal tab with the detached leaf layout and PTY id', () => {
|
||||
const store = createStore()
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
|
||||
getLeafId: vi.fn((paneId: number) => (paneId === 2 ? LEAF_2 : LEAF_1)),
|
||||
detachPaneForExternalMove: vi.fn(() => true)
|
||||
}
|
||||
const persistLayoutSnapshot = vi.fn()
|
||||
|
||||
const result = detachTerminalPaneToTab({
|
||||
manager,
|
||||
getStore: () => store,
|
||||
persistLayoutSnapshot,
|
||||
sourcePaneId: 2,
|
||||
sourceTabId: SOURCE_TAB_ID,
|
||||
targetGroupId: TARGET_GROUP_ID,
|
||||
worktreeId: WORKTREE_ID
|
||||
})
|
||||
|
||||
expect(result?.ptyId).toBe('remote:env-1@@terminal-1')
|
||||
expect(manager.detachPaneForExternalMove).toHaveBeenCalledWith(2)
|
||||
expect(store.createTab).toHaveBeenCalledWith(WORKTREE_ID, TARGET_GROUP_ID, undefined, {
|
||||
activate: true,
|
||||
initialPtyId: 'remote:env-1@@terminal-1',
|
||||
recordInteraction: true
|
||||
})
|
||||
expect(store.setTabLayout).toHaveBeenCalledWith(SOURCE_TAB_ID, {
|
||||
root: { type: 'leaf', leafId: LEAF_1 },
|
||||
activeLeafId: LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_1]: 'pty-left' }
|
||||
})
|
||||
expect(store.setTabLayout).toHaveBeenCalledWith('tab-detached', {
|
||||
root: { type: 'leaf', leafId: LEAF_2 },
|
||||
activeLeafId: LEAF_2,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' },
|
||||
buffersByLeafId: { [LEAF_2]: 'remote-buffer' },
|
||||
titlesByLeafId: { [LEAF_2]: 'remote shell' }
|
||||
})
|
||||
expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith({
|
||||
detachedPtyId: 'remote:env-1@@terminal-1',
|
||||
sourceLayout: {
|
||||
root: { type: 'leaf', leafId: LEAF_1 },
|
||||
activeLeafId: LEAF_1,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_1]: 'pty-left' }
|
||||
},
|
||||
sourceTabId: SOURCE_TAB_ID,
|
||||
targetTabId: 'tab-detached'
|
||||
})
|
||||
expect(store.setActiveTab).toHaveBeenCalledWith('tab-detached')
|
||||
expect(store.setActiveTabType).toHaveBeenCalledWith('terminal')
|
||||
expect(persistLayoutSnapshot).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('syncs PTY ownership when the primary source pane is detached', () => {
|
||||
const store = createStore()
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
|
||||
getLeafId: vi.fn((paneId: number) => (paneId === 1 ? LEAF_1 : LEAF_2)),
|
||||
detachPaneForExternalMove: vi.fn(() => true)
|
||||
}
|
||||
|
||||
const result = detachTerminalPaneToTab({
|
||||
getStore: () => store,
|
||||
manager,
|
||||
persistLayoutSnapshot: vi.fn(),
|
||||
sourcePaneId: 1,
|
||||
sourceTabId: SOURCE_TAB_ID,
|
||||
targetGroupId: TARGET_GROUP_ID,
|
||||
worktreeId: WORKTREE_ID
|
||||
})
|
||||
|
||||
expect(result?.ptyId).toBe('pty-left')
|
||||
expect(store.setTabLayout).toHaveBeenCalledWith(SOURCE_TAB_ID, {
|
||||
root: { type: 'leaf', leafId: LEAF_2 },
|
||||
activeLeafId: LEAF_2,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' },
|
||||
buffersByLeafId: { [LEAF_2]: 'remote-buffer' },
|
||||
titlesByLeafId: { [LEAF_2]: 'remote shell' }
|
||||
})
|
||||
expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith({
|
||||
detachedPtyId: 'pty-left',
|
||||
sourceLayout: {
|
||||
root: { type: 'leaf', leafId: LEAF_2 },
|
||||
activeLeafId: LEAF_2,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' },
|
||||
buffersByLeafId: { [LEAF_2]: 'remote-buffer' },
|
||||
titlesByLeafId: { [LEAF_2]: 'remote shell' }
|
||||
},
|
||||
sourceTabId: SOURCE_TAB_ID,
|
||||
targetTabId: 'tab-detached'
|
||||
})
|
||||
})
|
||||
|
||||
it('moves the detached tab into the requested group slot', () => {
|
||||
const store = createStore(splitLayout(), [EXISTING_TAB_1, EXISTING_TAB_2])
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
|
||||
getLeafId: vi.fn((paneId: number) => (paneId === 2 ? LEAF_2 : LEAF_1)),
|
||||
detachPaneForExternalMove: vi.fn(() => true)
|
||||
}
|
||||
|
||||
detachTerminalPaneToTab({
|
||||
getStore: () => store,
|
||||
manager,
|
||||
persistLayoutSnapshot: vi.fn(),
|
||||
sourcePaneId: 2,
|
||||
sourceTabId: SOURCE_TAB_ID,
|
||||
targetGroupId: TARGET_GROUP_ID,
|
||||
targetIndex: 1,
|
||||
worktreeId: WORKTREE_ID
|
||||
})
|
||||
|
||||
expect(store.reorderUnifiedTabs).toHaveBeenCalledWith(
|
||||
TARGET_GROUP_ID,
|
||||
[EXISTING_TAB_1, 'tab-detached', EXISTING_TAB_2],
|
||||
{ recordInteraction: false }
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the live transport PTY id when the snapshot has not persisted it yet', () => {
|
||||
const store = createStore({
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: LEAF_1 },
|
||||
second: { type: 'leaf', leafId: LEAF_2 }
|
||||
},
|
||||
activeLeafId: LEAF_2,
|
||||
expandedLeafId: null
|
||||
})
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
|
||||
getLeafId: vi.fn(() => LEAF_2),
|
||||
detachPaneForExternalMove: vi.fn(() => true)
|
||||
}
|
||||
|
||||
detachTerminalPaneToTab({
|
||||
fallbackPtyId: 'remote:env-2@@terminal-9',
|
||||
getStore: () => store,
|
||||
manager,
|
||||
persistLayoutSnapshot: vi.fn(),
|
||||
sourcePaneId: 2,
|
||||
sourceTabId: SOURCE_TAB_ID,
|
||||
targetGroupId: TARGET_GROUP_ID,
|
||||
worktreeId: WORKTREE_ID
|
||||
})
|
||||
|
||||
expect(store.setTabLayout).toHaveBeenCalledWith('tab-detached', {
|
||||
root: { type: 'leaf', leafId: LEAF_2 },
|
||||
activeLeafId: LEAF_2,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-2@@terminal-9' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { PaneExternalDropTarget } from '@/lib/pane-manager/pane-manager'
|
||||
import type { AppState } from '@/store'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach'
|
||||
|
||||
const TAB_GROUP_STRIP_SELECTOR = '[data-tab-group-strip-id][data-worktree-id]'
|
||||
|
||||
export type TerminalPaneTabDetachStore = Pick<
|
||||
AppState,
|
||||
| 'createTab'
|
||||
| 'groupsByWorktree'
|
||||
| 'reorderUnifiedTabs'
|
||||
| 'setActiveTab'
|
||||
| 'setActiveTabType'
|
||||
| 'setTabLayout'
|
||||
| 'syncPaneDetachPtyOwnership'
|
||||
| 'terminalLayoutsByTabId'
|
||||
>
|
||||
|
||||
type TerminalPaneTabDetachManager = {
|
||||
getPanes: () => readonly { id: number }[]
|
||||
getLeafId: (paneId: number) => string | null
|
||||
detachPaneForExternalMove: (paneId: number) => boolean
|
||||
}
|
||||
|
||||
export type TerminalTabStripDropTarget = PaneExternalDropTarget & {
|
||||
groupId: string
|
||||
insertionIndex?: number
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
export type DetachedTerminalPaneTab = {
|
||||
tab: TerminalTab
|
||||
leafId: string
|
||||
ptyId: string | null
|
||||
}
|
||||
|
||||
function pointWithinRect(clientX: number, clientY: number, rect: DOMRect): boolean {
|
||||
return (
|
||||
clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom
|
||||
)
|
||||
}
|
||||
|
||||
function rectFromBox(args: { left: number; top: number; width: number; height: number }): DOMRect {
|
||||
return {
|
||||
left: args.left,
|
||||
top: args.top,
|
||||
right: args.left + args.width,
|
||||
bottom: args.top + args.height,
|
||||
width: args.width,
|
||||
height: args.height
|
||||
} as DOMRect
|
||||
}
|
||||
|
||||
function clampIndex(index: number, max: number): number {
|
||||
return Math.min(Math.max(index, 0), max)
|
||||
}
|
||||
|
||||
function getTabElements(strip: HTMLElement): HTMLElement[] {
|
||||
return Array.from(strip.querySelectorAll<HTMLElement>('[data-tab-id]')).filter(
|
||||
(element) => typeof element.dataset.tabId === 'string' && element.dataset.tabId.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function getInsertionMarkerRect(
|
||||
tabRects: DOMRect[],
|
||||
insertionIndex: number,
|
||||
stripRect: DOMRect
|
||||
): DOMRect {
|
||||
const markerWidth = 2
|
||||
const clampedIndex = clampIndex(insertionIndex, tabRects.length)
|
||||
const rawLeft =
|
||||
clampedIndex < tabRects.length
|
||||
? (tabRects[clampedIndex]?.left ?? stripRect.left)
|
||||
: (tabRects.at(-1)?.right ?? stripRect.left) - markerWidth
|
||||
const left = Math.min(Math.max(rawLeft, stripRect.left), stripRect.right - markerWidth)
|
||||
return rectFromBox({ left, top: stripRect.top, width: markerWidth, height: stripRect.height })
|
||||
}
|
||||
|
||||
function resolveTabStripInsertion(args: {
|
||||
clientX: number
|
||||
clientY: number
|
||||
groupTabOrderLength: number
|
||||
strip: HTMLElement
|
||||
stripRect: DOMRect
|
||||
}): { index: number; rect: DOMRect } | null {
|
||||
const tabs = getTabElements(args.strip)
|
||||
if (tabs.length === 0) {
|
||||
return null
|
||||
}
|
||||
const tabRects = tabs.map((tab) => tab.getBoundingClientRect())
|
||||
|
||||
for (let index = 0; index < tabRects.length; index += 1) {
|
||||
const tabRect = tabRects[index]
|
||||
if (!tabRect) {
|
||||
continue
|
||||
}
|
||||
if (args.clientX < tabRect.left) {
|
||||
const insertionIndex = clampIndex(index, args.groupTabOrderLength)
|
||||
return {
|
||||
index: insertionIndex,
|
||||
rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect)
|
||||
}
|
||||
}
|
||||
if (pointWithinRect(args.clientX, args.clientY, tabRect)) {
|
||||
const insertionIndex = clampIndex(
|
||||
index + (args.clientX < tabRect.left + tabRect.width / 2 ? 0 : 1),
|
||||
args.groupTabOrderLength
|
||||
)
|
||||
return {
|
||||
index: insertionIndex,
|
||||
rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const insertionIndex = args.groupTabOrderLength
|
||||
return {
|
||||
index: insertionIndex,
|
||||
rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect)
|
||||
}
|
||||
}
|
||||
|
||||
function getElementsFromPoint(clientX: number, clientY: number): Element[] {
|
||||
if (typeof document === 'undefined') {
|
||||
return []
|
||||
}
|
||||
const elements = document.elementsFromPoint?.(clientX, clientY)
|
||||
if (elements && elements.length > 0) {
|
||||
return elements
|
||||
}
|
||||
const element = document.elementFromPoint?.(clientX, clientY)
|
||||
return element ? [element] : []
|
||||
}
|
||||
|
||||
export function resolveTerminalTabStripDropTarget(args: {
|
||||
clientX: number
|
||||
clientY: number
|
||||
groupsByWorktree: TerminalPaneTabDetachStore['groupsByWorktree']
|
||||
worktreeId: string
|
||||
}): TerminalTabStripDropTarget | null {
|
||||
const groups = args.groupsByWorktree[args.worktreeId] ?? []
|
||||
const groupById = new Map(groups.map((group) => [group.id, group]))
|
||||
const validGroupIds = new Set(groups.map((group) => group.id))
|
||||
if (validGroupIds.size === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const element of getElementsFromPoint(args.clientX, args.clientY)) {
|
||||
const strip = element.closest<HTMLElement>(TAB_GROUP_STRIP_SELECTOR)
|
||||
const groupId = strip?.dataset.tabGroupStripId
|
||||
const worktreeId = strip?.dataset.worktreeId
|
||||
if (!strip || !groupId || worktreeId !== args.worktreeId || !validGroupIds.has(groupId)) {
|
||||
continue
|
||||
}
|
||||
const rect = strip.getBoundingClientRect()
|
||||
if (!pointWithinRect(args.clientX, args.clientY, rect)) {
|
||||
continue
|
||||
}
|
||||
const group = groupById.get(groupId)
|
||||
const insertion = group
|
||||
? resolveTabStripInsertion({
|
||||
clientX: args.clientX,
|
||||
clientY: args.clientY,
|
||||
groupTabOrderLength: group.tabOrder?.length ?? 0,
|
||||
strip,
|
||||
stripRect: rect
|
||||
})
|
||||
: null
|
||||
return insertion
|
||||
? {
|
||||
id: groupId,
|
||||
groupId,
|
||||
insertionIndex: insertion.index,
|
||||
overlayKind: 'insertion',
|
||||
rect: insertion.rect,
|
||||
worktreeId
|
||||
}
|
||||
: { id: groupId, groupId, worktreeId, rect }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function withDetachedPtyFallback(args: {
|
||||
leafId: string
|
||||
ptyId: string | null
|
||||
detachedLayout: NonNullable<ReturnType<typeof detachTerminalLayoutLeaf>>['detachedLayout']
|
||||
}): NonNullable<ReturnType<typeof detachTerminalLayoutLeaf>>['detachedLayout'] {
|
||||
if (!args.ptyId || args.detachedLayout.ptyIdsByLeafId?.[args.leafId]) {
|
||||
return args.detachedLayout
|
||||
}
|
||||
return {
|
||||
...args.detachedLayout,
|
||||
ptyIdsByLeafId: {
|
||||
...args.detachedLayout.ptyIdsByLeafId,
|
||||
[args.leafId]: args.ptyId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isTerminalTabStripDropTarget(
|
||||
target: PaneExternalDropTarget
|
||||
): target is TerminalTabStripDropTarget {
|
||||
const candidate = target as Partial<TerminalTabStripDropTarget>
|
||||
return typeof candidate.groupId === 'string' && typeof candidate.worktreeId === 'string'
|
||||
}
|
||||
|
||||
function moveCreatedTabToIndex(args: {
|
||||
groupId: string
|
||||
store: TerminalPaneTabDetachStore
|
||||
tabId: string
|
||||
targetIndex: number | undefined
|
||||
worktreeId: string
|
||||
}): void {
|
||||
if (args.targetIndex === undefined) {
|
||||
return
|
||||
}
|
||||
const group = args.store.groupsByWorktree[args.worktreeId]?.find(
|
||||
(candidate) => candidate.id === args.groupId
|
||||
)
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
const orderWithoutCreatedTab = (group.tabOrder ?? []).filter((id) => id !== args.tabId)
|
||||
const insertionIndex = clampIndex(args.targetIndex, orderWithoutCreatedTab.length)
|
||||
const nextOrder = [...orderWithoutCreatedTab]
|
||||
nextOrder.splice(insertionIndex, 0, args.tabId)
|
||||
args.store.reorderUnifiedTabs(args.groupId, nextOrder, { recordInteraction: false })
|
||||
}
|
||||
|
||||
export function detachTerminalPaneToTab(args: {
|
||||
fallbackPtyId?: string | null
|
||||
getStore: () => TerminalPaneTabDetachStore
|
||||
manager: TerminalPaneTabDetachManager | null
|
||||
persistLayoutSnapshot: () => void
|
||||
sourcePaneId: number
|
||||
sourceTabId: string
|
||||
targetGroupId: string
|
||||
targetIndex?: number
|
||||
worktreeId: string
|
||||
}): DetachedTerminalPaneTab | null {
|
||||
const initialStore = args.getStore()
|
||||
const targetGroupExists =
|
||||
initialStore.groupsByWorktree[args.worktreeId]?.some(
|
||||
(group) => group.id === args.targetGroupId
|
||||
) ?? false
|
||||
if (!args.manager || !targetGroupExists || args.manager.getPanes().length <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sourceLeafId = args.manager.getLeafId(args.sourcePaneId)
|
||||
if (!sourceLeafId) {
|
||||
return null
|
||||
}
|
||||
|
||||
args.persistLayoutSnapshot()
|
||||
const store = args.getStore()
|
||||
const detached = detachTerminalLayoutLeaf(
|
||||
store.terminalLayoutsByTabId[args.sourceTabId],
|
||||
sourceLeafId
|
||||
)
|
||||
if (!detached) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ptyId = detached.ptyId ?? args.fallbackPtyId ?? null
|
||||
const detachedLayout = withDetachedPtyFallback({
|
||||
leafId: sourceLeafId,
|
||||
ptyId,
|
||||
detachedLayout: detached.detachedLayout
|
||||
})
|
||||
|
||||
// Why: remove the renderer pane only after the layout/PTY handoff has been
|
||||
// computed; the close callback detaches listeners but must not kill the PTY.
|
||||
if (!args.manager.detachPaneForExternalMove(args.sourcePaneId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const latestStore = args.getStore()
|
||||
const tab = latestStore.createTab(args.worktreeId, args.targetGroupId, undefined, {
|
||||
activate: true,
|
||||
initialPtyId: ptyId ?? undefined,
|
||||
recordInteraction: true
|
||||
})
|
||||
const afterCreateStore = args.getStore()
|
||||
moveCreatedTabToIndex({
|
||||
groupId: args.targetGroupId,
|
||||
store: afterCreateStore,
|
||||
tabId: tab.id,
|
||||
targetIndex: args.targetIndex,
|
||||
worktreeId: args.worktreeId
|
||||
})
|
||||
afterCreateStore.setTabLayout(args.sourceTabId, detached.sourceLayout)
|
||||
afterCreateStore.setTabLayout(tab.id, detachedLayout)
|
||||
afterCreateStore.syncPaneDetachPtyOwnership({
|
||||
detachedPtyId: ptyId,
|
||||
sourceLayout: detached.sourceLayout,
|
||||
sourceTabId: args.sourceTabId,
|
||||
targetTabId: tab.id
|
||||
})
|
||||
afterCreateStore.setActiveTab(tab.id)
|
||||
afterCreateStore.setActiveTabType('terminal')
|
||||
|
||||
return { tab, leafId: sourceLeafId, ptyId }
|
||||
}
|
||||
@@ -2,7 +2,11 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { IDisposable, Terminal } from '@xterm/xterm'
|
||||
import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types'
|
||||
import { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import {
|
||||
PaneManager,
|
||||
type PaneExternalDropHandler,
|
||||
type PaneExternalDropResolver
|
||||
} from '@/lib/pane-manager/pane-manager'
|
||||
import { consumePendingWebRuntimeSplitMirrorTelemetry } from '@/runtime/web-runtime-session'
|
||||
import {
|
||||
normalizeTerminalFastScrollSensitivity,
|
||||
@@ -273,6 +277,8 @@ type UseTerminalPaneLifecycleDeps = {
|
||||
// Why: same pane count does not imply same geometry; drag-reorder can move
|
||||
// panes without resizing them, so overlay rects need a layout-change tick.
|
||||
setPaneLayoutRevision: React.Dispatch<React.SetStateAction<number>>
|
||||
resolveExternalPaneDropTarget?: PaneExternalDropResolver
|
||||
onExternalPaneDrop?: PaneExternalDropHandler
|
||||
}
|
||||
|
||||
export function suppressIntentionalPaneCloseExit(
|
||||
@@ -532,7 +538,9 @@ export function useTerminalPaneLifecycle({
|
||||
paneTitlesRef,
|
||||
setRenamingPaneId,
|
||||
setPaneCount,
|
||||
setPaneLayoutRevision
|
||||
setPaneLayoutRevision,
|
||||
resolveExternalPaneDropTarget,
|
||||
onExternalPaneDrop
|
||||
}: UseTerminalPaneLifecycleDeps): void {
|
||||
const terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows(
|
||||
settings?.terminalScrollbackRows
|
||||
@@ -1094,6 +1102,7 @@ export function useTerminalPaneLifecycle({
|
||||
queueResizeAll(true)
|
||||
},
|
||||
onPaneClosed: (paneId, closedPane) => {
|
||||
const isDetachedToTab = closedPane?.reason === 'detach'
|
||||
const linkProviderDisposable = linkProviderDisposablesRef.current.get(paneId)
|
||||
if (linkProviderDisposable) {
|
||||
linkProviderDisposable.dispose()
|
||||
@@ -1169,31 +1178,37 @@ export function useTerminalPaneLifecycle({
|
||||
panePtyBinding.dispose()
|
||||
panePtyBindings.delete(paneId)
|
||||
}
|
||||
// Why: closing a pane is user-initiated teardown of this row — drop
|
||||
// (not remove) so any retained `done` snapshot for this pane is also
|
||||
// cleared and a same-frame live→gone transition cannot re-snapshot
|
||||
// it via the retention sync. This is pane-keyed state, so it must
|
||||
// clear even if the PTY transport was already removed.
|
||||
const leafId = closedPane?.leafId
|
||||
if (leafId) {
|
||||
if (leafId && !isDetachedToTab) {
|
||||
// Why: closing a pane is user-initiated teardown of this row — drop
|
||||
// (not remove) so any retained `done` snapshot for this pane is also
|
||||
// cleared and a same-frame live→gone transition cannot re-snapshot
|
||||
// it via the retention sync. This is pane-keyed state, so it must
|
||||
// clear even if the PTY transport was already removed.
|
||||
const paneKey = makePaneKey(tabId, leafId)
|
||||
useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
|
||||
clearTerminalPaneUnread(paneKey)
|
||||
useAppStore.getState().dropAgentStatus(paneKey)
|
||||
}
|
||||
if (transport) {
|
||||
const ptyId = suppressIntentionalPaneCloseExit(
|
||||
transport,
|
||||
useAppStore.getState().suppressPtyExit
|
||||
)
|
||||
if (ptyId) {
|
||||
// Why: user/CLI pane closes intentionally tear down this PTY after
|
||||
// PaneManager has already promoted the sibling. Suppress that exit
|
||||
// so the last-surviving pane is not mistaken for an exited tab.
|
||||
syncPanePtyLayoutBinding(paneId, null)
|
||||
clearTabPtyId(tabId, ptyId)
|
||||
if (isDetachedToTab) {
|
||||
// Why: pane-to-tab detach hands the PTY to a newly-created tab;
|
||||
// detach renderer listeners without sending a process teardown.
|
||||
transport.detach?.()
|
||||
} else {
|
||||
const ptyId = suppressIntentionalPaneCloseExit(
|
||||
transport,
|
||||
useAppStore.getState().suppressPtyExit
|
||||
)
|
||||
if (ptyId) {
|
||||
// Why: user/CLI pane closes intentionally tear down this PTY after
|
||||
// PaneManager has already promoted the sibling. Suppress that exit
|
||||
// so the last-surviving pane is not mistaken for an exited tab.
|
||||
syncPanePtyLayoutBinding(paneId, null)
|
||||
clearTabPtyId(tabId, ptyId)
|
||||
}
|
||||
transport.destroy?.()
|
||||
}
|
||||
transport.destroy?.()
|
||||
paneTransportsRef.current.delete(paneId)
|
||||
}
|
||||
clearRuntimePaneTitle(tabId, paneId)
|
||||
@@ -1292,6 +1307,8 @@ export function useTerminalPaneLifecycle({
|
||||
releaseWebviewDragPassthrough?.()
|
||||
releaseWebviewDragPassthrough = null
|
||||
},
|
||||
resolveExternalPaneDropTarget,
|
||||
onExternalPaneDrop,
|
||||
terminalOptions: () => {
|
||||
const currentSettings = settingsRef.current
|
||||
const terminalFontWeights = resolveTerminalFontWeights(currentSettings?.terminalFontWeight)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DropZone, ManagedPaneInternal } from './pane-manager-types'
|
||||
import type { DropZone, ManagedPaneInternal, PaneExternalDropTarget } from './pane-manager-types'
|
||||
import type { DragReorderCallbacks, DragReorderState } from './pane-drag-reorder'
|
||||
import {
|
||||
handlePaneDrop,
|
||||
@@ -62,14 +62,18 @@ export function beginPaneDragFromPointerDown(
|
||||
callbacks.getRoot().classList.remove('is-pane-dragging')
|
||||
callbacks.getPanes().get(paneId)?.container.classList.remove('is-drag-source')
|
||||
try {
|
||||
if (commitDrop && state.currentDropTarget && state.dragSourcePaneId !== null) {
|
||||
handlePaneDrop(
|
||||
state.dragSourcePaneId,
|
||||
state.currentDropTarget.paneId,
|
||||
state.currentDropTarget.zone,
|
||||
state,
|
||||
callbacks
|
||||
)
|
||||
if (commitDrop && state.dragSourcePaneId !== null) {
|
||||
if (state.currentDropTarget) {
|
||||
handlePaneDrop(
|
||||
state.dragSourcePaneId,
|
||||
state.currentDropTarget.paneId,
|
||||
state.currentDropTarget.zone,
|
||||
state,
|
||||
callbacks
|
||||
)
|
||||
} else if (state.currentExternalDropTarget) {
|
||||
callbacks.onExternalPaneDrop?.(state.dragSourcePaneId, state.currentExternalDropTarget)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Why: pointer capture can be lost crossing Electron webviews; always
|
||||
@@ -78,6 +82,7 @@ export function beginPaneDragFromPointerDown(
|
||||
hideDropOverlay(state)
|
||||
state.dragSourcePaneId = null
|
||||
state.currentDropTarget = null
|
||||
state.currentExternalDropTarget = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,8 +171,20 @@ function updateDropTarget(
|
||||
}
|
||||
const targetPane = findDropTargetPane(clientX, clientY, state, callbacks)
|
||||
if (!targetPane) {
|
||||
overlay.style.display = 'none'
|
||||
const sourcePaneId = state.dragSourcePaneId
|
||||
const externalTarget =
|
||||
sourcePaneId === null
|
||||
? null
|
||||
: (callbacks.resolveExternalDropTarget?.({ sourcePaneId, clientX, clientY }) ?? null)
|
||||
if (!externalTarget) {
|
||||
overlay.style.display = 'none'
|
||||
state.currentDropTarget = null
|
||||
state.currentExternalDropTarget = null
|
||||
return
|
||||
}
|
||||
state.currentDropTarget = null
|
||||
state.currentExternalDropTarget = externalTarget
|
||||
positionExternalDropOverlay(overlay, externalTarget)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -180,9 +197,11 @@ function updateDropTarget(
|
||||
) {
|
||||
overlay.style.display = 'none'
|
||||
state.currentDropTarget = null
|
||||
state.currentExternalDropTarget = null
|
||||
return
|
||||
}
|
||||
state.currentDropTarget = { paneId: targetPane.id, zone }
|
||||
state.currentExternalDropTarget = null
|
||||
positionDropOverlay(overlay, rect, zone)
|
||||
}
|
||||
|
||||
@@ -223,6 +242,7 @@ function resolveDropZone(clientX: number, clientY: number, rect: DOMRect): DropZ
|
||||
|
||||
function positionDropOverlay(overlay: HTMLElement, rect: DOMRect, zone: DropZone): void {
|
||||
overlay.style.display = ''
|
||||
overlay.dataset.paneDropOverlayKind = 'area'
|
||||
const scrollX = window.scrollX
|
||||
const scrollY = window.scrollY
|
||||
const halfWidth = rect.width / 2
|
||||
@@ -233,3 +253,13 @@ function positionDropOverlay(overlay: HTMLElement, rect: DOMRect, zone: DropZone
|
||||
overlay.style.width = `${zone === 'left' || zone === 'right' ? halfWidth : rect.width}px`
|
||||
overlay.style.height = `${zone === 'top' || zone === 'bottom' ? halfHeight : rect.height}px`
|
||||
}
|
||||
|
||||
function positionExternalDropOverlay(overlay: HTMLElement, target: PaneExternalDropTarget): void {
|
||||
const rect = target.rect
|
||||
overlay.style.display = ''
|
||||
overlay.dataset.paneDropOverlayKind = target.overlayKind ?? 'area'
|
||||
overlay.style.left = `${rect.left + window.scrollX}px`
|
||||
overlay.style.top = `${rect.top + window.scrollY}px`
|
||||
overlay.style.width = `${rect.width}px`
|
||||
overlay.style.height = `${rect.height}px`
|
||||
}
|
||||
|
||||
@@ -229,6 +229,7 @@ describe('attachPaneDrag', () => {
|
||||
expect(appendedElements[0].removed).toBe(true)
|
||||
expect(state.dragSourcePaneId).toBeNull()
|
||||
expect(state.currentDropTarget).toBeNull()
|
||||
expect(state.currentExternalDropTarget).toBeNull()
|
||||
expect(state.cleanupActiveDrag).toBeNull()
|
||||
expect(onDragActiveChange).toHaveBeenLastCalledWith(false)
|
||||
expect(detachPaneFromTree).not.toHaveBeenCalled()
|
||||
@@ -288,10 +289,83 @@ describe('attachPaneDrag', () => {
|
||||
expect(appendedElements[0]?.removed).toBe(true)
|
||||
expect(state.dragSourcePaneId).toBeNull()
|
||||
expect(state.currentDropTarget).toBeNull()
|
||||
expect(state.currentExternalDropTarget).toBeNull()
|
||||
expect(state.cleanupActiveDrag).toBeNull()
|
||||
expect(onDragActiveChange).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
|
||||
it('drops onto an external target when no pane target is under the pointer', () => {
|
||||
const handle = new FakeElement()
|
||||
const root = new FakeElement(['pane-manager-root'])
|
||||
const sourcePane = createPane(
|
||||
1,
|
||||
new FakeElement(['pane'], {
|
||||
left: 0,
|
||||
top: 80,
|
||||
right: 100,
|
||||
bottom: 180,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
)
|
||||
const siblingPane = createPane(
|
||||
2,
|
||||
new FakeElement(['pane'], {
|
||||
left: 120,
|
||||
top: 80,
|
||||
right: 220,
|
||||
bottom: 180,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
)
|
||||
const panes = new Map<number, ManagedPaneInternal>([
|
||||
[sourcePane.id, sourcePane],
|
||||
[siblingPane.id, siblingPane]
|
||||
])
|
||||
const externalTarget = {
|
||||
id: 'group-1',
|
||||
overlayKind: 'insertion' as const,
|
||||
rect: { left: 0, top: 0, right: 300, bottom: 32, width: 300, height: 32 } as DOMRect
|
||||
}
|
||||
const onExternalPaneDrop = vi.fn(() => true)
|
||||
const state = createDragReorderState()
|
||||
|
||||
attachPaneDrag(handle as unknown as HTMLElement, sourcePane.id, state, {
|
||||
getPanes: () => panes,
|
||||
getRoot: () => root as unknown as HTMLElement,
|
||||
getStyleOptions: () => ({}),
|
||||
isDestroyed: () => false,
|
||||
safeFit: vi.fn(),
|
||||
applyPaneOpacity: vi.fn(),
|
||||
applyDividerStyles: vi.fn(),
|
||||
refitPanesUnder: vi.fn(),
|
||||
resolveExternalDropTarget: ({ clientX, clientY }) =>
|
||||
clientX === 10 && clientY === 10 ? externalTarget : null,
|
||||
onExternalPaneDrop
|
||||
})
|
||||
|
||||
handle.dispatchPointer('pointerdown', pointerEvent({ clientX: 10, clientY: 90 }))
|
||||
handle.dispatchPointer('pointermove', pointerEvent({ clientX: 10, clientY: 10 }))
|
||||
|
||||
expect(state.currentDropTarget).toBeNull()
|
||||
expect(state.currentExternalDropTarget).toBe(externalTarget)
|
||||
expect(appendedElements[0].style).toMatchObject({
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
width: '300px',
|
||||
height: '32px'
|
||||
})
|
||||
expect(appendedElements[0].dataset.paneDropOverlayKind).toBe('insertion')
|
||||
|
||||
handle.dispatchPointer('pointerup', pointerEvent({ pointerId: 1 }))
|
||||
|
||||
expect(onExternalPaneDrop).toHaveBeenCalledWith(sourcePane.id, externalTarget)
|
||||
expect(detachPaneFromTree).not.toHaveBeenCalled()
|
||||
expect(insertPaneNextTo).not.toHaveBeenCalled()
|
||||
expect(state.currentExternalDropTarget).toBeNull()
|
||||
})
|
||||
|
||||
it('returns cleanup that removes handle listeners and cancels active drag capture', () => {
|
||||
const handle = new FakeElement()
|
||||
const root = new FakeElement(['pane-manager-root'])
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { DropZone, ManagedPane, ManagedPaneInternal } from './pane-manager-types'
|
||||
import type { PaneStyleOptions } from './pane-manager-types'
|
||||
import type {
|
||||
DropZone,
|
||||
ManagedPane,
|
||||
ManagedPaneInternal,
|
||||
PaneExternalDropHandler,
|
||||
PaneExternalDropResolver,
|
||||
PaneExternalDropTarget,
|
||||
PaneStyleOptions
|
||||
} from './pane-manager-types'
|
||||
import { detachPaneFromTree, findPaneChildren, insertPaneNextTo } from './pane-tree-ops'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -10,6 +17,7 @@ export type DragReorderState = {
|
||||
dragSourcePaneId: number | null
|
||||
dropOverlay: HTMLElement | null
|
||||
currentDropTarget: { paneId: number; zone: DropZone } | null
|
||||
currentExternalDropTarget: PaneExternalDropTarget | null
|
||||
cleanupActiveDrag: ((commitDrop: boolean) => void) | null
|
||||
}
|
||||
|
||||
@@ -25,6 +33,8 @@ export type DragReorderCallbacks = {
|
||||
requestPaneReparentFrame?: (callback: FrameRequestCallback) => void
|
||||
onLayoutChanged?: () => void
|
||||
onDragActiveChange?: (active: boolean) => void
|
||||
resolveExternalDropTarget?: PaneExternalDropResolver
|
||||
onExternalPaneDrop?: PaneExternalDropHandler
|
||||
}
|
||||
|
||||
export function createDragReorderState(): DragReorderState {
|
||||
@@ -32,6 +42,7 @@ export function createDragReorderState(): DragReorderState {
|
||||
dragSourcePaneId: null,
|
||||
dropOverlay: null,
|
||||
currentDropTarget: null,
|
||||
currentExternalDropTarget: null,
|
||||
cleanupActiveDrag: null
|
||||
}
|
||||
}
|
||||
@@ -44,6 +55,7 @@ export function cancelActivePaneDrag(state: DragReorderState): void {
|
||||
hideDropOverlay(state)
|
||||
state.dragSourcePaneId = null
|
||||
state.currentDropTarget = null
|
||||
state.currentExternalDropTarget = null
|
||||
}
|
||||
|
||||
/** True when dropping source onto target in zone would leave pane order unchanged. */
|
||||
|
||||
@@ -27,8 +27,26 @@ export type PaneSpawnHints = {
|
||||
export type ClosedPaneInfo = {
|
||||
paneId: number
|
||||
leafId: TerminalLeafId
|
||||
reason?: 'close' | 'detach'
|
||||
}
|
||||
|
||||
export type PaneExternalDropTarget = {
|
||||
id: string
|
||||
rect: DOMRect
|
||||
overlayKind?: 'area' | 'insertion'
|
||||
}
|
||||
|
||||
export type PaneExternalDropResolver = (args: {
|
||||
sourcePaneId: number
|
||||
clientX: number
|
||||
clientY: number
|
||||
}) => PaneExternalDropTarget | null
|
||||
|
||||
export type PaneExternalDropHandler = (
|
||||
sourcePaneId: number,
|
||||
target: PaneExternalDropTarget
|
||||
) => boolean
|
||||
|
||||
export type PaneManagerOptions = {
|
||||
onPaneCreated?: (pane: ManagedPane, spawnHints?: PaneSpawnHints) => void | Promise<void>
|
||||
onPaneClosed?: (paneId: number, closedPane?: ClosedPaneInfo) => void
|
||||
@@ -37,6 +55,8 @@ export type PaneManagerOptions = {
|
||||
/** Why: Electron webviews can steal pointer streams from renderer-owned
|
||||
* pane drags unless callers temporarily put them in pointer passthrough. */
|
||||
onPaneDragActiveChange?: (active: boolean) => void
|
||||
resolveExternalPaneDropTarget?: PaneExternalDropResolver
|
||||
onExternalPaneDrop?: PaneExternalDropHandler
|
||||
terminalOptions?: (paneId: number) => Partial<ITerminalOptions>
|
||||
terminalTuiScrollSensitivity?: () => number | undefined
|
||||
onLinkClick?: (event: MouseEvent | undefined, url: string) => void
|
||||
|
||||
@@ -5,7 +5,10 @@ import type {
|
||||
ManagedPane,
|
||||
ManagedPaneInternal,
|
||||
PaneRenderingDiagnostics,
|
||||
DropZone
|
||||
DropZone,
|
||||
PaneExternalDropHandler,
|
||||
PaneExternalDropResolver,
|
||||
PaneExternalDropTarget
|
||||
} from './pane-manager-types'
|
||||
import type { SplitPaneAroundLeafIdsOptions } from './pane-subtree-split'
|
||||
import {
|
||||
@@ -40,11 +43,23 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import { registerLivePaneManager, unregisterLivePaneManager } from './pane-manager-registry'
|
||||
import { schedulePaneRevealRepaint } from './pane-reveal-repaint'
|
||||
import { PaneIdentityRegistry } from './pane-identity-registry'
|
||||
import { closeManagedPane, splitManagedPane } from './pane-split-close'
|
||||
import {
|
||||
closeManagedPane,
|
||||
detachManagedPaneForExternalMove,
|
||||
splitManagedPane
|
||||
} from './pane-split-close'
|
||||
import { FIRST_PANE_ID } from '../../../../shared/pane-key'
|
||||
import { splitPaneAroundMountedSubtree } from './pane-subtree-split'
|
||||
|
||||
export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone }
|
||||
export type {
|
||||
PaneManagerOptions,
|
||||
PaneStyleOptions,
|
||||
ManagedPane,
|
||||
DropZone,
|
||||
PaneExternalDropTarget,
|
||||
PaneExternalDropResolver,
|
||||
PaneExternalDropHandler
|
||||
}
|
||||
|
||||
export class PaneManager {
|
||||
private root: HTMLElement
|
||||
@@ -158,6 +173,22 @@ export class PaneManager {
|
||||
})
|
||||
}
|
||||
|
||||
detachPaneForExternalMove(paneId: number): boolean {
|
||||
return detachManagedPaneForExternalMove({
|
||||
paneId,
|
||||
activePaneId: this.activePaneId,
|
||||
panes: this.panes,
|
||||
root: this.root,
|
||||
styleOptions: this.styleOptions,
|
||||
managerOptions: this.options,
|
||||
getDragCallbacks: () => this.getDragCallbacks(),
|
||||
releasePaneIdentity: (numericPaneId) => this.identities.release(numericPaneId),
|
||||
setActivePaneId: (id) => {
|
||||
this.activePaneId = id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getPanes(): ManagedPane[] {
|
||||
return Array.from(this.panes.values()).map(toPublicPane)
|
||||
}
|
||||
@@ -404,7 +435,9 @@ export class PaneManager {
|
||||
this.requestPaneReparentFrame(callback)
|
||||
},
|
||||
onLayoutChanged: this.options.onLayoutChanged,
|
||||
onDragActiveChange: this.options.onPaneDragActiveChange
|
||||
onDragActiveChange: this.options.onPaneDragActiveChange,
|
||||
resolveExternalDropTarget: this.options.resolveExternalPaneDropTarget,
|
||||
onExternalPaneDrop: this.options.onExternalPaneDrop
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -180,10 +180,39 @@ export function closeManagedPane(args: CloseManagedPaneArgs): void {
|
||||
safeFit(p)
|
||||
}
|
||||
updateMultiPaneState(args.getDragCallbacks())
|
||||
args.managerOptions.onPaneClosed?.(args.paneId, { paneId: args.paneId, leafId: closedLeafId })
|
||||
args.managerOptions.onPaneClosed?.(args.paneId, {
|
||||
paneId: args.paneId,
|
||||
leafId: closedLeafId,
|
||||
reason: 'close'
|
||||
})
|
||||
args.managerOptions.onLayoutChanged?.()
|
||||
}
|
||||
|
||||
export function detachManagedPaneForExternalMove(args: CloseManagedPaneArgs): boolean {
|
||||
const pane = args.panes.get(args.paneId)
|
||||
if (!pane || args.panes.size <= 1) {
|
||||
return false
|
||||
}
|
||||
const closedLeafId = pane.leafId
|
||||
args.releasePaneIdentity(args.paneId)
|
||||
removePaneContainer(args, pane)
|
||||
const nextActivePaneId = activateReplacementPane(args)
|
||||
applyPaneOpacity(args.panes.values(), nextActivePaneId, args.styleOptions)
|
||||
for (const p of args.panes.values()) {
|
||||
safeFit(p)
|
||||
}
|
||||
updateMultiPaneState(args.getDragCallbacks())
|
||||
// Why: pane-to-tab detach tears down only this renderer pane; the PTY is
|
||||
// adopted by the new tab, so TerminalPane must skip process-close cleanup.
|
||||
args.managerOptions.onPaneClosed?.(args.paneId, {
|
||||
paneId: args.paneId,
|
||||
leafId: closedLeafId,
|
||||
reason: 'detach'
|
||||
})
|
||||
args.managerOptions.onLayoutChanged?.()
|
||||
return true
|
||||
}
|
||||
|
||||
function removePaneContainer(args: CloseManagedPaneArgs, pane: ManagedPaneInternal): void {
|
||||
const paneContainer = pane.container
|
||||
const parent = paneContainer.parentElement
|
||||
|
||||
@@ -1117,6 +1117,57 @@ describe('setActiveWorktree', () => {
|
||||
expect(groups[0].tabOrder).toEqual([terminal.id])
|
||||
})
|
||||
|
||||
it('moves live PTY ownership when detaching a primary pane to a tab', () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
const sourceTabId = 'tab-source'
|
||||
const targetTabId = 'tab-target'
|
||||
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[wt]: [
|
||||
makeTab({ id: sourceTabId, worktreeId: wt, ptyId: 'pty-detached' }),
|
||||
makeTab({ id: targetTabId, worktreeId: wt, ptyId: null })
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
[sourceTabId]: ['pty-detached', 'pty-survivor'],
|
||||
[targetTabId]: ['pty-detached']
|
||||
},
|
||||
lastKnownRelayPtyIdByTabId: {
|
||||
[sourceTabId]: 'pty-detached',
|
||||
[targetTabId]: 'pty-detached'
|
||||
}
|
||||
})
|
||||
|
||||
store.getState().syncPaneDetachPtyOwnership({
|
||||
detachedPtyId: 'pty-detached',
|
||||
sourceLayout: {
|
||||
root: { type: 'leaf', leafId: 'survivor-leaf' },
|
||||
activeLeafId: 'survivor-leaf',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { 'survivor-leaf': 'pty-survivor' }
|
||||
},
|
||||
sourceTabId,
|
||||
targetTabId
|
||||
})
|
||||
|
||||
const state = store.getState()
|
||||
expect(state.ptyIdsByTabId[sourceTabId]).toEqual(['pty-survivor'])
|
||||
expect(state.ptyIdsByTabId[targetTabId]).toEqual(['pty-detached'])
|
||||
expect(state.lastKnownRelayPtyIdByTabId[sourceTabId]).toBe('pty-survivor')
|
||||
expect(state.lastKnownRelayPtyIdByTabId[targetTabId]).toBe('pty-detached')
|
||||
expect(state.tabsByWorktree[wt].find((tab) => tab.id === sourceTabId)?.ptyId).toBe(
|
||||
'pty-survivor'
|
||||
)
|
||||
expect(state.tabsByWorktree[wt].find((tab) => tab.id === targetTabId)?.ptyId).toBe(
|
||||
'pty-detached'
|
||||
)
|
||||
})
|
||||
|
||||
it('stores trimmed quick command labels on terminal and unified tabs', () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
|
||||
@@ -293,6 +293,36 @@ function equalStringSets(a: readonly string[], b: readonly string[]): boolean {
|
||||
return a.every((value) => bSet.has(value))
|
||||
}
|
||||
|
||||
function uniquePtyIds(ptyIds: readonly (string | null | undefined)[]): string[] {
|
||||
return [...new Set(ptyIds.filter((ptyId): ptyId is string => Boolean(ptyId)))]
|
||||
}
|
||||
|
||||
function resolvePrimaryLayoutPtyId(layout: TerminalLayoutSnapshot): string | null {
|
||||
const ptyIdsByLeafId = layout.ptyIdsByLeafId ?? {}
|
||||
const activePtyId = layout.activeLeafId ? ptyIdsByLeafId[layout.activeLeafId] : undefined
|
||||
return activePtyId ?? Object.values(ptyIdsByLeafId)[0] ?? null
|
||||
}
|
||||
|
||||
function withTerminalTabPtyId(
|
||||
tabsByWorktree: Record<string, TerminalTab[]>,
|
||||
tabId: string,
|
||||
ptyId: string | null
|
||||
): Record<string, TerminalTab[]> {
|
||||
for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) {
|
||||
const index = tabs.findIndex((tab) => tab.id === tabId)
|
||||
if (index === -1) {
|
||||
continue
|
||||
}
|
||||
if (tabs[index]?.ptyId === ptyId) {
|
||||
return tabsByWorktree
|
||||
}
|
||||
const nextTabs = [...tabs]
|
||||
nextTabs[index] = { ...nextTabs[index]!, ptyId }
|
||||
return { ...tabsByWorktree, [worktreeId]: nextTabs }
|
||||
}
|
||||
return tabsByWorktree
|
||||
}
|
||||
|
||||
export type AutomaticAgentResumeClaim = {
|
||||
worktreeId: string
|
||||
launchAgent: TuiAgent
|
||||
@@ -506,6 +536,12 @@ export type TerminalSlice = {
|
||||
setTabPaneExpanded: (tabId: string, expanded: boolean) => void
|
||||
setTabCanExpandPane: (tabId: string, canExpand: boolean) => void
|
||||
setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void
|
||||
syncPaneDetachPtyOwnership: (args: {
|
||||
detachedPtyId: string | null
|
||||
sourceLayout: TerminalLayoutSnapshot
|
||||
sourceTabId: string
|
||||
targetTabId: string
|
||||
}) => void
|
||||
queueTabStartupCommand: (
|
||||
tabId: string,
|
||||
startup: {
|
||||
@@ -2455,6 +2491,54 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
})
|
||||
},
|
||||
|
||||
syncPaneDetachPtyOwnership: ({ detachedPtyId, sourceLayout, sourceTabId, targetTabId }) => {
|
||||
set((s) => {
|
||||
const layoutSourcePtyIds = uniquePtyIds(Object.values(sourceLayout.ptyIdsByLeafId ?? {}))
|
||||
const existingSourcePtyIds = (s.ptyIdsByTabId[sourceTabId] ?? []).filter(
|
||||
(ptyId) => ptyId !== detachedPtyId
|
||||
)
|
||||
const sourcePtyIds = layoutSourcePtyIds.length > 0 ? layoutSourcePtyIds : existingSourcePtyIds
|
||||
const sourcePrimaryPtyId = resolvePrimaryLayoutPtyId(sourceLayout) ?? sourcePtyIds[0] ?? null
|
||||
const nextPtyIdsByTabId = {
|
||||
...s.ptyIdsByTabId,
|
||||
[sourceTabId]: sourcePtyIds
|
||||
}
|
||||
if (detachedPtyId) {
|
||||
nextPtyIdsByTabId[targetTabId] = uniquePtyIds([
|
||||
...(nextPtyIdsByTabId[targetTabId] ?? []),
|
||||
detachedPtyId
|
||||
])
|
||||
}
|
||||
|
||||
const nextLastKnownRelayPtyIdByTabId = { ...s.lastKnownRelayPtyIdByTabId }
|
||||
if (sourcePrimaryPtyId) {
|
||||
nextLastKnownRelayPtyIdByTabId[sourceTabId] = sourcePrimaryPtyId
|
||||
} else {
|
||||
delete nextLastKnownRelayPtyIdByTabId[sourceTabId]
|
||||
}
|
||||
if (detachedPtyId) {
|
||||
nextLastKnownRelayPtyIdByTabId[targetTabId] = detachedPtyId
|
||||
}
|
||||
|
||||
// Why: pane-to-tab detach moves live PTY ownership without spawning or
|
||||
// exiting processes, so sync identity maps directly without activity bumps.
|
||||
const sourceTabsByWorktree = withTerminalTabPtyId(
|
||||
s.tabsByWorktree,
|
||||
sourceTabId,
|
||||
sourcePrimaryPtyId
|
||||
)
|
||||
const nextTabsByWorktree = detachedPtyId
|
||||
? withTerminalTabPtyId(sourceTabsByWorktree, targetTabId, detachedPtyId)
|
||||
: sourceTabsByWorktree
|
||||
|
||||
return {
|
||||
ptyIdsByTabId: nextPtyIdsByTabId,
|
||||
lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId,
|
||||
...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
queueTabStartupCommand: (tabId, startup) => {
|
||||
// Why: launchToken is only meaningful for tracked launch-config reuse;
|
||||
// plain startup commands must not mint or carry a synthetic token.
|
||||
|
||||
Reference in New Issue
Block a user