Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 b22bdc9ee6 fix(tabs): keep slow tab reorders after the native drag threshold
preventDefault on the first dragstart aborts the only HTML5 drag, so a
slow reorder that is still under 4px never reaches onTabDragStart. Leave
dragstart alone, start reorder chrome once travel hits 4px, and activate
on release when that drag cancelled the click and no reorder started.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 13:18:50 +00:00
Cursor Agentand陈大猫 125654896c fix(tabs): cancel sub-threshold drags so tab clicks still activate
Chromium fires dragstart after about 2px on a draggable tab and drops the
click. Track the primary press and preventDefault below 4px so activation
still runs, while deliberate reorders keep onTabDragStart.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-23 12:52:39 +00:00
5 changed files with 371 additions and 25 deletions

No files matched your search

+35
View File
@@ -315,6 +315,41 @@ test("draggable top-tab close buttons cancel the drag gesture on mousedown", ()
assert.equal(workspaceSource.includes("<button"), false);
});
test("draggable top tab roots activate sub-threshold presses and still reorder past the threshold", () => {
const tabTypes = ["plugin-view", "editor", "session", "workspace", "logView"];
for (const tabType of tabTypes) {
const marker = `data-tab-type="${tabType}"`;
const start = topTabItemsSource.indexOf(marker);
assert.notEqual(start, -1, marker);
const classNameAt = topTabItemsSource.indexOf("className={cn(", start);
assert.notEqual(classNameAt, -1, tabType);
const openingHandlers = topTabItemsSource.slice(start, classNameAt);
assert.match(openingHandlers, /onMouseDown=\{handleTabDragThresholdMouseDown\}/, tabType);
assert.match(openingHandlers, /onMouseUp=\{\(event\) => handleTabDragThresholdRelease\(event,/, tabType);
assert.match(
openingHandlers,
/onDragStart=\{\(event\) => \{\s*handleTabDragThresholdDragStart\(event\);\s*onTabDragStart\(event,/,
tabType,
);
assert.match(
openingHandlers,
/onDrag=\{\(event\) => \{\s*if \(!handleTabDragThresholdDrag\(event\)\) return;\s*onTabDragStart\(event,/,
tabType,
);
assert.match(
openingHandlers,
/onDragEnd=\{\(event\) => \{\s*handleTabDragThresholdRelease\(event,[\s\S]*?onTabDragEnd\(\);\s*\}\}/,
tabType,
);
assert.equal(openingHandlers.includes("handleTabCloseButtonMouseDown"), false, tabType);
assert.equal(openingHandlers.includes("preventDefault"), false, tabType);
}
assert.equal(topTabItemsSource.match(/onMouseDown=\{handleTabMiddleMouseDown\}/g)?.length ?? 0, 0);
assert.equal(topTabItemsSource.match(/handleTabDragThresholdRelease\(/g)?.length, 10);
});
test("session close button double click stays on the close button", () => {
let stopPropagationCount = 0;
+14 -7
View File
@@ -24,6 +24,7 @@ import { useWindowControls } from '../application/state/useWindowControls';
import { useSettingsChromeStore } from '../application/state/settingsChromeStore';
import { useI18n } from '../application/i18n/I18nProvider';
import { Host, TerminalSession, Workspace } from '../types';
import { isTabDragReorderStarted } from '../lib/tabInteractions';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { ContextMenuItem, ContextMenuSeparator } from './ui/context-menu';
@@ -520,14 +521,20 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
]);
const handleTabDragStart = useCallback((e: React.DragEvent, tabId: string) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('tab-reorder-id', tabId);
// Also set session-id for backward compatibility with workspace split functionality
// Only orphan sessions can be dragged to create workspaces
const isOrphanSession = orphanSessionMap.has(tabId);
if (isOrphanSession) {
e.dataTransfer.setData('session-id', tabId);
// Payload must be written on the original dragstart. A slow reorder calls
// this again from `drag`, when DataTransfer is already read-only.
try {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('tab-reorder-id', tabId);
// Also set session-id for backward compatibility with workspace split functionality
// Only orphan sessions can be dragged to create workspaces
if (orphanSessionMap.has(tabId)) {
e.dataTransfer.setData('session-id', tabId);
}
} catch (error) {
if (!(error instanceof DOMException)) throw error;
}
if (!isTabDragReorderStarted()) return;
draggedTabIdRef.current = tabId;
// Use setTimeout to allow the drag image to be captured before we change styles
setTimeout(() => {
+75 -17
View File
@@ -26,7 +26,7 @@ import { cn } from '../../lib/utils';
import { Host, TerminalSession, Workspace } from '../../types';
import { DISTRO_LOGOS, DISTRO_COLORS } from '../DistroAvatar';
import { getShellIconPath, isMonochromeShellIcon } from '../../lib/useDiscoveredShells';
import { handleTabCloseButtonMouseDown, handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../../lib/tabInteractions';
import { handleTabCloseButtonMouseDown, handleTabDragThresholdDrag, handleTabDragThresholdDragStart, handleTabDragThresholdMouseDown, handleTabDragThresholdRelease, handleTabMiddleClickClose } from '../../lib/tabInteractions';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '../ui/context-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { SessionTabContextMenuContent } from './SessionTabContextMenuContent';
@@ -516,6 +516,9 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
shortcutNumber,
}) => {
const isActive = useIsTabActive(tab.id);
const activate = useCallback(() => {
activeTabStore.setActiveTabId(tab.id);
}, [tab.id]);
const close = useCallback((event?: React.MouseEvent) => {
event?.stopPropagation();
onClose(tab.id);
@@ -526,12 +529,23 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
data-tab-type="plugin-view"
data-state={isActive ? 'active' : 'inactive'}
data-active={topTabActiveValue(isActive)}
onClick={() => activeTabStore.setActiveTabId(tab.id)}
onMouseDown={handleTabMiddleMouseDown}
onClick={activate}
onMouseDown={handleTabDragThresholdMouseDown}
onMouseUp={(event) => handleTabDragThresholdRelease(event, activate)}
onAuxClick={(event) => handleTabMiddleClickClose(event, close)}
draggable
onDragStart={(event) => onTabDragStart(event, tab.id)}
onDragEnd={onTabDragEnd}
onDragStart={(event) => {
handleTabDragThresholdDragStart(event);
onTabDragStart(event, tab.id);
}}
onDrag={(event) => {
if (!handleTabDragThresholdDrag(event)) return;
onTabDragStart(event, tab.id);
}}
onDragEnd={(event) => {
handleTabDragThresholdRelease(event, activate);
onTabDragEnd();
}}
onDragOver={(event) => onTabDragOver(event, tab.id)}
onDragLeave={onTabDragLeave}
onDrop={(event) => onTabDrop(event, tab.id)}
@@ -648,11 +662,22 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
data-state={isActive ? 'active' : 'inactive'}
data-active={topTabActiveValue(isActive)}
onClick={handleClick}
onMouseDown={handleTabMiddleMouseDown}
onMouseDown={handleTabDragThresholdMouseDown}
onMouseUp={(event) => handleTabDragThresholdRelease(event, handleClick)}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onRequestCloseEditorTab(editorTab.id))}
draggable
onDragStart={(e) => onTabDragStart(e, tabId)}
onDragEnd={onTabDragEnd}
onDragStart={(event) => {
handleTabDragThresholdDragStart(event);
onTabDragStart(event, tabId);
}}
onDrag={(event) => {
if (!handleTabDragThresholdDrag(event)) return;
onTabDragStart(event, tabId);
}}
onDragEnd={(event) => {
handleTabDragThresholdRelease(event, handleClick);
onTabDragEnd();
}}
onDragOver={(e) => onTabDragOver(e, tabId)}
onDragLeave={onTabDragLeave}
onDrop={(e) => onTabDrop(e, tabId)}
@@ -825,11 +850,22 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
data-active={topTabActiveValue(isActive)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseDown={handleTabMiddleMouseDown}
onMouseDown={handleTabDragThresholdMouseDown}
onMouseUp={(event) => handleTabDragThresholdRelease(event, handleClick)}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseSession(session.id))}
draggable
onDragStart={(e) => onTabDragStart(e, session.id)}
onDragEnd={onTabDragEnd}
onDragStart={(event) => {
handleTabDragThresholdDragStart(event);
onTabDragStart(event, session.id);
}}
onDrag={(event) => {
if (!handleTabDragThresholdDrag(event)) return;
onTabDragStart(event, session.id);
}}
onDragEnd={(event) => {
handleTabDragThresholdRelease(event, handleClick);
onTabDragEnd();
}}
onDragOver={(e) => onTabDragOver(e, session.id)}
onDragLeave={onTabDragLeave}
onDrop={(e) => onTabDrop(e, session.id)}
@@ -1030,11 +1066,22 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
data-host-drop-active={isHostDropTarget ? 'true' : 'false'}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseDown={handleTabMiddleMouseDown}
onMouseDown={handleTabDragThresholdMouseDown}
onMouseUp={(event) => handleTabDragThresholdRelease(event, handleClick)}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseWorkspace(workspace.id))}
draggable
onDragStart={(e) => onTabDragStart(e, workspace.id)}
onDragEnd={onTabDragEnd}
onDragStart={(event) => {
handleTabDragThresholdDragStart(event);
onTabDragStart(event, workspace.id);
}}
onDrag={(event) => {
if (!handleTabDragThresholdDrag(event)) return;
onTabDragStart(event, workspace.id);
}}
onDragEnd={(event) => {
handleTabDragThresholdRelease(event, handleClick);
onTabDragEnd();
}}
onDragOver={(e) => onTabDragOver(e, workspace.id)}
onDragLeave={onTabDragLeave}
onDrop={(e) => onTabDrop(e, workspace.id)}
@@ -1177,11 +1224,22 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
data-state={isActive ? 'active' : 'inactive'}
data-active={topTabActiveValue(isActive)}
onClick={handleClick}
onMouseDown={handleTabMiddleMouseDown}
onMouseDown={handleTabDragThresholdMouseDown}
onMouseUp={(event) => handleTabDragThresholdRelease(event, handleClick)}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseLogView(logView.id))}
draggable
onDragStart={(e) => onTabDragStart(e, logView.id)}
onDragEnd={onTabDragEnd}
onDragStart={(event) => {
handleTabDragThresholdDragStart(event);
onTabDragStart(event, logView.id);
}}
onDrag={(event) => {
if (!handleTabDragThresholdDrag(event)) return;
onTabDragStart(event, logView.id);
}}
onDragEnd={(event) => {
handleTabDragThresholdRelease(event, handleClick);
onTabDragEnd();
}}
onDragOver={(e) => onTabDragOver(e, logView.id)}
onDragLeave={onTabDragLeave}
onDrop={(e) => onTabDrop(e, logView.id)}
+152 -1
View File
@@ -4,21 +4,32 @@ import type React from "react";
import {
MIDDLE_MOUSE_BUTTON,
TAB_DRAG_THRESHOLD_PX,
handleTabCloseButtonMouseDown,
handleTabDragThresholdDrag,
handleTabDragThresholdDragStart,
handleTabDragThresholdMouseDown,
handleTabDragThresholdRelease,
handleTabDragThresholdReset,
isTabDragReorderStarted,
handleTabMiddleClickClose,
handleTabMiddleMouseDown,
} from "./tabInteractions.ts";
interface FakeMouseEvent {
button: number;
clientX: number;
clientY: number;
preventDefault: () => void;
stopPropagation: () => void;
}
const makeEvent = (button: number) => {
const makeEvent = (button: number, clientX = 0, clientY = 0) => {
const calls = { preventDefault: 0, stopPropagation: 0 };
const event = {
button,
clientX,
clientY,
preventDefault: () => {
calls.preventDefault++;
},
@@ -29,6 +40,12 @@ const makeEvent = (button: number) => {
return { event: event as unknown as React.MouseEvent, calls };
};
const beginPrimaryPress = (clientX: number, clientY: number) => {
const press = makeEvent(0, clientX, clientY);
handleTabDragThresholdMouseDown(press.event);
return press;
};
test("handleTabMiddleClickClose closes the tab on a middle click", () => {
let closed = 0;
const { event, calls } = makeEvent(MIDDLE_MOUSE_BUTTON);
@@ -66,6 +83,140 @@ test("handleTabMiddleMouseDown suppresses autoscroll only for the middle button"
assert.equal(left.calls.preventDefault, 0);
});
test("sub-threshold dragstart does not cancel the gesture, and mouseup activates", () => {
handleTabDragThresholdReset();
for (const distance of [0, TAB_DRAG_THRESHOLD_PX - 1]) {
const press = beginPrimaryPress(10, 20);
assert.equal(press.calls.preventDefault, 0, "primary mousedown must still allow a later drag");
const drag = makeEvent(0, 10 + distance, 20);
const skipReorder = handleTabDragThresholdDragStart(drag.event as unknown as React.DragEvent);
assert.equal(skipReorder, true, `distance ${distance} must not start a reorder`);
assert.equal(isTabDragReorderStarted(), false, `distance ${distance} must not arm reorder chrome`);
assert.equal(drag.calls.preventDefault, 0, `distance ${distance} must not preventDefault`);
let activated = 0;
const release = makeEvent(0, 10 + distance, 20);
handleTabDragThresholdRelease(release.event, () => {
activated++;
});
assert.equal(activated, 1, `distance ${distance} must activate on mouseup`);
assert.equal(release.calls.preventDefault, 0);
}
handleTabDragThresholdReset();
});
test("a sub-threshold release activates only once when mouseup and dragend both fire", () => {
handleTabDragThresholdReset();
beginPrimaryPress(4, 4);
const drag = makeEvent(0, 6, 4);
assert.equal(handleTabDragThresholdDragStart(drag.event as unknown as React.DragEvent), true);
let activated = 0;
const activate = () => {
activated++;
};
handleTabDragThresholdRelease(makeEvent(0, 6, 4).event, activate);
handleTabDragThresholdRelease(makeEvent(0, 6, 4).event, activate);
assert.equal(activated, 1);
handleTabDragThresholdReset();
});
test("mouseup without dragstart does not activate", () => {
handleTabDragThresholdReset();
beginPrimaryPress(1, 1);
let activated = 0;
handleTabDragThresholdRelease(makeEvent(0, 2, 1).event, () => {
activated++;
});
assert.equal(activated, 0);
handleTabDragThresholdReset();
});
test("drags at or above the threshold still reorder and do not activate on release", () => {
handleTabDragThresholdReset();
for (const distance of [TAB_DRAG_THRESHOLD_PX, TAB_DRAG_THRESHOLD_PX + 12]) {
beginPrimaryPress(5, 8);
const drag = makeEvent(0, 5 + distance, 8);
const skipReorder = handleTabDragThresholdDragStart(drag.event as unknown as React.DragEvent);
assert.equal(skipReorder, false, `distance ${distance} must keep the reorder drag`);
assert.equal(isTabDragReorderStarted(), true, `distance ${distance} must arm reorder chrome`);
assert.equal(drag.calls.preventDefault, 0, `distance ${distance} must not preventDefault`);
assert.equal(handleTabDragThresholdDrag(drag.event as unknown as React.DragEvent), false);
let activated = 0;
handleTabDragThresholdRelease(makeEvent(0, 5 + distance, 8).event, () => {
activated++;
});
assert.equal(activated, 0, `distance ${distance} must not fake-activate`);
}
handleTabDragThresholdReset();
});
test("a slow drag that starts under the threshold reorders once it crosses it", () => {
handleTabDragThresholdReset();
beginPrimaryPress(0, 0);
const dragStart = makeEvent(0, TAB_DRAG_THRESHOLD_PX - 1, 0);
assert.equal(
handleTabDragThresholdDragStart(dragStart.event as unknown as React.DragEvent),
true,
);
assert.equal(dragStart.calls.preventDefault, 0);
const stillShort = makeEvent(0, TAB_DRAG_THRESHOLD_PX - 1, 0);
assert.equal(handleTabDragThresholdDrag(stillShort.event as unknown as React.DragEvent), false);
const crossed = makeEvent(0, TAB_DRAG_THRESHOLD_PX, 0);
assert.equal(isTabDragReorderStarted(), false);
assert.equal(handleTabDragThresholdDrag(crossed.event as unknown as React.DragEvent), true);
assert.equal(isTabDragReorderStarted(), true);
assert.equal(handleTabDragThresholdDrag(crossed.event as unknown as React.DragEvent), false);
let activated = 0;
handleTabDragThresholdRelease(makeEvent(0, 1, 0).event, () => {
activated++;
});
assert.equal(activated, 0, "returning near the origin after a real drag must not activate");
handleTabDragThresholdReset();
});
test("handleTabDragThreshold ignores non-primary buttons", () => {
handleTabDragThresholdReset();
for (const button of [MIDDLE_MOUSE_BUTTON, 2]) {
const press = makeEvent(button, 4, 6);
handleTabDragThresholdMouseDown(press.event);
assert.equal(
press.calls.preventDefault,
button === MIDDLE_MOUSE_BUTTON ? 1 : 0,
`button ${button} mousedown preventDefault count`,
);
const drag = makeEvent(button, 4, 6);
const skipReorder = handleTabDragThresholdDragStart(drag.event as unknown as React.DragEvent);
assert.equal(skipReorder, false, `button ${button} must not skip dragstart`);
assert.equal(isTabDragReorderStarted(), true, `button ${button} must still arm reorder chrome`);
assert.equal(drag.calls.preventDefault, 0, `button ${button} dragstart must not preventDefault`);
}
handleTabDragThresholdReset();
});
test("handleTabDragThresholdReset drops a press so the next drag is not judged from stale coordinates", () => {
handleTabDragThresholdReset();
beginPrimaryPress(0, 0);
handleTabDragThresholdReset();
const drag = makeEvent(0, 1, 0);
const skipReorder = handleTabDragThresholdDragStart(drag.event as unknown as React.DragEvent);
assert.equal(skipReorder, false);
assert.equal(drag.calls.preventDefault, 0);
beginPrimaryPress(0, 0);
const next = makeEvent(0, 1, 0);
assert.equal(handleTabDragThresholdDragStart(next.event as unknown as React.DragEvent), true);
assert.equal(next.calls.preventDefault, 0);
handleTabDragThresholdReset();
});
test("handleTabCloseButtonMouseDown blocks ancestor drag only for the primary button", () => {
const left = makeEvent(0);
handleTabCloseButtonMouseDown(left.event);
+95
View File
@@ -32,6 +32,101 @@ export const handleTabCloseButtonMouseDown = (e: React.MouseEvent): void => {
e.preventDefault();
};
/**
* Pointer travel below this is a click, not a tab reorder. Chromium can fire
* `dragstart` after about 2px on a `draggable` tab and cancel the click that
* would activate it (#3886).
*/
export const TAB_DRAG_THRESHOLD_PX = 4;
type TabPress = {
x: number;
y: number;
dragStartFired: boolean;
reorderStarted: boolean;
};
let tabPress: TabPress | null = null;
const pointerDistanceFrom = (press: TabPress, x: number, y: number): number =>
Math.hypot(x - press.x, y - press.y);
const pointerDistance = (x: number, y: number): number => {
if (!tabPress) return Number.POSITIVE_INFINITY;
return pointerDistanceFrom(tabPress, x, y);
};
/**
* Record a primary-button press on a draggable tab root, and keep middle-click
* autoscroll suppression. Wire to the tab root `onMouseDown`. Non-primary
* buttons are not tracked, so they cannot cancel a later drag.
*/
export const handleTabDragThresholdMouseDown = (e: React.MouseEvent): void => {
handleTabMiddleMouseDown(e);
if (e.button !== 0) return;
tabPress = { x: e.clientX, y: e.clientY, dragStartFired: false, reorderStarted: false };
};
/**
* Note that `dragstart` fired. Never `preventDefault` here: cancelling the
* only `dragstart` aborts the HTML drag, so a slow reorder cannot continue
* (#3886). Returns true when travel is still below the reorder threshold.
* Callers still invoke `onTabDragStart` so the drag payload can be written
* while DataTransfer is writable; reorder chrome waits for
* `isTabDragReorderStarted`.
*/
export const handleTabDragThresholdDragStart = (e: React.DragEvent): boolean => {
if (!tabPress) return false;
tabPress.dragStartFired = true;
if (pointerDistance(e.clientX, e.clientY) < TAB_DRAG_THRESHOLD_PX) return true;
tabPress.reorderStarted = true;
return false;
};
/** True when this gesture should show reorder chrome, or there is no tracked press. */
export const isTabDragReorderStarted = (): boolean =>
!tabPress || tabPress.reorderStarted;
/**
* `dragstart` only fires once. Later travel arrives as `drag`. Returns true
* the first time that travel reaches the reorder threshold.
*/
export const handleTabDragThresholdDrag = (e: React.DragEvent): boolean => {
if (!tabPress?.dragStartFired || tabPress.reorderStarted) return false;
if (pointerDistance(e.clientX, e.clientY) < TAB_DRAG_THRESHOLD_PX) return false;
tabPress.reorderStarted = true;
return true;
};
type TabReleasePoint = {
button?: number;
clientX: number;
clientY: number;
};
/**
* Activate when `dragstart` already cancelled the click, the pointer stayed
* below the reorder threshold, and no reorder started. Wire to `mouseup` and
* `dragend` (Chromium delivers `dragend` after an uncancelled drag). A second
* call in the same gesture does not activate again.
*/
export const handleTabDragThresholdRelease = (
event: TabReleasePoint,
activate: () => void,
): void => {
const press = tabPress;
tabPress = null;
if (!press?.dragStartFired || press.reorderStarted) return;
if (event.button != null && event.button !== 0) return;
if (pointerDistanceFrom(press, event.clientX, event.clientY) >= TAB_DRAG_THRESHOLD_PX) return;
activate();
};
/** Drop a tracked press so the next gesture starts clean. */
export const handleTabDragThresholdReset = (): void => {
tabPress = null;
};
/**
* Close a tab when it is middle-clicked. Wire to `onAuxClick`, which fires for
* a completed non-primary click. Left clicks (tab activation) and right clicks