mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
feat(tab-drag): gate cross-group terminal drops when daemon is off
Block terminal-tab drops that would cross groups (sortable into a different group, tab-bar empty-space, or pane-body split) while settings.experimentalTerminalDaemon is false. Browser and editor tabs drag freely. Same-group terminal reorder is unaffected. Why: moving a live terminal between groups remounts the pane, and the non-daemon reattach path on this branch was too brittle to ship (was just reverted). Daemon-on reattach uses snapshots and is safe, so the gate is a no-op there. Gate can be removed once the daemon flips on by default. UX: the overlay/drop-indicator is hidden for blocked targets via resetOver() in handleDragOver, so the user doesn't see a preview for a drop that will be refused. Defensive guards in handleDragEnd catch any race between dragOver state and drop.
This commit is contained in:
parent
2d8a49422f
commit
f0a89a2d5d
1 changed files with 61 additions and 2 deletions
|
|
@ -1,3 +1,8 @@
|
|||
/* 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 shared drag gate for
|
||||
* terminal tabs, 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,
|
||||
|
|
@ -25,7 +30,8 @@ import {
|
|||
isTabDragData,
|
||||
parseGroupDropId,
|
||||
parseSharedSortableId,
|
||||
type CrossGroupDragState
|
||||
type CrossGroupDragState,
|
||||
type TabDragData
|
||||
} from './CrossGroupDragContext'
|
||||
import {
|
||||
canDropTabIntoPaneBody,
|
||||
|
|
@ -48,6 +54,33 @@ import {
|
|||
const EMPTY_GROUPS: TabGroup[] = []
|
||||
const EMPTY_TABS: Tab[] = []
|
||||
|
||||
// Why: moving a live terminal tab between groups remounts the pane, and the
|
||||
// non-daemon PTY reattach path relies on a brittle rAF-retry + width-jitter
|
||||
// sequence that can leave the destination pane blank under load. Until the
|
||||
// terminal daemon flips on by default (which gives snapshot-based reattach),
|
||||
// gate cross-group terminal moves so users never see a half-working drag.
|
||||
// Same-group reorder is unaffected — it doesn't tear down the pane. Browser
|
||||
// and editor tabs have no PTY lifecycle and always drag freely. Pane-body
|
||||
// split drops onto another group are treated like cross-group moves because
|
||||
// they also remount the terminal pane. Center-zone drops don't create a new
|
||||
// group, so they reduce to the tab-bar path and the same rules apply.
|
||||
function isTerminalCrossGroupDropBlocked(
|
||||
drag: TabDragData,
|
||||
targetGroupId: string,
|
||||
splitZone: TabDropZone | null,
|
||||
daemonEnabled: boolean
|
||||
): boolean {
|
||||
if (daemonEnabled || drag.contentType !== 'terminal') {
|
||||
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
|
||||
|
|
@ -66,6 +99,7 @@ export default function TabGroupDndContext({
|
|||
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
|
||||
|
|
@ -234,6 +268,12 @@ export default function TabGroupDndContext({
|
|||
const overId = String(event.over.id)
|
||||
const sortableTarget = parseSharedSortableId(overId)
|
||||
if (sortableTarget) {
|
||||
if (
|
||||
isTerminalCrossGroupDropBlocked(activeTab, sortableTarget.groupId, null, daemonEnabled)
|
||||
) {
|
||||
resetOver()
|
||||
return
|
||||
}
|
||||
const orderedTabs = groupVisibleOrder.get(sortableTarget.groupId) ?? []
|
||||
const insertionIndex = computeInsertionIndex(
|
||||
orderedTabs,
|
||||
|
|
@ -252,6 +292,12 @@ export default function TabGroupDndContext({
|
|||
|
||||
const groupDropTarget = parseGroupDropId(overId)
|
||||
if (groupDropTarget) {
|
||||
if (
|
||||
isTerminalCrossGroupDropBlocked(activeTab, groupDropTarget.groupId, null, daemonEnabled)
|
||||
) {
|
||||
resetOver()
|
||||
return
|
||||
}
|
||||
const orderedTabs = groupVisibleOrder.get(groupDropTarget.groupId) ?? []
|
||||
const insertionIndex = computeInsertionFromPointer(
|
||||
orderedTabs,
|
||||
|
|
@ -303,6 +349,12 @@ export default function TabGroupDndContext({
|
|||
resetOver()
|
||||
return
|
||||
}
|
||||
if (
|
||||
isTerminalCrossGroupDropBlocked(activeTab, paneBodyTarget.groupId, zone, daemonEnabled)
|
||||
) {
|
||||
resetOver()
|
||||
return
|
||||
}
|
||||
setDragState({
|
||||
activeTab,
|
||||
overGroupId: paneBodyTarget.groupId,
|
||||
|
|
@ -314,7 +366,7 @@ export default function TabGroupDndContext({
|
|||
|
||||
resetOver()
|
||||
},
|
||||
[groupVisibleOrder, worktreeId]
|
||||
[daemonEnabled, groupVisibleOrder, worktreeId]
|
||||
)
|
||||
|
||||
const handleDragCancel = useCallback(
|
||||
|
|
@ -347,6 +399,9 @@ export default function TabGroupDndContext({
|
|||
if (zone === 'center' && activeTab.sourceGroupId === targetGroupId) {
|
||||
return
|
||||
}
|
||||
if (isTerminalCrossGroupDropBlocked(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 {
|
||||
|
|
@ -389,6 +444,9 @@ export default function TabGroupDndContext({
|
|||
return
|
||||
}
|
||||
|
||||
if (isTerminalCrossGroupDropBlocked(activeTab, targetGroupId, null, daemonEnabled)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
moveUnifiedTabToGroup(activeTab.unifiedTabId, targetGroupId, {
|
||||
index: currentDragState.overTabBarIndex,
|
||||
|
|
@ -402,6 +460,7 @@ export default function TabGroupDndContext({
|
|||
},
|
||||
[
|
||||
clearDragState,
|
||||
daemonEnabled,
|
||||
dropUnifiedTab,
|
||||
maybeCloseEmptySourceGroup,
|
||||
moveUnifiedTabToGroup,
|
||||
|
|
|
|||
Loading…
Reference in a new issue