mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
Merge ef2196671b into 7a9bc4ef6d
This commit is contained in:
commit
afe4dad107
15 changed files with 1355 additions and 471 deletions
|
|
@ -966,6 +966,7 @@ function Terminal(): React.JSX.Element | null {
|
|||
onCloseAllFiles={closeAllFiles}
|
||||
onPinFile={pinFile}
|
||||
tabBarOrder={tabBarOrder}
|
||||
onReorder={setTabBarOrder}
|
||||
/>,
|
||||
titlebarTabsTarget
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants'
|
|||
import type { BrowserTab as BrowserTabState } from '../../../../shared/types'
|
||||
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from './SortableTab'
|
||||
import { getLiveBrowserUrl } from '../browser-pane/browser-runtime'
|
||||
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
|
||||
import { getDropIndicatorClasses, type DropIndicator } from './drop-indicator'
|
||||
|
||||
function formatBrowserTabUrlLabel(url: string): string {
|
||||
if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') {
|
||||
|
|
@ -27,7 +27,7 @@ function formatBrowserTabUrlLabel(url: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
function getBrowserTabLabel(tab: BrowserTabState): string {
|
||||
export function getBrowserTabLabel(tab: BrowserTabState): string {
|
||||
if (
|
||||
!tab.title ||
|
||||
tab.title === tab.url ||
|
||||
|
|
@ -51,7 +51,12 @@ export default function BrowserTab({
|
|||
onClose,
|
||||
onCloseToRight,
|
||||
onSplitGroup,
|
||||
dragData
|
||||
groupId,
|
||||
unifiedTabId,
|
||||
sortableId,
|
||||
dragData,
|
||||
dropIndicator,
|
||||
sharedDragMode = false
|
||||
}: {
|
||||
tab: BrowserTabState
|
||||
isActive: boolean
|
||||
|
|
@ -60,10 +65,22 @@ export default function BrowserTab({
|
|||
onClose: () => void
|
||||
onCloseToRight: () => void
|
||||
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
|
||||
dragData: TabDragItemData
|
||||
groupId?: string
|
||||
unifiedTabId?: string
|
||||
sortableId?: string
|
||||
dragData?: {
|
||||
sourceGroupId: string
|
||||
unifiedTabId: string
|
||||
visibleId: string
|
||||
contentType: 'browser'
|
||||
worktreeId: string
|
||||
label: string
|
||||
}
|
||||
dropIndicator?: DropIndicator
|
||||
sharedDragMode?: boolean
|
||||
}): React.JSX.Element {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: tab.id,
|
||||
id: sortableId ?? tab.id,
|
||||
data: dragData
|
||||
})
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
|
@ -103,15 +120,17 @@ export default function BrowserTab({
|
|||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
opacity: isDragging ? 0.8 : 1
|
||||
opacity: isDragging ? (sharedDragMode ? 0 : 0.8) : 1
|
||||
}}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${
|
||||
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${getDropIndicatorClasses(dropIndicator ?? null)} ${
|
||||
isActive
|
||||
? 'bg-accent text-foreground border-b-transparent'
|
||||
: 'bg-card text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
}`}
|
||||
data-tab-group-id={groupId}
|
||||
data-unified-tab-id={unifiedTabId}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import { STATUS_COLORS, STATUS_LABELS } from '../right-sidebar/status-display'
|
|||
import type { GitFileStatus } from '../../../../shared/types'
|
||||
import type { OpenFile } from '../../store/slices/editor'
|
||||
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from './SortableTab'
|
||||
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
|
||||
import { getDropIndicatorClasses, type DropIndicator } from './drop-indicator'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const isLinux = navigator.userAgent.includes('Linux')
|
||||
|
|
@ -49,7 +49,12 @@ export default function EditorFileTab({
|
|||
onCloseAll,
|
||||
onPin,
|
||||
onSplitGroup,
|
||||
dragData
|
||||
groupId,
|
||||
unifiedTabId,
|
||||
sortableId,
|
||||
dragData,
|
||||
dropIndicator,
|
||||
sharedDragMode = false
|
||||
}: {
|
||||
file: OpenFile & { tabId?: string }
|
||||
isActive: boolean
|
||||
|
|
@ -61,13 +66,25 @@ export default function EditorFileTab({
|
|||
onCloseAll: () => void
|
||||
onPin?: () => void
|
||||
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
|
||||
dragData: TabDragItemData
|
||||
groupId?: string
|
||||
unifiedTabId?: string
|
||||
sortableId?: string
|
||||
dragData?: {
|
||||
sourceGroupId: string
|
||||
unifiedTabId: string
|
||||
visibleId: string
|
||||
contentType: 'editor' | 'diff' | 'conflict-review'
|
||||
worktreeId: string
|
||||
label: string
|
||||
}
|
||||
dropIndicator?: DropIndicator
|
||||
sharedDragMode?: boolean
|
||||
}): React.JSX.Element {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
// Why: split groups can duplicate the same open file into multiple visible
|
||||
// tabs. Using the unified tab ID keeps each rendered tab draggable as a
|
||||
// distinct item instead of collapsing every copy onto the file entity ID.
|
||||
id: file.tabId ?? file.id,
|
||||
id: sortableId ?? file.tabId ?? file.id,
|
||||
data: dragData
|
||||
})
|
||||
|
||||
|
|
@ -75,7 +92,7 @@ export default function EditorFileTab({
|
|||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
opacity: isDragging ? 0.8 : 1
|
||||
opacity: isDragging ? (sharedDragMode ? 0 : 0.8) : 1
|
||||
}
|
||||
|
||||
const isDiff = file.mode === 'diff'
|
||||
|
|
@ -183,11 +200,13 @@ export default function EditorFileTab({
|
|||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${
|
||||
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${getDropIndicatorClasses(dropIndicator ?? null)} ${
|
||||
isActive
|
||||
? 'bg-accent text-foreground border-b-transparent'
|
||||
: 'bg-card text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
}`}
|
||||
data-tab-group-id={groupId}
|
||||
data-unified-tab-id={unifiedTabId}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
|
||||
import { getDropIndicatorClasses, type DropIndicator } from './drop-indicator'
|
||||
|
||||
type SortableTabProps = {
|
||||
tab: TerminalTab
|
||||
|
|
@ -27,7 +27,19 @@ type SortableTabProps = {
|
|||
onSetTabColor: (tabId: string, color: string | null) => void
|
||||
onToggleExpand: (tabId: string) => void
|
||||
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
|
||||
dragData: TabDragItemData
|
||||
groupId?: string
|
||||
unifiedTabId?: string
|
||||
sortableId?: string
|
||||
dragData?: {
|
||||
sourceGroupId: string
|
||||
unifiedTabId: string
|
||||
visibleId: string
|
||||
contentType: 'terminal'
|
||||
worktreeId: string
|
||||
label: string
|
||||
}
|
||||
dropIndicator?: DropIndicator
|
||||
sharedDragMode?: boolean
|
||||
}
|
||||
|
||||
export const TAB_COLORS = [
|
||||
|
|
@ -59,10 +71,15 @@ export default function SortableTab({
|
|||
onSetTabColor,
|
||||
onToggleExpand,
|
||||
onSplitGroup,
|
||||
dragData
|
||||
groupId,
|
||||
unifiedTabId,
|
||||
sortableId,
|
||||
dragData,
|
||||
dropIndicator,
|
||||
sharedDragMode = false
|
||||
}: SortableTabProps): React.JSX.Element {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: tab.id,
|
||||
id: sortableId ?? tab.id,
|
||||
data: dragData
|
||||
})
|
||||
|
||||
|
|
@ -70,7 +87,7 @@ export default function SortableTab({
|
|||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
opacity: isDragging ? 0.8 : 1
|
||||
opacity: isDragging ? (sharedDragMode ? 0 : 0.8) : 1
|
||||
}
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
|
||||
|
|
@ -155,11 +172,13 @@ export default function SortableTab({
|
|||
data-tab-title={tab.customTitle ?? tab.title}
|
||||
{...attributes}
|
||||
{...dragListeners}
|
||||
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${
|
||||
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${getDropIndicatorClasses(dropIndicator ?? null)} ${
|
||||
isActive
|
||||
? 'bg-accent text-foreground border-b-transparent'
|
||||
: 'bg-card text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
}`}
|
||||
data-tab-group-id={groupId}
|
||||
data-unified-tab-id={unifiedTabId}
|
||||
onDoubleClick={(e) => {
|
||||
if (isEditing) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,8 +1,22 @@
|
|||
/* eslint-disable max-lines -- Why: cross-group drag support requires shared
|
||||
* sortable-ID mapping, drop-indicator state, and dual DnD-context switching to
|
||||
* live alongside the existing tab-bar ordering logic. Splitting into separate
|
||||
* files would fragment the rendering pipeline these pieces must coordinate. */
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent
|
||||
} from '@dnd-kit/core'
|
||||
import { SortableContext, horizontalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'
|
||||
import { FilePlus, Globe, Plus, TerminalSquare } from 'lucide-react'
|
||||
import type {
|
||||
BrowserTab as BrowserTabState,
|
||||
TabContentType,
|
||||
TerminalTab,
|
||||
WorkspaceVisibleTabType
|
||||
} from '../../../../shared/types'
|
||||
|
|
@ -11,9 +25,9 @@ import { buildStatusMap } from '../right-sidebar/status-display'
|
|||
import type { OpenFile } from '../../store/slices/editor'
|
||||
import SortableTab from './SortableTab'
|
||||
import EditorFileTab from './EditorFileTab'
|
||||
import BrowserTab from './BrowserTab'
|
||||
import BrowserTab, { getBrowserTabLabel } from './BrowserTab'
|
||||
import { reconcileTabOrder } from './reconcile-order'
|
||||
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
|
||||
import type { DropIndicator } from './drop-indicator'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -21,22 +35,35 @@ import {
|
|||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { getEditorDisplayLabel } from '../editor/editor-labels'
|
||||
import {
|
||||
buildGroupDropId,
|
||||
buildSharedSortableId,
|
||||
useCrossGroupDragState,
|
||||
useIsCrossGroupDndPresent
|
||||
} from '../tab-group/CrossGroupDragContext'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const NEW_TERMINAL_SHORTCUT = isMac ? '⌘T' : 'Ctrl+T'
|
||||
const NEW_BROWSER_SHORTCUT = isMac ? '⌘⇧B' : 'Ctrl+Shift+B'
|
||||
// Why: main moved the New Markdown shortcut to ⌘⇧M to avoid clashing with the
|
||||
// OS-level "new window" chord. Keep that binding even as cross-group DnD lands.
|
||||
const NEW_FILE_SHORTCUT = isMac ? '⌘⇧M' : 'Ctrl+Shift+M'
|
||||
|
||||
type TabBarProps = {
|
||||
tabs: (TerminalTab & { unifiedTabId?: string })[]
|
||||
tabs: TerminalTab[]
|
||||
activeTabId: string | null
|
||||
groupId?: string
|
||||
worktreeId: string
|
||||
expandedPaneByTabId: Record<string, boolean>
|
||||
onActivate: (tabId: string) => void
|
||||
onClose: (tabId: string) => void
|
||||
onCloseOthers: (tabId: string) => void
|
||||
onCloseToRight: (tabId: string) => void
|
||||
// Why: the legacy single-strip DnDContext inside TabBar fires this when the
|
||||
// tab bar renders outside split-group mode (Terminal.tsx portal fallback).
|
||||
// Split groups wrap the bar in TabGroupDndContext, which owns reorder itself
|
||||
// — so this stays optional and only the fallback path needs to wire it.
|
||||
onReorder?: (worktreeId: string, order: string[]) => void
|
||||
onNewTerminalTab: () => void
|
||||
onNewBrowserTab: () => void
|
||||
onNewFileTab?: () => void
|
||||
|
|
@ -44,7 +71,7 @@ type TabBarProps = {
|
|||
onSetTabColor: (tabId: string, color: string | null) => void
|
||||
onTogglePaneExpand: (tabId: string) => void
|
||||
editorFiles?: (OpenFile & { tabId?: string })[]
|
||||
browserTabs?: (BrowserTabState & { tabId?: string })[]
|
||||
browserTabs?: BrowserTabState[]
|
||||
activeFileId?: string | null
|
||||
activeBrowserTabId?: string | null
|
||||
activeTabType?: WorkspaceVisibleTabType
|
||||
|
|
@ -55,6 +82,8 @@ type TabBarProps = {
|
|||
onCloseAllFiles?: () => void
|
||||
onPinFile?: (fileId: string, tabId?: string) => void
|
||||
tabBarOrder?: string[]
|
||||
groupId?: string
|
||||
unifiedTabIdByVisibleId?: Record<string, string>
|
||||
onCreateSplitGroup?: (
|
||||
direction: 'left' | 'right' | 'up' | 'down',
|
||||
sourceVisibleTabId?: string
|
||||
|
|
@ -64,28 +93,46 @@ type TabBarProps = {
|
|||
type TabItem =
|
||||
| {
|
||||
type: 'terminal'
|
||||
id: string
|
||||
visibleId: string
|
||||
unifiedTabId: string
|
||||
data: TerminalTab & { unifiedTabId?: string }
|
||||
sortableId: string
|
||||
data: TerminalTab
|
||||
}
|
||||
| {
|
||||
type: 'editor'
|
||||
visibleId: string
|
||||
unifiedTabId: string
|
||||
sortableId: string
|
||||
data: OpenFile & { tabId?: string }
|
||||
}
|
||||
| { type: 'editor'; id: string; unifiedTabId: string; data: OpenFile & { tabId?: string } }
|
||||
| {
|
||||
type: 'browser'
|
||||
id: string
|
||||
visibleId: string
|
||||
unifiedTabId: string
|
||||
data: BrowserTabState & { tabId?: string }
|
||||
sortableId: string
|
||||
data: BrowserTabState
|
||||
}
|
||||
|
||||
function getEditorDragContentType(
|
||||
file: OpenFile & { tabId?: string }
|
||||
): 'editor' | 'diff' | 'conflict-review' {
|
||||
return file.mode === 'diff'
|
||||
? 'diff'
|
||||
: file.mode === 'conflict-review'
|
||||
? 'conflict-review'
|
||||
: 'editor'
|
||||
}
|
||||
|
||||
function TabBarInner({
|
||||
tabs,
|
||||
activeTabId,
|
||||
groupId,
|
||||
worktreeId,
|
||||
expandedPaneByTabId,
|
||||
onActivate,
|
||||
onClose,
|
||||
onCloseOthers,
|
||||
onCloseToRight,
|
||||
onReorder,
|
||||
onNewTerminalTab,
|
||||
onNewBrowserTab,
|
||||
onNewFileTab,
|
||||
|
|
@ -104,10 +151,23 @@ function TabBarInner({
|
|||
onCloseAllFiles,
|
||||
onPinFile,
|
||||
tabBarOrder,
|
||||
groupId,
|
||||
unifiedTabIdByVisibleId,
|
||||
onCreateSplitGroup
|
||||
}: TabBarProps): React.JSX.Element {
|
||||
const isSharedDnd = useIsCrossGroupDndPresent() && Boolean(groupId)
|
||||
const dragState = useCrossGroupDragState()
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 }
|
||||
})
|
||||
)
|
||||
const { setNodeRef: setGroupDropNodeRef } = useDroppable({
|
||||
id: buildGroupDropId(groupId ?? `legacy-${worktreeId}`),
|
||||
disabled: !isSharedDnd
|
||||
})
|
||||
|
||||
const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)
|
||||
const resolvedGroupId = groupId ?? worktreeId
|
||||
const statusByRelativePath = useMemo(
|
||||
() => buildStatusMap(gitStatusByWorktree[worktreeId] ?? []),
|
||||
[worktreeId, gitStatusByWorktree]
|
||||
|
|
@ -136,31 +196,110 @@ function TabBarInner({
|
|||
if (terminal) {
|
||||
items.push({
|
||||
type: 'terminal',
|
||||
id,
|
||||
unifiedTabId: terminal.unifiedTabId ?? terminal.id,
|
||||
visibleId: id,
|
||||
unifiedTabId: unifiedTabIdByVisibleId?.[id] ?? id,
|
||||
sortableId: isSharedDnd && groupId ? buildSharedSortableId(groupId, id) : id,
|
||||
data: terminal
|
||||
})
|
||||
continue
|
||||
}
|
||||
const file = editorMap.get(id)
|
||||
if (file) {
|
||||
items.push({ type: 'editor', id, unifiedTabId: file.tabId ?? file.id, data: file })
|
||||
items.push({
|
||||
type: 'editor',
|
||||
visibleId: id,
|
||||
unifiedTabId: unifiedTabIdByVisibleId?.[id] ?? file.tabId ?? file.id,
|
||||
sortableId: isSharedDnd && groupId ? buildSharedSortableId(groupId, id) : id,
|
||||
data: file
|
||||
})
|
||||
continue
|
||||
}
|
||||
const browserTab = browserMap.get(id)
|
||||
if (browserTab) {
|
||||
items.push({
|
||||
type: 'browser',
|
||||
id,
|
||||
unifiedTabId: browserTab.tabId ?? browserTab.id,
|
||||
visibleId: id,
|
||||
unifiedTabId: unifiedTabIdByVisibleId?.[id] ?? id,
|
||||
sortableId: isSharedDnd && groupId ? buildSharedSortableId(groupId, id) : id,
|
||||
data: browserTab
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
}, [tabBarOrder, terminalIds, editorFileIds, browserTabIds, terminalMap, editorMap, browserMap])
|
||||
}, [
|
||||
browserMap,
|
||||
browserTabIds,
|
||||
editorFileIds,
|
||||
editorMap,
|
||||
groupId,
|
||||
isSharedDnd,
|
||||
tabBarOrder,
|
||||
terminalIds,
|
||||
terminalMap,
|
||||
unifiedTabIdByVisibleId
|
||||
])
|
||||
|
||||
const sortableIds = useMemo(() => orderedItems.map((item) => item.id), [orderedItems])
|
||||
const sortableIds = useMemo(() => orderedItems.map((item) => item.sortableId), [orderedItems])
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
// Why: in shared-DnD mode, the outer TabGroupDndContext owns drag-end and
|
||||
// sortableIds are composite ${groupId}::${visibleId} strings. This legacy
|
||||
// handler is only attached to the inner DndContext at line ~563, which is not
|
||||
// rendered in shared mode — but guard defensively so composite IDs can never
|
||||
// be persisted as tab order if the shared-mode flag toggles mid-render.
|
||||
if (isSharedDnd) {
|
||||
return
|
||||
}
|
||||
if (!over || active.id === over.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const oldIndex = sortableIds.indexOf(active.id as string)
|
||||
const newIndex = sortableIds.indexOf(over.id as string)
|
||||
if (oldIndex === -1 || newIndex === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
const newOrder = arrayMove(sortableIds, oldIndex, newIndex)
|
||||
onReorder?.(worktreeId, newOrder)
|
||||
},
|
||||
[isSharedDnd, sortableIds, worktreeId, onReorder]
|
||||
)
|
||||
|
||||
// Why: VS Code marks both adjacent tabs at the insertion point — the left
|
||||
// tab gets a right-edge indicator and the right tab gets a left-edge
|
||||
// indicator — so the two pseudo-elements form one continuous vertical line
|
||||
// between them. This mirrors that pattern for visual clarity.
|
||||
const dropIndicatorByVisibleId = useMemo(() => {
|
||||
const indicators = new Map<string, DropIndicator>()
|
||||
if (
|
||||
!isSharedDnd ||
|
||||
dragState.activeTab == null ||
|
||||
dragState.overGroupId !== groupId ||
|
||||
dragState.overTabBarIndex == null ||
|
||||
orderedItems.length === 0
|
||||
) {
|
||||
return indicators
|
||||
}
|
||||
|
||||
const insertionIndex = Math.max(0, Math.min(dragState.overTabBarIndex, orderedItems.length))
|
||||
if (insertionIndex > 0) {
|
||||
indicators.set(orderedItems[insertionIndex - 1]!.visibleId, 'right')
|
||||
}
|
||||
if (insertionIndex < orderedItems.length) {
|
||||
indicators.set(orderedItems[insertionIndex]!.visibleId, 'left')
|
||||
}
|
||||
return indicators
|
||||
}, [
|
||||
dragState.activeTab,
|
||||
dragState.overGroupId,
|
||||
dragState.overTabBarIndex,
|
||||
groupId,
|
||||
isSharedDnd,
|
||||
orderedItems
|
||||
])
|
||||
|
||||
const focusTerminalTabSurface = useCallback((tabId: string) => {
|
||||
// Why: creating a terminal from the "+" menu is a two-step focus race:
|
||||
|
|
@ -185,6 +324,11 @@ function TabBarInner({
|
|||
|
||||
// Horizontal wheel scrolling for the tab strip
|
||||
const tabStripRef = useRef<HTMLDivElement>(null)
|
||||
// Why: auto-scroll-to-end bookkeeping from main. `prevStripLenRef` tracks the
|
||||
// previous strip length per worktree so we only auto-scroll when a tab was
|
||||
// genuinely added (not on worktree switches); `stickToEndRef` captures the
|
||||
// "user is parked at the right edge" state so label-width growth (e.g.
|
||||
// "Terminal 5" → branch name) keeps the close button visible.
|
||||
const prevStripLenRef = useRef<{ worktreeId: string; len: number } | null>(null)
|
||||
const stickToEndRef = useRef(false)
|
||||
|
||||
|
|
@ -281,8 +425,137 @@ function TabBarInner({
|
|||
prevStripLenRef.current = { worktreeId, len }
|
||||
}, [orderedItems, worktreeId])
|
||||
|
||||
const setTabStripNode = useCallback((node: HTMLDivElement | null) => {
|
||||
tabStripRef.current = node
|
||||
}, [])
|
||||
|
||||
const getDragData = useCallback(
|
||||
<TContentType extends TabContentType>(
|
||||
item: TabItem,
|
||||
contentType: TContentType,
|
||||
label: string
|
||||
):
|
||||
| {
|
||||
sourceGroupId: string
|
||||
unifiedTabId: string
|
||||
visibleId: string
|
||||
contentType: TContentType
|
||||
worktreeId: string
|
||||
label: string
|
||||
}
|
||||
| undefined => {
|
||||
if (!isSharedDnd || !groupId) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
sourceGroupId: groupId,
|
||||
unifiedTabId: item.unifiedTabId,
|
||||
visibleId: item.visibleId,
|
||||
contentType,
|
||||
worktreeId,
|
||||
label
|
||||
}
|
||||
},
|
||||
[groupId, isSharedDnd, worktreeId]
|
||||
)
|
||||
|
||||
const tabStrip = (
|
||||
<SortableContext items={sortableIds} strategy={horizontalListSortingStrategy}>
|
||||
{/* Why: no-drag lets tab interactions work inside the titlebar's drag
|
||||
region. The outer container inherits drag so empty space after the
|
||||
"+" button remains window-draggable. */}
|
||||
<div
|
||||
ref={setTabStripNode}
|
||||
className="terminal-tab-strip flex items-stretch overflow-x-auto overflow-y-hidden"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
{orderedItems.map((item, index) => {
|
||||
const dropIndicator = dropIndicatorByVisibleId.get(item.visibleId) ?? null
|
||||
if (item.type === 'terminal') {
|
||||
return (
|
||||
<SortableTab
|
||||
key={item.visibleId}
|
||||
tab={item.data}
|
||||
groupId={groupId}
|
||||
unifiedTabId={item.unifiedTabId}
|
||||
sortableId={item.sortableId}
|
||||
dragData={getDragData(item, 'terminal', item.data.customTitle ?? item.data.title)}
|
||||
dropIndicator={dropIndicator}
|
||||
sharedDragMode={isSharedDnd}
|
||||
tabCount={tabs.length}
|
||||
hasTabsToRight={index < orderedItems.length - 1}
|
||||
isActive={activeTabType === 'terminal' && item.visibleId === activeTabId}
|
||||
isExpanded={expandedPaneByTabId[item.visibleId] === true}
|
||||
onActivate={onActivate}
|
||||
onClose={onClose}
|
||||
onCloseOthers={onCloseOthers}
|
||||
onCloseToRight={onCloseToRight}
|
||||
onSetCustomTitle={onSetCustomTitle}
|
||||
onSetTabColor={onSetTabColor}
|
||||
onToggleExpand={onTogglePaneExpand}
|
||||
onSplitGroup={(direction, sourceVisibleTabId) =>
|
||||
onCreateSplitGroup?.(direction, sourceVisibleTabId)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (item.type === 'browser') {
|
||||
return (
|
||||
<BrowserTab
|
||||
key={item.visibleId}
|
||||
tab={item.data}
|
||||
groupId={groupId}
|
||||
unifiedTabId={item.unifiedTabId}
|
||||
sortableId={item.sortableId}
|
||||
dragData={getDragData(item, 'browser', getBrowserTabLabel(item.data))}
|
||||
dropIndicator={dropIndicator}
|
||||
sharedDragMode={isSharedDnd}
|
||||
isActive={activeTabType === 'browser' && activeBrowserTabId === item.visibleId}
|
||||
hasTabsToRight={index < orderedItems.length - 1}
|
||||
onActivate={() => onActivateBrowserTab?.(item.visibleId)}
|
||||
onClose={() => onCloseBrowserTab?.(item.visibleId)}
|
||||
onCloseToRight={() => onCloseToRight(item.visibleId)}
|
||||
onSplitGroup={(direction, sourceVisibleTabId) =>
|
||||
onCreateSplitGroup?.(direction, sourceVisibleTabId)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<EditorFileTab
|
||||
key={item.visibleId}
|
||||
file={item.data}
|
||||
groupId={groupId}
|
||||
unifiedTabId={item.unifiedTabId}
|
||||
sortableId={item.sortableId}
|
||||
dragData={getDragData(
|
||||
item,
|
||||
getEditorDragContentType(item.data),
|
||||
getEditorDisplayLabel(item.data)
|
||||
)}
|
||||
dropIndicator={dropIndicator}
|
||||
sharedDragMode={isSharedDnd}
|
||||
isActive={activeTabType === 'editor' && activeFileId === item.visibleId}
|
||||
hasTabsToRight={index < orderedItems.length - 1}
|
||||
statusByRelativePath={statusByRelativePath}
|
||||
onActivate={() => onActivateFile?.(item.visibleId)}
|
||||
onClose={() => onCloseFile?.(item.visibleId)}
|
||||
onCloseToRight={() => onCloseToRight(item.visibleId)}
|
||||
onCloseAll={() => onCloseAllFiles?.()}
|
||||
onPin={() => onPinFile?.(item.data.id, item.data.tabId)}
|
||||
onSplitGroup={(direction, sourceVisibleTabId) =>
|
||||
onCreateSplitGroup?.(direction, sourceVisibleTabId)
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setGroupDropNodeRef}
|
||||
className="flex items-stretch h-full overflow-hidden flex-1 min-w-0"
|
||||
// Why: only drops aimed at the top tab/session strip should open files in
|
||||
// Orca's editor. Terminal-pane drops need to keep inserting file paths
|
||||
|
|
@ -290,87 +563,19 @@ function TabBarInner({
|
|||
// this explicit surface marker instead of treating the whole app as an
|
||||
// editor drop zone.
|
||||
data-native-file-drop-target="editor"
|
||||
// Why: the group droppable rect is registered on this outer row (not on
|
||||
// the inner tab strip) so dropping past the last tab — including the
|
||||
// empty area to the right of the "+" button — still lands in this
|
||||
// group. Attaching to the strip alone leaves no droppable gutter once
|
||||
// the strip is sized to its tabs.
|
||||
>
|
||||
<SortableContext items={sortableIds} strategy={horizontalListSortingStrategy}>
|
||||
{/* Why: no-drag lets tab interactions work inside the titlebar's drag
|
||||
region. The outer container inherits drag so empty space after the
|
||||
"+" button remains window-draggable. */}
|
||||
<div
|
||||
ref={tabStripRef}
|
||||
className="terminal-tab-strip flex items-stretch overflow-x-auto overflow-y-hidden"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
{orderedItems.map((item, index) => {
|
||||
const dragData: TabDragItemData = {
|
||||
kind: 'tab',
|
||||
worktreeId,
|
||||
groupId: resolvedGroupId,
|
||||
unifiedTabId: item.unifiedTabId,
|
||||
visibleTabId: item.id,
|
||||
tabType: item.type
|
||||
}
|
||||
|
||||
if (item.type === 'terminal') {
|
||||
return (
|
||||
<SortableTab
|
||||
key={item.id}
|
||||
tab={item.data}
|
||||
tabCount={tabs.length}
|
||||
hasTabsToRight={index < orderedItems.length - 1}
|
||||
isActive={activeTabType === 'terminal' && item.id === activeTabId}
|
||||
isExpanded={expandedPaneByTabId[item.id] === true}
|
||||
onActivate={onActivate}
|
||||
onClose={onClose}
|
||||
onCloseOthers={onCloseOthers}
|
||||
onCloseToRight={onCloseToRight}
|
||||
onSetCustomTitle={onSetCustomTitle}
|
||||
onSetTabColor={onSetTabColor}
|
||||
onToggleExpand={onTogglePaneExpand}
|
||||
onSplitGroup={(direction, sourceVisibleTabId) =>
|
||||
onCreateSplitGroup?.(direction, sourceVisibleTabId)
|
||||
}
|
||||
dragData={dragData}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (item.type === 'browser') {
|
||||
return (
|
||||
<BrowserTab
|
||||
key={item.id}
|
||||
tab={item.data}
|
||||
isActive={activeTabType === 'browser' && activeBrowserTabId === item.id}
|
||||
hasTabsToRight={index < orderedItems.length - 1}
|
||||
onActivate={() => onActivateBrowserTab?.(item.id)}
|
||||
onClose={() => onCloseBrowserTab?.(item.id)}
|
||||
onCloseToRight={() => onCloseToRight(item.id)}
|
||||
onSplitGroup={(direction, sourceVisibleTabId) =>
|
||||
onCreateSplitGroup?.(direction, sourceVisibleTabId)
|
||||
}
|
||||
dragData={dragData}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<EditorFileTab
|
||||
key={item.id}
|
||||
file={item.data}
|
||||
isActive={activeTabType === 'editor' && activeFileId === item.id}
|
||||
hasTabsToRight={index < orderedItems.length - 1}
|
||||
statusByRelativePath={statusByRelativePath}
|
||||
onActivate={() => onActivateFile?.(item.id)}
|
||||
onClose={() => onCloseFile?.(item.id)}
|
||||
onCloseToRight={() => onCloseToRight(item.id)}
|
||||
onCloseAll={() => onCloseAllFiles?.()}
|
||||
onPin={() => onPinFile?.(item.data.id, item.data.tabId)}
|
||||
onSplitGroup={(direction, sourceVisibleTabId) =>
|
||||
onCreateSplitGroup?.(direction, sourceVisibleTabId)
|
||||
}
|
||||
dragData={dragData}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
{isSharedDnd ? (
|
||||
tabStrip
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
{tabStrip}
|
||||
</DndContext>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
|
|
|
|||
14
src/renderer/src/components/tab-bar/drop-indicator.ts
Normal file
14
src/renderer/src/components/tab-bar/drop-indicator.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export type DropIndicator = 'left' | 'right' | null
|
||||
|
||||
// Why: the theme's accent color (#404040 in dark mode) is too subtle for a
|
||||
// drag-and-drop insertion cue. Using a vivid blue matches VS Code's
|
||||
// tab.dragAndDropBorder and is immediately visible against all tab backgrounds.
|
||||
export function getDropIndicatorClasses(dropIndicator: DropIndicator): string {
|
||||
if (dropIndicator === 'left') {
|
||||
return "before:absolute before:inset-y-0 before:left-0 before:w-[2px] before:bg-blue-500 before:z-10 before:content-['']"
|
||||
}
|
||||
if (dropIndicator === 'right') {
|
||||
return "after:absolute after:inset-y-0 after:right-0 after:w-[2px] after:bg-blue-500 after:z-10 after:content-['']"
|
||||
}
|
||||
return ''
|
||||
}
|
||||
100
src/renderer/src/components/tab-group/CrossGroupDragContext.tsx
Normal file
100
src/renderer/src/components/tab-group/CrossGroupDragContext.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { createContext, useContext } from 'react'
|
||||
import type { TabContentType } from '../../../../shared/types'
|
||||
import type { TabDropZone } from './useTabDragSplit'
|
||||
|
||||
export type TabDragData = {
|
||||
sourceGroupId: string
|
||||
unifiedTabId: string
|
||||
visibleId: string
|
||||
contentType: TabContentType
|
||||
worktreeId: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type PaneSplitTarget = {
|
||||
groupId: string
|
||||
zone: TabDropZone
|
||||
}
|
||||
|
||||
export type CrossGroupDragState = {
|
||||
activeTab: TabDragData | null
|
||||
overGroupId: string | null
|
||||
overTabBarIndex: number | null
|
||||
// Why: when the pointer hovers a pane body (not the tab strip) during a tab
|
||||
// drag, the drop should create or join a split instead of reordering tabs.
|
||||
// Tracking the hovered pane + zone here lets TabGroupPanel render the split
|
||||
// preview overlay while keeping the drop decision in one place.
|
||||
paneSplit: PaneSplitTarget | null
|
||||
}
|
||||
|
||||
export const GROUP_DROP_PREFIX = 'group-drop::'
|
||||
|
||||
export const EMPTY_DRAG_STATE: CrossGroupDragState = {
|
||||
activeTab: null,
|
||||
overGroupId: null,
|
||||
overTabBarIndex: null,
|
||||
paneSplit: null
|
||||
}
|
||||
|
||||
// Why: `event.active.data.current` is typed as `Record<string, unknown> | undefined`
|
||||
// by dnd-kit. Casting without validation would let a malformed draggable (e.g. a
|
||||
// future non-tab draggable sharing this DnD context) silently pass through as a
|
||||
// TabDragData and corrupt store invocations downstream.
|
||||
export function isTabDragData(value: unknown): value is TabDragData {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const v = value as Record<string, unknown>
|
||||
return (
|
||||
typeof v.sourceGroupId === 'string' &&
|
||||
typeof v.unifiedTabId === 'string' &&
|
||||
typeof v.visibleId === 'string' &&
|
||||
typeof v.contentType === 'string' &&
|
||||
typeof v.worktreeId === 'string' &&
|
||||
typeof v.label === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export const CrossGroupDragStateContext = createContext<CrossGroupDragState>(EMPTY_DRAG_STATE)
|
||||
export const CrossGroupDndContextPresent = createContext(false)
|
||||
|
||||
// Why: once split groups share a single DnD context, every sortable item must
|
||||
// be globally unique across the whole workspace tree, not just within its tab
|
||||
// strip. Prefixing the visible ID with the group ID preserves existing tab-bar
|
||||
// ordering logic while letting dnd-kit distinguish same-entity tabs in
|
||||
// different groups.
|
||||
export function buildSharedSortableId(groupId: string, visibleId: string): string {
|
||||
return `${groupId}::${visibleId}`
|
||||
}
|
||||
|
||||
export function parseSharedSortableId(id: string): { groupId: string; visibleId: string } | null {
|
||||
// Group-drop IDs (e.g. 'group-drop::someGroup') share the '::' delimiter with
|
||||
// sortable IDs, so without this guard we'd mis-parse them as { groupId: 'group-drop', visibleId: 'someGroup' }.
|
||||
if (id.startsWith(GROUP_DROP_PREFIX)) {
|
||||
return null
|
||||
}
|
||||
const separatorIndex = id.indexOf('::')
|
||||
if (separatorIndex <= 0) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
groupId: id.slice(0, separatorIndex),
|
||||
visibleId: id.slice(separatorIndex + 2)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGroupDropId(groupId: string): string {
|
||||
return `${GROUP_DROP_PREFIX}${groupId}`
|
||||
}
|
||||
|
||||
export function parseGroupDropId(id: string): { groupId: string } | null {
|
||||
return id.startsWith(GROUP_DROP_PREFIX) ? { groupId: id.slice(GROUP_DROP_PREFIX.length) } : null
|
||||
}
|
||||
|
||||
export function useCrossGroupDragState(): CrossGroupDragState {
|
||||
return useContext(CrossGroupDragStateContext)
|
||||
}
|
||||
|
||||
export function useIsCrossGroupDndPresent(): boolean {
|
||||
return useContext(CrossGroupDndContextPresent)
|
||||
}
|
||||
522
src/renderer/src/components/tab-group/TabGroupDndContext.tsx
Normal file
522
src/renderer/src/components/tab-group/TabGroupDndContext.tsx
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
/* oxlint-disable max-lines -- Why: the cross-group DnD context coordinates
|
||||
* collision detection, drag-state tracking, three distinct drop targets
|
||||
* (sortable tab, group droppable, pane-body split), the cross-group drop
|
||||
* gate, and the drag overlay — splitting would fragment the reducer-like
|
||||
* state machine these callbacks share via dragStateRef. */
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type ClientRect,
|
||||
type CollisionDetection,
|
||||
type DragCancelEvent,
|
||||
type DragEndEvent,
|
||||
type DragMoveEvent,
|
||||
type DragOverEvent,
|
||||
type DragStartEvent,
|
||||
type UniqueIdentifier
|
||||
} from '@dnd-kit/core'
|
||||
import { FileCode, Globe, TerminalSquare } from 'lucide-react'
|
||||
import type { Tab, TabGroup } from '../../../../shared/types'
|
||||
import { useAppStore } from '../../store'
|
||||
import {
|
||||
CrossGroupDndContextPresent,
|
||||
CrossGroupDragStateContext,
|
||||
EMPTY_DRAG_STATE,
|
||||
isTabDragData,
|
||||
parseGroupDropId,
|
||||
parseSharedSortableId,
|
||||
type CrossGroupDragState,
|
||||
type TabDragData
|
||||
} from './CrossGroupDragContext'
|
||||
import {
|
||||
canDropTabIntoPaneBody,
|
||||
getTabPaneBodyDroppableId,
|
||||
parsePaneBodyDroppableId,
|
||||
resolveDropZone,
|
||||
type TabDropZone
|
||||
} from './useTabDragSplit'
|
||||
import {
|
||||
applyInsertionIndex,
|
||||
computeInsertionFromPointer,
|
||||
computeInsertionIndex,
|
||||
makeCollision,
|
||||
pointInRect,
|
||||
toVisibleTabId
|
||||
} from './tab-drag-helpers'
|
||||
|
||||
// Why: referential stability for useAppStore selector defaults — avoids a fresh
|
||||
// `[]` on every render, which would invalidate downstream `useMemo` deps.
|
||||
const EMPTY_GROUPS: TabGroup[] = []
|
||||
const EMPTY_TABS: Tab[] = []
|
||||
|
||||
// Why: cross-group drag requires the terminal daemon to be enabled. The
|
||||
// flaky part is terminal-tab drag — moving a live terminal between groups
|
||||
// remounts the pane, and the non-daemon renderer-side reattach path can
|
||||
// leave the destination blank. The daemon's snapshot-based reattach fixes
|
||||
// that. Strictly speaking, browser and editor tabs have no PTY lifecycle
|
||||
// and could drag cross-group regardless, but we deliberately gate all tab
|
||||
// types on the same flag so the UX is consistent.
|
||||
//
|
||||
// The consistency matters for user trust: if some tab types dragged across
|
||||
// groups and others silently refused, users would reasonably think the
|
||||
// refused drags are a bug ("my browser tab moved but my terminal tab
|
||||
// didn't — is the drag broken?") and either file bug reports or lose
|
||||
// confidence in the feature altogether. A single binary behavior
|
||||
// ("cross-group drag works" or "cross-group drag doesn't, turn on the
|
||||
// daemon") is easier to learn, document, and support.
|
||||
//
|
||||
// - daemon ON → cross-group drag works for all tab types
|
||||
// - daemon OFF → cross-group drag is refused silently for all tab types
|
||||
// (no drop indicator, no overlay); same-group reorder is
|
||||
// unaffected (the pane never remounts in that case)
|
||||
//
|
||||
// Pane-body split drops that land in a non-center zone create a new group
|
||||
// and remount the active pane, so they're treated as cross-group moves
|
||||
// even when the target group matches the source. Center-zone drops do not
|
||||
// create a new group and reduce to the tab-bar insertion path.
|
||||
//
|
||||
// When the daemon flips on by default, this gate becomes a no-op for most
|
||||
// users and can be deleted as a follow-up cleanup.
|
||||
function isCrossGroupDropBlocked(
|
||||
drag: TabDragData,
|
||||
targetGroupId: string,
|
||||
splitZone: TabDropZone | null,
|
||||
daemonEnabled: boolean
|
||||
): boolean {
|
||||
if (daemonEnabled) {
|
||||
return false
|
||||
}
|
||||
// Split (non-center) into any group, including the source, creates a new
|
||||
// group and remounts the pane — block it the same way as a cross-group move.
|
||||
if (splitZone && splitZone !== 'center') {
|
||||
return true
|
||||
}
|
||||
return drag.sourceGroupId !== targetGroupId
|
||||
}
|
||||
|
||||
export default function TabGroupDndContext({
|
||||
worktreeId,
|
||||
children
|
||||
}: {
|
||||
worktreeId: string
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 }
|
||||
})
|
||||
)
|
||||
const reorderUnifiedTabs = useAppStore((state) => state.reorderUnifiedTabs)
|
||||
const moveUnifiedTabToGroup = useAppStore((state) => state.moveUnifiedTabToGroup)
|
||||
const dropUnifiedTab = useAppStore((state) => state.dropUnifiedTab)
|
||||
const closeEmptyGroup = useAppStore((state) => state.closeEmptyGroup)
|
||||
const groups = useAppStore((state) => state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS)
|
||||
const unifiedTabs = useAppStore((state) => state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_TABS)
|
||||
const daemonEnabled = useAppStore((state) => state.settings?.experimentalTerminalDaemon === true)
|
||||
const [dragState, setDragState] = useState<CrossGroupDragState>(EMPTY_DRAG_STATE)
|
||||
const dragStateRef = useRef(dragState)
|
||||
dragStateRef.current = dragState
|
||||
const pointerRef = useRef<{ x: number; y: number } | null>(null)
|
||||
const droppableRectsRef = useRef<Map<UniqueIdentifier, ClientRect>>(new Map())
|
||||
|
||||
const groupVisibleOrder = useMemo(() => {
|
||||
const tabsById = new Map(unifiedTabs.map((tab) => [tab.id, tab]))
|
||||
return new Map(
|
||||
groups.map((group) => [
|
||||
group.id,
|
||||
group.tabOrder
|
||||
.map((tabId) => tabsById.get(tabId))
|
||||
.filter((tab): tab is Tab => tab !== undefined)
|
||||
.map((tab) => ({
|
||||
unifiedTabId: tab.id,
|
||||
visibleId: toVisibleTabId(tab)
|
||||
}))
|
||||
])
|
||||
)
|
||||
}, [groups, unifiedTabs])
|
||||
|
||||
const maybeCloseEmptySourceGroup = useCallback(
|
||||
(sourceGroupId: string) => {
|
||||
const state = useAppStore.getState()
|
||||
const nextGroups = state.groupsByWorktree[worktreeId] ?? []
|
||||
const sourceGroup = nextGroups.find((group) => group.id === sourceGroupId)
|
||||
if (!sourceGroup || sourceGroup.tabOrder.length > 0 || nextGroups.length <= 1) {
|
||||
return
|
||||
}
|
||||
closeEmptyGroup(worktreeId, sourceGroupId)
|
||||
},
|
||||
[closeEmptyGroup, worktreeId]
|
||||
)
|
||||
|
||||
// Why: pointer-based hit testing — closestCenter measures distance from the
|
||||
// ORIGINAL tab position (hidden by DragOverlay), not the cursor. Priority:
|
||||
// sortable tab > tab-bar empty space > pane body (split target, last so the
|
||||
// tab strip still wins reordering hits within the same group).
|
||||
const collisionDetection = useCallback<CollisionDetection>(
|
||||
(args: Parameters<CollisionDetection>[0]) => {
|
||||
if (args.pointerCoordinates) {
|
||||
pointerRef.current = args.pointerCoordinates
|
||||
}
|
||||
droppableRectsRef.current = args.droppableRects
|
||||
|
||||
if (!args.pointerCoordinates) {
|
||||
return closestCenter(args)
|
||||
}
|
||||
|
||||
const pointer = args.pointerCoordinates
|
||||
const getRect = (id: string | number) => args.droppableRects.get(id)
|
||||
|
||||
const hit = (matches: (id: string) => boolean): { id: UniqueIdentifier } | undefined =>
|
||||
args.droppableContainers.find(
|
||||
(c) => matches(String(c.id)) && pointInRect(pointer, getRect(c.id))
|
||||
)
|
||||
|
||||
const sortableHit = hit((id) => parseSharedSortableId(id) !== null)
|
||||
if (sortableHit) {
|
||||
return makeCollision(String(sortableHit.id), args)
|
||||
}
|
||||
const tabBarHit = hit((id) => parseGroupDropId(id) !== null)
|
||||
if (tabBarHit) {
|
||||
return makeCollision(String(tabBarHit.id), args)
|
||||
}
|
||||
const paneBodyHit = hit((id) => parsePaneBodyDroppableId(id) !== null)
|
||||
if (paneBodyHit) {
|
||||
return makeCollision(String(paneBodyHit.id), args)
|
||||
}
|
||||
return []
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
pointerRef.current = null
|
||||
setDragState(EMPTY_DRAG_STATE)
|
||||
}, [])
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const activeTab = isTabDragData(event.active.data.current)
|
||||
? event.active.data.current
|
||||
: undefined
|
||||
if (!activeTab) {
|
||||
clearDragState()
|
||||
return
|
||||
}
|
||||
setDragState({
|
||||
activeTab,
|
||||
overGroupId: activeTab.sourceGroupId,
|
||||
overTabBarIndex: null,
|
||||
paneSplit: null
|
||||
})
|
||||
},
|
||||
[clearDragState]
|
||||
)
|
||||
|
||||
// Why: onDragOver only fires when the "over" droppable changes. Insertion
|
||||
// index within a tab (midpoint-based) and pane-body split zone (quadrant-
|
||||
// based) both depend on the pointer's position inside a single droppable, so
|
||||
// they have to be recomputed every move.
|
||||
const handleDragMove = useCallback(
|
||||
(_event: DragMoveEvent) => {
|
||||
setDragState((current) => {
|
||||
if (!current.activeTab || !current.overGroupId) {
|
||||
return current
|
||||
}
|
||||
const pointer = pointerRef.current
|
||||
if (!pointer) {
|
||||
return current
|
||||
}
|
||||
|
||||
if (current.paneSplit) {
|
||||
const paneRect = droppableRectsRef.current.get(
|
||||
getTabPaneBodyDroppableId(current.paneSplit.groupId)
|
||||
)
|
||||
if (!paneRect) {
|
||||
return current
|
||||
}
|
||||
const zone = resolveDropZone(paneRect, pointer)
|
||||
if (zone === current.paneSplit.zone) {
|
||||
return current
|
||||
}
|
||||
return { ...current, paneSplit: { groupId: current.paneSplit.groupId, zone } }
|
||||
}
|
||||
|
||||
const orderedTabs = groupVisibleOrder.get(current.overGroupId) ?? []
|
||||
const newIndex =
|
||||
computeInsertionFromPointer(
|
||||
orderedTabs,
|
||||
pointer,
|
||||
droppableRectsRef.current,
|
||||
current.overGroupId,
|
||||
current.activeTab.visibleId
|
||||
) ?? orderedTabs.length
|
||||
if (newIndex === current.overTabBarIndex) {
|
||||
return current
|
||||
}
|
||||
return { ...current, overTabBarIndex: newIndex }
|
||||
})
|
||||
},
|
||||
[groupVisibleOrder]
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(event: DragOverEvent) => {
|
||||
const activeTab = isTabDragData(event.active.data.current)
|
||||
? event.active.data.current
|
||||
: undefined
|
||||
// Why: non-destructive reset — keep activeTab so handleDragMove keeps
|
||||
// running and the overlay keeps tracking the cursor after leaving every
|
||||
// droppable; clearing activeTab would freeze the overlay until drop.
|
||||
const resetOver = (): void =>
|
||||
setDragState((current) =>
|
||||
current.activeTab
|
||||
? { ...current, overGroupId: null, overTabBarIndex: null, paneSplit: null }
|
||||
: EMPTY_DRAG_STATE
|
||||
)
|
||||
if (!activeTab || !event.over) {
|
||||
resetOver()
|
||||
return
|
||||
}
|
||||
|
||||
const overId = String(event.over.id)
|
||||
const sortableTarget = parseSharedSortableId(overId)
|
||||
if (sortableTarget) {
|
||||
if (isCrossGroupDropBlocked(activeTab, sortableTarget.groupId, null, daemonEnabled)) {
|
||||
resetOver()
|
||||
return
|
||||
}
|
||||
const orderedTabs = groupVisibleOrder.get(sortableTarget.groupId) ?? []
|
||||
const insertionIndex = computeInsertionIndex(
|
||||
orderedTabs,
|
||||
sortableTarget.visibleId,
|
||||
pointerRef.current?.x ?? null,
|
||||
droppableRectsRef.current.get(overId)
|
||||
)
|
||||
setDragState({
|
||||
activeTab,
|
||||
overGroupId: sortableTarget.groupId,
|
||||
overTabBarIndex: insertionIndex,
|
||||
paneSplit: null
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const groupDropTarget = parseGroupDropId(overId)
|
||||
if (groupDropTarget) {
|
||||
if (isCrossGroupDropBlocked(activeTab, groupDropTarget.groupId, null, daemonEnabled)) {
|
||||
resetOver()
|
||||
return
|
||||
}
|
||||
const orderedTabs = groupVisibleOrder.get(groupDropTarget.groupId) ?? []
|
||||
const insertionIndex = computeInsertionFromPointer(
|
||||
orderedTabs,
|
||||
pointerRef.current,
|
||||
droppableRectsRef.current,
|
||||
groupDropTarget.groupId,
|
||||
activeTab.visibleId
|
||||
)
|
||||
setDragState({
|
||||
activeTab,
|
||||
overGroupId: groupDropTarget.groupId,
|
||||
overTabBarIndex: insertionIndex ?? orderedTabs.length,
|
||||
paneSplit: null
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const paneBodyTarget = parsePaneBodyDroppableId(overId)
|
||||
if (paneBodyTarget) {
|
||||
const allowed = canDropTabIntoPaneBody({
|
||||
activeDrag: {
|
||||
kind: 'tab',
|
||||
worktreeId,
|
||||
groupId: activeTab.sourceGroupId,
|
||||
unifiedTabId: activeTab.unifiedTabId,
|
||||
visibleTabId: activeTab.visibleId,
|
||||
// canDropTabIntoPaneBody ignores tabType — value is irrelevant here.
|
||||
tabType:
|
||||
activeTab.contentType === 'terminal' || activeTab.contentType === 'browser'
|
||||
? activeTab.contentType
|
||||
: 'editor'
|
||||
},
|
||||
groupsByWorktree: useAppStore.getState().groupsByWorktree,
|
||||
overGroupId: paneBodyTarget.groupId,
|
||||
worktreeId
|
||||
})
|
||||
if (!allowed) {
|
||||
resetOver()
|
||||
return
|
||||
}
|
||||
|
||||
const paneRect = droppableRectsRef.current.get(overId)
|
||||
const pointer = pointerRef.current
|
||||
const zone: TabDropZone =
|
||||
paneRect && pointer ? resolveDropZone(paneRect, pointer) : 'center'
|
||||
// Why: same-group center-zone is a silent no-op in handleDragEnd — hide the
|
||||
// "merge" overlay so the user doesn't see a preview that can't be applied.
|
||||
if (paneBodyTarget.groupId === activeTab.sourceGroupId && zone === 'center') {
|
||||
resetOver()
|
||||
return
|
||||
}
|
||||
if (isCrossGroupDropBlocked(activeTab, paneBodyTarget.groupId, zone, daemonEnabled)) {
|
||||
resetOver()
|
||||
return
|
||||
}
|
||||
setDragState({
|
||||
activeTab,
|
||||
overGroupId: paneBodyTarget.groupId,
|
||||
overTabBarIndex: null,
|
||||
paneSplit: { groupId: paneBodyTarget.groupId, zone }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resetOver()
|
||||
},
|
||||
[daemonEnabled, groupVisibleOrder, worktreeId]
|
||||
)
|
||||
|
||||
const handleDragCancel = useCallback(
|
||||
(_event: DragCancelEvent) => {
|
||||
clearDragState()
|
||||
},
|
||||
[clearDragState]
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const activeTab = isTabDragData(event.active.data.current)
|
||||
? event.active.data.current
|
||||
: undefined
|
||||
const currentDragState = dragStateRef.current
|
||||
const sourceGroupId = activeTab?.sourceGroupId
|
||||
clearDragState()
|
||||
|
||||
if (!activeTab || !event.over || !currentDragState.overGroupId) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetGroupId = currentDragState.overGroupId
|
||||
|
||||
// Why: pane-body split takes precedence over tab-strip insertion. A
|
||||
// center-zone drop on the source group's own pane is a no-op (store
|
||||
// rejects it), so skip the call there to avoid misleading the user.
|
||||
if (currentDragState.paneSplit) {
|
||||
const { zone } = currentDragState.paneSplit
|
||||
if (zone === 'center' && activeTab.sourceGroupId === targetGroupId) {
|
||||
return
|
||||
}
|
||||
if (isCrossGroupDropBlocked(activeTab, targetGroupId, zone, daemonEnabled)) {
|
||||
return
|
||||
}
|
||||
// Why: store actions can throw (e.g. group disappeared mid-drag) — catch
|
||||
// so the drag still ends cleanly (clearDragState already ran above).
|
||||
try {
|
||||
dropUnifiedTab(activeTab.unifiedTabId, {
|
||||
groupId: targetGroupId,
|
||||
splitDirection: zone === 'center' ? undefined : zone
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Cross-group drag drop failed:', err)
|
||||
}
|
||||
if (sourceGroupId && sourceGroupId !== targetGroupId) {
|
||||
maybeCloseEmptySourceGroup(sourceGroupId)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (currentDragState.overTabBarIndex !== null) {
|
||||
if (activeTab.sourceGroupId === targetGroupId) {
|
||||
// Why: read the live store state here rather than the `groupVisibleOrder`
|
||||
// closure — the memoized map can be one frame stale relative to the
|
||||
// store (e.g. if a tab closed mid-drag). Matches the
|
||||
// `maybeCloseEmptySourceGroup` pattern below.
|
||||
const liveState = useAppStore.getState()
|
||||
const liveGroup = (liveState.groupsByWorktree[worktreeId] ?? []).find(
|
||||
(g) => g.id === targetGroupId
|
||||
)
|
||||
const currentOrder = liveGroup?.tabOrder ?? []
|
||||
const nextOrder = applyInsertionIndex(
|
||||
currentOrder,
|
||||
activeTab.unifiedTabId,
|
||||
currentDragState.overTabBarIndex
|
||||
)
|
||||
if (nextOrder) {
|
||||
try {
|
||||
reorderUnifiedTabs(targetGroupId, nextOrder)
|
||||
} catch (err) {
|
||||
console.error('Cross-group drag drop failed:', err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isCrossGroupDropBlocked(activeTab, targetGroupId, null, daemonEnabled)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
moveUnifiedTabToGroup(activeTab.unifiedTabId, targetGroupId, {
|
||||
index: currentDragState.overTabBarIndex,
|
||||
activate: true
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Cross-group drag drop failed:', err)
|
||||
}
|
||||
maybeCloseEmptySourceGroup(activeTab.sourceGroupId)
|
||||
}
|
||||
},
|
||||
[
|
||||
clearDragState,
|
||||
daemonEnabled,
|
||||
dropUnifiedTab,
|
||||
maybeCloseEmptySourceGroup,
|
||||
moveUnifiedTabToGroup,
|
||||
reorderUnifiedTabs,
|
||||
worktreeId
|
||||
]
|
||||
)
|
||||
|
||||
const overlayIcon = (() => {
|
||||
if (!dragState.activeTab) {
|
||||
return null
|
||||
}
|
||||
if (dragState.activeTab.contentType === 'terminal') {
|
||||
return <TerminalSquare className="size-3.5 shrink-0" />
|
||||
}
|
||||
if (dragState.activeTab.contentType === 'browser') {
|
||||
return <Globe className="size-3.5 shrink-0" />
|
||||
}
|
||||
return <FileCode className="size-3.5 shrink-0" />
|
||||
})()
|
||||
|
||||
return (
|
||||
<CrossGroupDndContextPresent.Provider value={true}>
|
||||
<CrossGroupDragStateContext.Provider value={dragState}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragMove={handleDragMove}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
{children}
|
||||
<DragOverlay>
|
||||
{dragState.activeTab ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-card/95 px-3 py-1.5 text-sm shadow-lg">
|
||||
{overlayIcon}
|
||||
<span className="max-w-[240px] truncate">{dragState.activeTab.label}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</CrossGroupDragStateContext.Provider>
|
||||
</CrossGroupDndContextPresent.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { lazy, Suspense } from 'react'
|
||||
import { lazy, Suspense, useMemo } from 'react'
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
import { Columns2, Ellipsis, Rows2, X } from 'lucide-react'
|
||||
import { useAppStore } from '../../store'
|
||||
|
|
@ -13,8 +13,10 @@ import TabBar from '../tab-bar/TabBar'
|
|||
import TerminalPane from '../terminal-pane/TerminalPane'
|
||||
import BrowserPane from '../browser-pane/BrowserPane'
|
||||
import { useTabGroupWorkspaceModel } from './useTabGroupWorkspaceModel'
|
||||
import { useCrossGroupDragState } from './CrossGroupDragContext'
|
||||
import { getTabPaneBodyDroppableId } from './useTabDragSplit'
|
||||
import TabGroupDropOverlay from './TabGroupDropOverlay'
|
||||
import { getTabPaneBodyDroppableId, type TabDropZone } from './useTabDragSplit'
|
||||
import { toVisibleTabId } from './tab-drag-helpers'
|
||||
|
||||
const EditorPanel = lazy(() => import('../editor/EditorPanel'))
|
||||
|
||||
|
|
@ -25,23 +27,23 @@ export default function TabGroupPanel({
|
|||
isFocused,
|
||||
hasSplitGroups,
|
||||
reserveClosedExplorerToggleSpace,
|
||||
reserveCollapsedSidebarHeaderSpace,
|
||||
isTabDragActive = false,
|
||||
activeDropZone = null
|
||||
reserveCollapsedSidebarHeaderSpace
|
||||
}: {
|
||||
groupId: string
|
||||
worktreeId: string
|
||||
// Why: preserved from main (PR #777) so TerminalPane.isVisible and
|
||||
// BrowserPane display/isActive can suppress hidden worktrees. Without this
|
||||
// guard, detached panes keep their WebGL renderers alive and exhaust
|
||||
// Chromium's context budget across worktrees.
|
||||
isWorktreeActive: boolean
|
||||
isFocused: boolean
|
||||
hasSplitGroups: boolean
|
||||
reserveClosedExplorerToggleSpace: boolean
|
||||
reserveCollapsedSidebarHeaderSpace: boolean
|
||||
isTabDragActive?: boolean
|
||||
activeDropZone?: TabDropZone | null
|
||||
}): React.JSX.Element {
|
||||
const rightSidebarOpen = useAppStore((state) => state.rightSidebarOpen)
|
||||
const sidebarOpen = useAppStore((state) => state.sidebarOpen)
|
||||
|
||||
const dragState = useCrossGroupDragState()
|
||||
const model = useTabGroupWorkspaceModel({ groupId, worktreeId })
|
||||
const {
|
||||
activeBrowserTab,
|
||||
|
|
@ -54,13 +56,19 @@ export default function TabGroupPanel({
|
|||
terminalTabs,
|
||||
worktreePath
|
||||
} = model
|
||||
const { setNodeRef: setBodyDropRef } = useDroppable({
|
||||
const unifiedTabIdByVisibleId = useMemo(
|
||||
() => Object.fromEntries(model.groupTabs.map((item) => [toVisibleTabId(item), item.id])),
|
||||
[model.groupTabs]
|
||||
)
|
||||
const isForeignDragTarget =
|
||||
dragState.activeTab != null &&
|
||||
dragState.overGroupId === groupId &&
|
||||
dragState.activeTab.sourceGroupId !== groupId
|
||||
|
||||
const isTabDragActive = dragState.activeTab != null
|
||||
const paneDropZone = dragState.paneSplit?.groupId === groupId ? dragState.paneSplit.zone : null
|
||||
const { setNodeRef: setPaneBodyDropRef } = useDroppable({
|
||||
id: getTabPaneBodyDroppableId(groupId),
|
||||
data: {
|
||||
kind: 'pane-body',
|
||||
groupId,
|
||||
worktreeId
|
||||
},
|
||||
disabled: !isTabDragActive
|
||||
})
|
||||
|
||||
|
|
@ -68,8 +76,9 @@ export default function TabGroupPanel({
|
|||
<TabBar
|
||||
tabs={terminalTabs}
|
||||
activeTabId={activeTab?.contentType === 'terminal' ? activeTab.entityId : null}
|
||||
groupId={groupId}
|
||||
worktreeId={worktreeId}
|
||||
groupId={groupId}
|
||||
unifiedTabIdByVisibleId={unifiedTabIdByVisibleId}
|
||||
expandedPaneByTabId={model.expandedPaneByTabId}
|
||||
onActivate={commands.activateTerminal}
|
||||
onClose={(terminalId) => {
|
||||
|
|
@ -154,8 +163,12 @@ export default function TabGroupPanel({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`group/tab-group flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
|
||||
hasSplitGroups ? ` border-x border-b ${isFocused ? 'border-accent' : 'border-border'}` : ''
|
||||
className={`flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
|
||||
hasSplitGroups
|
||||
? ` group/tab-group border-x border-b ${
|
||||
isFocused || isForeignDragTarget ? 'border-accent' : 'border-border'
|
||||
}`
|
||||
: ''
|
||||
}`}
|
||||
onPointerDown={commands.focusGroup}
|
||||
// Why: keyboard and assistive-tech users can move focus into an unfocused
|
||||
|
|
@ -193,7 +206,10 @@ export default function TabGroupPanel({
|
|||
{/* Why: pane-scoped layout actions belong with the active pane instead
|
||||
of the global tab-bar `+`, which should keep opening tabs exactly
|
||||
as before. The local overflow menu holds split directions and
|
||||
close-group without changing the existing tab-creation affordance. */}
|
||||
close-group without changing the existing tab-creation affordance.
|
||||
Drag-to-split (via TabGroupDndContext) is the primary way to
|
||||
create splits, but this menu remains the only way to spawn an
|
||||
empty split pane without an originating tab drag. */}
|
||||
<div
|
||||
className={actionChromeClassName}
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
|
|
@ -267,8 +283,8 @@ export default function TabGroupPanel({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={setBodyDropRef} className="relative flex-1 min-h-0 overflow-hidden">
|
||||
{activeDropZone ? <TabGroupDropOverlay zone={activeDropZone} /> : null}
|
||||
<div ref={setPaneBodyDropRef} className="relative flex-1 min-h-0 overflow-hidden">
|
||||
{paneDropZone ? <TabGroupDropOverlay zone={paneDropZone} /> : null}
|
||||
{model.groupTabs
|
||||
.filter((item) => item.contentType === 'terminal')
|
||||
.map((item) => (
|
||||
|
|
@ -328,6 +344,10 @@ export default function TabGroupPanel({
|
|||
<div
|
||||
key={bt.id}
|
||||
className="absolute inset-0 flex min-h-0 min-w-0"
|
||||
// Why: hidden worktrees stay mounted so their browser tabs survive
|
||||
// worktree switches, but detached BrowserViews must be hidden or
|
||||
// they leak WebContents and steal keyboard focus. Guarding display
|
||||
// and isActive on isWorktreeActive keeps offscreen worktrees silent.
|
||||
style={{
|
||||
display: isWorktreeActive && activeBrowserTab?.id === bt.id ? undefined : 'none'
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -13,19 +13,8 @@ vi.mock('../../store', () => ({
|
|||
vi.mock('./TabGroupPanel', () => ({
|
||||
default: (props: unknown) => ({ __mock: 'TabGroupPanel', props })
|
||||
}))
|
||||
|
||||
vi.mock('./useTabDragSplit', () => ({
|
||||
useTabDragSplit: () => ({
|
||||
activeDrag: null,
|
||||
collisionDetection: vi.fn(),
|
||||
hoveredDropTarget: null,
|
||||
onDragCancel: vi.fn(),
|
||||
onDragEnd: vi.fn(),
|
||||
onDragMove: vi.fn(),
|
||||
onDragOver: vi.fn(),
|
||||
onDragStart: vi.fn(),
|
||||
sensors: []
|
||||
})
|
||||
vi.mock('./TabGroupDndContext', () => ({
|
||||
default: ({ children }: { children: unknown }) => children
|
||||
}))
|
||||
|
||||
import TabGroupSplitLayout from './TabGroupSplitLayout'
|
||||
|
|
@ -39,8 +28,12 @@ describe('TabGroupSplitLayout', () => {
|
|||
isWorktreeActive
|
||||
})
|
||||
|
||||
const splitNodeElement = element.props.children.props.children
|
||||
const tabGroupPanelElement = splitNodeElement.type(splitNodeElement.props)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const innerDiv = (element.type as any)(element.props) as React.ReactElement
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const splitNodeElement = (innerDiv.props as any).children
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tabGroupPanelElement = (splitNodeElement.type as any)(splitNodeElement.props)
|
||||
return tabGroupPanelElement.props as {
|
||||
groupId: string
|
||||
worktreeId: string
|
||||
|
|
@ -94,8 +87,12 @@ describe('TabGroupSplitLayout', () => {
|
|||
isWorktreeActive: true
|
||||
})
|
||||
|
||||
const splitNodeElement = element.props.children.props.children
|
||||
const rootElement = splitNodeElement.type(splitNodeElement.props)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const innerDiv = (element.type as any)(element.props) as React.ReactElement
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const splitNodeElement = (innerDiv.props as any).children
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rootElement = (splitNodeElement.type as any)(splitNodeElement.props)
|
||||
const leftChild = rootElement.props.children[0].props.children
|
||||
const rightChild = rootElement.props.children[2].props.children
|
||||
const leftPanelProps = leftChild.type(leftChild.props).props as {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { useCallback, useState } from 'react'
|
||||
import { DndContext } from '@dnd-kit/core'
|
||||
import type { TabGroupLayoutNode } from '../../../../shared/types'
|
||||
import { useAppStore } from '../../store'
|
||||
import TabGroupPanel from './TabGroupPanel'
|
||||
import { type TabDropZone, useTabDragSplit } from './useTabDragSplit'
|
||||
import TabGroupDndContext from './TabGroupDndContext'
|
||||
|
||||
const MIN_RATIO = 0.15
|
||||
const MAX_RATIO = 0.85
|
||||
|
|
@ -90,10 +89,7 @@ function SplitNode({
|
|||
hasSplitGroups,
|
||||
touchesTopEdge,
|
||||
touchesRightEdge,
|
||||
touchesLeftEdge,
|
||||
isTabDragActive,
|
||||
activeDropGroupId,
|
||||
activeDropZone
|
||||
touchesLeftEdge
|
||||
}: {
|
||||
node: TabGroupLayoutNode
|
||||
nodePath: string
|
||||
|
|
@ -104,9 +100,6 @@ function SplitNode({
|
|||
touchesTopEdge: boolean
|
||||
touchesRightEdge: boolean
|
||||
touchesLeftEdge: boolean
|
||||
isTabDragActive: boolean
|
||||
activeDropGroupId: string | null
|
||||
activeDropZone: TabDropZone | null
|
||||
}): React.JSX.Element {
|
||||
const setTabGroupSplitRatio = useAppStore((state) => state.setTabGroupSplitRatio)
|
||||
|
||||
|
|
@ -115,6 +108,9 @@ function SplitNode({
|
|||
<TabGroupPanel
|
||||
groupId={node.groupId}
|
||||
worktreeId={worktreeId}
|
||||
// Why: preserved from main (PR #777). TabGroupPanel still needs
|
||||
// isWorktreeActive to gate TerminalPane.isVisible and BrowserPane so
|
||||
// hidden worktrees do not leak WebGL contexts / WebContents.
|
||||
isWorktreeActive={isWorktreeActive}
|
||||
// Why: hidden worktrees stay mounted so their PTYs and split layouts
|
||||
// survive worktree switches, but only the visible worktree may own the
|
||||
|
|
@ -124,8 +120,6 @@ function SplitNode({
|
|||
hasSplitGroups={hasSplitGroups}
|
||||
reserveClosedExplorerToggleSpace={touchesTopEdge && touchesRightEdge}
|
||||
reserveCollapsedSidebarHeaderSpace={touchesTopEdge && touchesLeftEdge}
|
||||
isTabDragActive={isTabDragActive}
|
||||
activeDropZone={activeDropGroupId === node.groupId ? activeDropZone : null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -149,9 +143,6 @@ function SplitNode({
|
|||
touchesTopEdge={touchesTopEdge}
|
||||
touchesRightEdge={isHorizontal ? false : touchesRightEdge}
|
||||
touchesLeftEdge={touchesLeftEdge}
|
||||
isTabDragActive={isTabDragActive}
|
||||
activeDropGroupId={activeDropGroupId}
|
||||
activeDropZone={activeDropZone}
|
||||
/>
|
||||
</div>
|
||||
<ResizeHandle
|
||||
|
|
@ -169,9 +160,6 @@ function SplitNode({
|
|||
touchesTopEdge={isHorizontal ? touchesTopEdge : false}
|
||||
touchesRightEdge={touchesRightEdge}
|
||||
touchesLeftEdge={isHorizontal ? false : touchesLeftEdge}
|
||||
isTabDragActive={isTabDragActive}
|
||||
activeDropGroupId={activeDropGroupId}
|
||||
activeDropZone={activeDropZone}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -189,18 +177,8 @@ export default function TabGroupSplitLayout({
|
|||
focusedGroupId?: string
|
||||
isWorktreeActive: boolean
|
||||
}): React.JSX.Element {
|
||||
const dragSplit = useTabDragSplit({ worktreeId, enabled: isWorktreeActive })
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={dragSplit.sensors}
|
||||
collisionDetection={dragSplit.collisionDetection}
|
||||
onDragStart={dragSplit.onDragStart}
|
||||
onDragMove={dragSplit.onDragMove}
|
||||
onDragOver={dragSplit.onDragOver}
|
||||
onDragEnd={dragSplit.onDragEnd}
|
||||
onDragCancel={dragSplit.onDragCancel}
|
||||
>
|
||||
<TabGroupDndContext worktreeId={worktreeId}>
|
||||
<div className="flex flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
<SplitNode
|
||||
node={layout}
|
||||
|
|
@ -212,11 +190,8 @@ export default function TabGroupSplitLayout({
|
|||
touchesTopEdge={true}
|
||||
touchesRightEdge={true}
|
||||
touchesLeftEdge={true}
|
||||
isTabDragActive={dragSplit.activeDrag !== null}
|
||||
activeDropGroupId={dragSplit.hoveredDropTarget?.groupId ?? null}
|
||||
activeDropZone={dragSplit.hoveredDropTarget?.zone ?? null}
|
||||
/>
|
||||
</div>
|
||||
</DndContext>
|
||||
</TabGroupDndContext>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
122
src/renderer/src/components/tab-group/tab-drag-helpers.ts
Normal file
122
src/renderer/src/components/tab-group/tab-drag-helpers.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import type { ClientRect, Collision, CollisionDetection, UniqueIdentifier } from '@dnd-kit/core'
|
||||
import type { Tab } from '../../../../shared/types'
|
||||
import { buildSharedSortableId } from './CrossGroupDragContext'
|
||||
|
||||
export type GroupVisibleTab = {
|
||||
unifiedTabId: string
|
||||
visibleId: string
|
||||
}
|
||||
|
||||
export function pointInRect(
|
||||
point: { x: number; y: number },
|
||||
rect?: ClientRect | null
|
||||
): boolean {
|
||||
if (!rect) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom
|
||||
)
|
||||
}
|
||||
|
||||
export function makeCollision(
|
||||
containerId: string,
|
||||
args: Parameters<CollisionDetection>[0]
|
||||
): Collision[] {
|
||||
const container = args.droppableContainers.find(
|
||||
(candidate) => String(candidate.id) === containerId
|
||||
)
|
||||
return container ? [{ id: container.id, data: { droppableContainer: container, value: 1 } }] : []
|
||||
}
|
||||
|
||||
export function toVisibleTabId(tab: Tab): string {
|
||||
return tab.contentType === 'terminal' || tab.contentType === 'browser' ? tab.entityId : tab.id
|
||||
}
|
||||
|
||||
export function computeInsertionIndex(
|
||||
orderedTabs: GroupVisibleTab[],
|
||||
overVisibleId: string,
|
||||
pointerX: number | null,
|
||||
hoveredRect?: ClientRect | null
|
||||
): number | null {
|
||||
const hoveredIndex = orderedTabs.findIndex((item) => item.visibleId === overVisibleId)
|
||||
if (hoveredIndex === -1) {
|
||||
return null
|
||||
}
|
||||
if (pointerX == null || !hoveredRect) {
|
||||
return hoveredIndex
|
||||
}
|
||||
const midpoint = hoveredRect.left + hoveredRect.width / 2
|
||||
return hoveredIndex + (pointerX >= midpoint ? 1 : 0)
|
||||
}
|
||||
|
||||
// Why: when the pointer is in the tab-bar container but not directly over a
|
||||
// sortable item (e.g. empty space after the last tab), we still need a usable
|
||||
// insertion index. Picks the closest tab by horizontal distance, then uses
|
||||
// pointer-vs-midpoint to decide whether to insert before or after it.
|
||||
export function computeInsertionFromPointer(
|
||||
orderedTabs: GroupVisibleTab[],
|
||||
pointer: { x: number; y: number } | null,
|
||||
rects: Map<UniqueIdentifier, ClientRect>,
|
||||
groupId: string,
|
||||
activeVisibleId?: string
|
||||
): number | null {
|
||||
if (!pointer || orderedTabs.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let bestIndex = -1
|
||||
let bestDistance = Infinity
|
||||
|
||||
for (let i = 0; i < orderedTabs.length; i++) {
|
||||
const item = orderedTabs[i]!
|
||||
// Why: skip the dragged tab's own rect so nearest-neighbor picks an actual
|
||||
// target instead of the stale self-rect (which sits at the original position
|
||||
// while the overlay floats under the cursor).
|
||||
if (activeVisibleId && item.visibleId === activeVisibleId) {
|
||||
continue
|
||||
}
|
||||
const sortableId = buildSharedSortableId(groupId, item.visibleId)
|
||||
const rect = rects.get(sortableId)
|
||||
if (!rect) {
|
||||
continue
|
||||
}
|
||||
const midpoint = rect.left + rect.width / 2
|
||||
const distance = Math.abs(pointer.x - midpoint)
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIndex === -1) {
|
||||
return orderedTabs.length
|
||||
}
|
||||
|
||||
const sortableId = buildSharedSortableId(groupId, orderedTabs[bestIndex]!.visibleId)
|
||||
const rect = rects.get(sortableId)
|
||||
if (!rect) {
|
||||
return bestIndex
|
||||
}
|
||||
|
||||
const midpoint = rect.left + rect.width / 2
|
||||
return bestIndex + (pointer.x >= midpoint ? 1 : 0)
|
||||
}
|
||||
|
||||
export function applyInsertionIndex(
|
||||
currentOrder: string[],
|
||||
tabId: string,
|
||||
insertionIndex: number
|
||||
): string[] | null {
|
||||
const currentIndex = currentOrder.indexOf(tabId)
|
||||
if (currentIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextOrder = currentOrder.filter((candidate) => candidate !== tabId)
|
||||
const adjustedInsertionIndex = currentIndex < insertionIndex ? insertionIndex - 1 : insertionIndex
|
||||
const clampedInsertionIndex = Math.max(0, Math.min(adjustedInsertionIndex, nextOrder.length))
|
||||
nextOrder.splice(clampedInsertionIndex, 0, tabId)
|
||||
|
||||
return nextOrder.every((candidate, index) => candidate === currentOrder[index]) ? null : nextOrder
|
||||
}
|
||||
|
|
@ -1,20 +1,5 @@
|
|||
import { useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
closestCenter,
|
||||
pointerWithin,
|
||||
PointerSensor,
|
||||
type CollisionDetection,
|
||||
type DragEndEvent,
|
||||
type DragMoveEvent,
|
||||
type DragOverEvent,
|
||||
type DragStartEvent,
|
||||
type UniqueIdentifier,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from '@dnd-kit/core'
|
||||
import { arrayMove } from '@dnd-kit/sortable'
|
||||
import type { UniqueIdentifier } from '@dnd-kit/core'
|
||||
import type { TabGroup } from '../../../../shared/types'
|
||||
import { useAppStore } from '../../store'
|
||||
import type { TabSplitDirection } from '../../store/slices/tabs'
|
||||
|
||||
export type TabDropZone = 'center' | TabSplitDirection
|
||||
|
|
@ -70,39 +55,7 @@ export function canDropTabIntoPaneBody({
|
|||
return true
|
||||
}
|
||||
|
||||
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 {
|
||||
return (
|
||||
Boolean(value) && typeof value === 'object' && (value as TabPaneDropData).kind === 'pane-body'
|
||||
)
|
||||
}
|
||||
|
||||
function getDragCenter(
|
||||
event: Pick<DragMoveEvent, 'active' | 'delta'>
|
||||
): { x: number; y: number } | null {
|
||||
const translated = event.active.rect.current.translated
|
||||
if (translated) {
|
||||
return {
|
||||
x: translated.left + translated.width / 2,
|
||||
y: translated.top + translated.height / 2
|
||||
}
|
||||
}
|
||||
|
||||
const initial = event.active.rect.current.initial
|
||||
if (!initial) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
x: initial.left + initial.width / 2 + event.delta.x,
|
||||
y: initial.top + initial.height / 2 + event.delta.y
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDropZone(
|
||||
export function resolveDropZone(
|
||||
rect: { left: number; top: number; width: number; height: number },
|
||||
point: { x: number; y: number }
|
||||
): TabDropZone {
|
||||
|
|
@ -133,241 +86,14 @@ function resolveDropZone(
|
|||
return localY < rect.height / 2 ? 'up' : 'down'
|
||||
}
|
||||
|
||||
const collisionDetection: CollisionDetection = (args) => {
|
||||
const pointerCollisions = pointerWithin(args)
|
||||
return pointerCollisions.length > 0 ? pointerCollisions : closestCenter(args)
|
||||
}
|
||||
const PANE_BODY_PREFIX = 'tab-group-pane-body:'
|
||||
|
||||
export function getTabPaneBodyDroppableId(groupId: string): UniqueIdentifier {
|
||||
return `tab-group-pane-body:${groupId}`
|
||||
return `${PANE_BODY_PREFIX}${groupId}`
|
||||
}
|
||||
|
||||
export function useTabDragSplit({
|
||||
worktreeId,
|
||||
enabled = true
|
||||
}: {
|
||||
worktreeId: string
|
||||
/** When false (e.g. for hidden worktrees), returns empty sensors so no
|
||||
* DndContext pointer listeners are registered on the document. Multiple
|
||||
* simultaneous DndContext instances with active sensors can interfere. */
|
||||
enabled?: boolean
|
||||
}): {
|
||||
activeDrag: TabDragItemData | null
|
||||
collisionDetection: CollisionDetection
|
||||
hoveredDropTarget: HoveredTabDropTarget | null
|
||||
onDragCancel: () => void
|
||||
onDragEnd: (event: DragEndEvent) => void
|
||||
onDragMove: (event: DragMoveEvent) => void
|
||||
onDragOver: (event: DragOverEvent) => void
|
||||
onDragStart: (event: DragStartEvent) => void
|
||||
sensors: ReturnType<typeof useSensors>
|
||||
} {
|
||||
const reorderUnifiedTabs = useAppStore((state) => state.reorderUnifiedTabs)
|
||||
const dropUnifiedTab = useAppStore((state) => state.dropUnifiedTab)
|
||||
const [activeDrag, setActiveDrag] = useState<TabDragItemData | null>(null)
|
||||
const [hoveredDropTarget, setHoveredDropTarget] = useState<HoveredTabDropTarget | null>(null)
|
||||
|
||||
const pointerSensor = useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 }
|
||||
})
|
||||
const activeSensors = useSensors(pointerSensor)
|
||||
const emptySensors = useSensors()
|
||||
// Why: hidden worktrees stay mounted so their PTYs survive worktree
|
||||
// switches, but their DndContext should not register pointer listeners
|
||||
// on the document. Multiple active DndContext instances can interfere
|
||||
// with each other.
|
||||
const sensors = enabled ? activeSensors : emptySensors
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
setActiveDrag(null)
|
||||
setHoveredDropTarget(null)
|
||||
}, [])
|
||||
|
||||
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))
|
||||
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 }
|
||||
})
|
||||
},
|
||||
[worktreeId]
|
||||
)
|
||||
|
||||
const onDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const dragData = event.active.data.current
|
||||
if (!isTabDragData(dragData) || dragData.worktreeId !== worktreeId) {
|
||||
clearDragState()
|
||||
return
|
||||
}
|
||||
|
||||
setActiveDrag(dragData)
|
||||
},
|
||||
[clearDragState, worktreeId]
|
||||
)
|
||||
|
||||
const onDragMove = useCallback(
|
||||
(event: DragMoveEvent) => {
|
||||
updateHoveredPane(event)
|
||||
},
|
||||
[updateHoveredPane]
|
||||
)
|
||||
|
||||
const onDragOver = useCallback(
|
||||
(event: DragOverEvent) => {
|
||||
updateHoveredPane(event)
|
||||
},
|
||||
[updateHoveredPane]
|
||||
)
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const activeData = event.active.data.current
|
||||
const overData = event.over?.data.current
|
||||
|
||||
if (!event.over || !isTabDragData(activeData) || activeData.worktreeId !== worktreeId) {
|
||||
clearDragState()
|
||||
return
|
||||
}
|
||||
|
||||
if (isTabDragData(overData)) {
|
||||
if (activeData.unifiedTabId === overData.unifiedTabId) {
|
||||
clearDragState()
|
||||
return
|
||||
}
|
||||
|
||||
const state = useAppStore.getState()
|
||||
const groups = state.groupsByWorktree[worktreeId] ?? []
|
||||
const targetGroup = groups.find((group) => group.id === overData.groupId)
|
||||
if (!targetGroup) {
|
||||
clearDragState()
|
||||
return
|
||||
}
|
||||
|
||||
if (activeData.groupId === overData.groupId) {
|
||||
const oldIndex = targetGroup.tabOrder.indexOf(activeData.unifiedTabId)
|
||||
const newIndex = targetGroup.tabOrder.indexOf(overData.unifiedTabId)
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
reorderUnifiedTabs(
|
||||
overData.groupId,
|
||||
arrayMove(targetGroup.tabOrder, oldIndex, newIndex)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const targetIndex = targetGroup.tabOrder.indexOf(overData.unifiedTabId)
|
||||
dropUnifiedTab(activeData.unifiedTabId, {
|
||||
groupId: overData.groupId,
|
||||
index: targetIndex === -1 ? targetGroup.tabOrder.length : targetIndex
|
||||
})
|
||||
}
|
||||
|
||||
clearDragState()
|
||||
return
|
||||
}
|
||||
|
||||
if (isPaneDropData(overData)) {
|
||||
if (
|
||||
!canDropTabIntoPaneBody({
|
||||
activeDrag: activeData,
|
||||
groupsByWorktree: useAppStore.getState().groupsByWorktree,
|
||||
overGroupId: overData.groupId,
|
||||
worktreeId
|
||||
})
|
||||
) {
|
||||
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) {
|
||||
dropUnifiedTab(activeData.unifiedTabId, {
|
||||
groupId: overData.groupId,
|
||||
splitDirection: zone === 'center' ? undefined : zone
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearDragState()
|
||||
},
|
||||
[clearDragState, dropUnifiedTab, reorderUnifiedTabs, worktreeId]
|
||||
)
|
||||
|
||||
// Why: dnd-kit fires onDragCancel (not onDragEnd) when the user presses
|
||||
// Escape or the drag is otherwise aborted. Without this handler the
|
||||
// activeDrag and hoveredDropTarget state would remain stale, leaving the
|
||||
// drop overlay visible indefinitely.
|
||||
const onDragCancel = useCallback(() => {
|
||||
clearDragState()
|
||||
}, [clearDragState])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
activeDrag,
|
||||
collisionDetection,
|
||||
hoveredDropTarget,
|
||||
onDragCancel,
|
||||
onDragEnd,
|
||||
onDragMove,
|
||||
onDragOver,
|
||||
onDragStart,
|
||||
sensors
|
||||
}),
|
||||
[
|
||||
activeDrag,
|
||||
hoveredDropTarget,
|
||||
onDragCancel,
|
||||
onDragEnd,
|
||||
onDragMove,
|
||||
onDragOver,
|
||||
onDragStart,
|
||||
sensors
|
||||
]
|
||||
)
|
||||
export function parsePaneBodyDroppableId(id: string): { groupId: string } | null {
|
||||
return id.startsWith(PANE_BODY_PREFIX)
|
||||
? { groupId: id.slice(PANE_BODY_PREFIX.length) }
|
||||
: null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -468,6 +468,136 @@ describe('TabsSlice', () => {
|
|||
).toEqual([tab.id])
|
||||
})
|
||||
|
||||
it('updates active editor surface state when moving with activation', () => {
|
||||
store.setState({
|
||||
activeWorktreeId: WT,
|
||||
openFiles: [
|
||||
{
|
||||
id: 'file-a.ts',
|
||||
filePath: '/tmp/feature/file-a.ts',
|
||||
relativePath: 'file-a.ts',
|
||||
worktreeId: WT,
|
||||
language: 'typescript',
|
||||
isDirty: false,
|
||||
isPreview: false,
|
||||
mode: 'edit'
|
||||
}
|
||||
]
|
||||
})
|
||||
const tab = store.getState().createUnifiedTab(WT, 'editor', {
|
||||
id: 'file-a.ts',
|
||||
entityId: 'file-a.ts',
|
||||
label: 'file-a.ts'
|
||||
})
|
||||
const sourceGroupId = store.getState().groupsByWorktree[WT][0].id
|
||||
const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right')
|
||||
|
||||
store.getState().moveUnifiedTabToGroup(tab.id, targetGroupId!, { activate: true })
|
||||
|
||||
const state = store.getState()
|
||||
expect(state.activeGroupIdByWorktree[WT]).toBe(targetGroupId)
|
||||
expect(state.activeFileIdByWorktree[WT]).toBe('file-a.ts')
|
||||
expect(state.activeTabTypeByWorktree[WT]).toBe('editor')
|
||||
expect(state.activeFileId).toBe('file-a.ts')
|
||||
expect(state.activeTabType).toBe('editor')
|
||||
})
|
||||
|
||||
it('updates active browser surface state when moving with activation', () => {
|
||||
store.setState({ activeWorktreeId: WT })
|
||||
const browserTab = store.getState().createBrowserTab(WT, 'https://example.com', {
|
||||
title: 'Example',
|
||||
activate: true
|
||||
})
|
||||
const unifiedTab = store.getState().createUnifiedTab(WT, 'browser', {
|
||||
entityId: browserTab.id,
|
||||
label: 'Example'
|
||||
})
|
||||
const sourceGroupId = store.getState().groupsByWorktree[WT][0].id
|
||||
const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right')
|
||||
|
||||
store.getState().moveUnifiedTabToGroup(unifiedTab.id, targetGroupId!, { activate: true })
|
||||
|
||||
const state = store.getState()
|
||||
expect(state.activeGroupIdByWorktree[WT]).toBe(targetGroupId)
|
||||
expect(state.activeBrowserTabIdByWorktree[WT]).toBe(browserTab.id)
|
||||
expect(state.activeTabTypeByWorktree[WT]).toBe('browser')
|
||||
expect(state.activeBrowserTabId).toBe(browserTab.id)
|
||||
expect(state.activeTabType).toBe('browser')
|
||||
})
|
||||
|
||||
it('updates active terminal surface state when moving with activation', () => {
|
||||
store.setState({ activeWorktreeId: WT })
|
||||
const runtimeTerminal = {
|
||||
id: 'term-1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: WT,
|
||||
title: 'Terminal 1',
|
||||
sortOrder: 0,
|
||||
createdAt: Date.now(),
|
||||
customTitle: null,
|
||||
color: null
|
||||
}
|
||||
store.setState({ tabsByWorktree: { [WT]: [runtimeTerminal] } })
|
||||
const terminalTab = store.getState().createUnifiedTab(WT, 'terminal', {
|
||||
entityId: runtimeTerminal.id,
|
||||
label: 'Terminal 1'
|
||||
})
|
||||
const sourceGroupId = store.getState().groupsByWorktree[WT][0].id
|
||||
const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right')
|
||||
|
||||
store.getState().moveUnifiedTabToGroup(terminalTab.id, targetGroupId!, { activate: true })
|
||||
|
||||
const state = store.getState()
|
||||
expect(state.activeGroupIdByWorktree[WT]).toBe(targetGroupId)
|
||||
expect(state.activeTabIdByWorktree[WT]).toBe(runtimeTerminal.id)
|
||||
expect(state.activeTabTypeByWorktree[WT]).toBe('terminal')
|
||||
expect(state.activeTabId).toBe(runtimeTerminal.id)
|
||||
expect(state.activeTabType).toBe('terminal')
|
||||
})
|
||||
|
||||
it('does not overwrite global surface state when activating in a non-active worktree', () => {
|
||||
// When the moved tab's worktree is not the active worktree, per-worktree
|
||||
// state should still update but global surface state must remain untouched.
|
||||
// This prevents a background worktree move from hijacking the user's
|
||||
// current focus.
|
||||
const runtimeTerminal = {
|
||||
id: 'term-1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: WT,
|
||||
title: 'Terminal 1',
|
||||
sortOrder: 0,
|
||||
createdAt: Date.now(),
|
||||
customTitle: null,
|
||||
color: null
|
||||
}
|
||||
store.setState({
|
||||
activeWorktreeId: 'other-worktree',
|
||||
tabsByWorktree: { [WT]: [runtimeTerminal] }
|
||||
})
|
||||
const terminalTab = store.getState().createUnifiedTab(WT, 'terminal', {
|
||||
entityId: runtimeTerminal.id,
|
||||
label: 'Terminal 1'
|
||||
})
|
||||
const sourceGroupId = store.getState().groupsByWorktree[WT][0].id
|
||||
const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right')
|
||||
|
||||
// Capture global surface state before the move
|
||||
const beforeState = store.getState()
|
||||
const prevActiveTabId = beforeState.activeTabId
|
||||
const prevActiveTabType = beforeState.activeTabType
|
||||
const prevActiveFileId = beforeState.activeFileId
|
||||
|
||||
store.getState().moveUnifiedTabToGroup(terminalTab.id, targetGroupId!, { activate: true })
|
||||
|
||||
const state = store.getState()
|
||||
// Per-worktree state should still update
|
||||
expect(state.activeGroupIdByWorktree[WT]).toBe(targetGroupId)
|
||||
// Global surface state should NOT have changed
|
||||
expect(state.activeTabId).toBe(prevActiveTabId)
|
||||
expect(state.activeTabType).toBe(prevActiveTabType)
|
||||
expect(state.activeFileId).toBe(prevActiveFileId)
|
||||
})
|
||||
|
||||
it('copies a unified tab into another group', () => {
|
||||
const tab = store.getState().createUnifiedTab(WT, 'editor', {
|
||||
id: 'file-a.ts',
|
||||
|
|
|
|||
|
|
@ -941,7 +941,8 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
|||
}
|
||||
return group
|
||||
})
|
||||
return {
|
||||
const nextState = {
|
||||
...state,
|
||||
unifiedTabsByWorktree: {
|
||||
...state.unifiedTabsByWorktree,
|
||||
[worktreeId]: (state.unifiedTabsByWorktree[worktreeId] ?? []).map((candidate) =>
|
||||
|
|
@ -954,6 +955,20 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
|||
},
|
||||
activeGroupIdByWorktree: nextActiveGroupIdByWorktree
|
||||
}
|
||||
return {
|
||||
unifiedTabsByWorktree: nextState.unifiedTabsByWorktree,
|
||||
groupsByWorktree: nextState.groupsByWorktree,
|
||||
activeGroupIdByWorktree: nextState.activeGroupIdByWorktree,
|
||||
// Only update the active surface when this worktree is the active one,
|
||||
// matching the guard pattern used by other buildActiveSurfacePatch callers.
|
||||
// This must run even when opts.activate is false — moving the currently-
|
||||
// active tab out of the active group changes the source group's activeTabId
|
||||
// via pickNextActiveTab, so the workspace-level active surface can become
|
||||
// stale otherwise.
|
||||
...(state.activeWorktreeId === worktreeId
|
||||
? buildActiveSurfacePatch(nextState, worktreeId, targetGroupId)
|
||||
: {})
|
||||
}
|
||||
})
|
||||
return moved
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue