Middle-clicking a tab (mouse wheel click) is a conventional "close tab" gesture in browsers and editors. Wire it to every closeable tab strip: the top session / workspace / log-view / editor tabs and the SFTP tab bar. A small shared helper (lib/tabInteractions.ts) handles the gesture: onAuxClick closes the tab when button === 1, and onMouseDown calls preventDefault for the middle button so the Chromium/Electron autoscroll overlay does not appear. Left-click activation and right-click context menus are untouched. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,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 { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../lib/tabInteractions';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
@@ -355,6 +356,8 @@ const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
data-tab-type="editor"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onRequestCloseEditorTab(editorTab.id))}
|
||||
className="netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
@@ -458,6 +461,8 @@ const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
data-tab-type="session"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseSession(session.id))}
|
||||
draggable
|
||||
onDragStart={(e) => onTabDragStart(e, session.id)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
@@ -586,6 +591,8 @@ const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
data-tab-type="workspace"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseWorkspace(workspace.id))}
|
||||
draggable
|
||||
onDragStart={(e) => onTabDragStart(e, workspace.id)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
@@ -694,6 +701,8 @@ const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
||||
data-tab-type="logView"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseLogView(logView.id))}
|
||||
className="netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
|
||||
@@ -20,6 +20,7 @@ import React, {
|
||||
} from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from "../../lib/tabInteractions";
|
||||
import { useRenderTracker } from "../../lib/useRenderTracker";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { cn } from "../../lib/utils";
|
||||
@@ -322,6 +323,8 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
data-tab-type="sftp"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={(e) => handleSelectTabClick(e, tab.id)}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseTab(tab.id))}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, tab.id)}
|
||||
onDragEnd={handleTabDragEnd}
|
||||
|
||||
66
lib/tabInteractions.test.ts
Normal file
66
lib/tabInteractions.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type React from "react";
|
||||
|
||||
import {
|
||||
MIDDLE_MOUSE_BUTTON,
|
||||
handleTabMiddleClickClose,
|
||||
handleTabMiddleMouseDown,
|
||||
} from "./tabInteractions.ts";
|
||||
|
||||
interface FakeMouseEvent {
|
||||
button: number;
|
||||
preventDefault: () => void;
|
||||
stopPropagation: () => void;
|
||||
}
|
||||
|
||||
const makeEvent = (button: number) => {
|
||||
const calls = { preventDefault: 0, stopPropagation: 0 };
|
||||
const event = {
|
||||
button,
|
||||
preventDefault: () => {
|
||||
calls.preventDefault++;
|
||||
},
|
||||
stopPropagation: () => {
|
||||
calls.stopPropagation++;
|
||||
},
|
||||
} satisfies FakeMouseEvent;
|
||||
return { event: event as unknown as React.MouseEvent, calls };
|
||||
};
|
||||
|
||||
test("handleTabMiddleClickClose closes the tab on a middle click", () => {
|
||||
let closed = 0;
|
||||
const { event, calls } = makeEvent(MIDDLE_MOUSE_BUTTON);
|
||||
|
||||
handleTabMiddleClickClose(event, () => {
|
||||
closed++;
|
||||
});
|
||||
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(calls.preventDefault, 1);
|
||||
assert.equal(calls.stopPropagation, 1);
|
||||
});
|
||||
|
||||
test("handleTabMiddleClickClose ignores left and right clicks", () => {
|
||||
for (const button of [0, 2]) {
|
||||
let closed = 0;
|
||||
const { event, calls } = makeEvent(button);
|
||||
|
||||
handleTabMiddleClickClose(event, () => {
|
||||
closed++;
|
||||
});
|
||||
|
||||
assert.equal(closed, 0, `button ${button} must not close the tab`);
|
||||
assert.equal(calls.preventDefault, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("handleTabMiddleMouseDown suppresses autoscroll only for the middle button", () => {
|
||||
const middle = makeEvent(MIDDLE_MOUSE_BUTTON);
|
||||
handleTabMiddleMouseDown(middle.event);
|
||||
assert.equal(middle.calls.preventDefault, 1);
|
||||
|
||||
const left = makeEvent(0);
|
||||
handleTabMiddleMouseDown(left.event);
|
||||
assert.equal(left.calls.preventDefault, 0);
|
||||
});
|
||||
34
lib/tabInteractions.ts
Normal file
34
lib/tabInteractions.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type React from "react";
|
||||
|
||||
/**
|
||||
* The DOM `MouseEvent.button` value for the middle mouse button (wheel click).
|
||||
* 0 = left/primary, 1 = middle, 2 = right/secondary.
|
||||
*/
|
||||
export const MIDDLE_MOUSE_BUTTON = 1;
|
||||
|
||||
/**
|
||||
* Suppress the Chromium/Electron middle-click autoscroll affordance on a tab.
|
||||
* Wire to `onMouseDown`: autoscroll is armed on mousedown, so preventing the
|
||||
* default there stops the panning-cursor overlay from appearing when a user
|
||||
* middle-clicks a tab to close it (#1044).
|
||||
*/
|
||||
export const handleTabMiddleMouseDown = (e: React.MouseEvent): void => {
|
||||
if (e.button === MIDDLE_MOUSE_BUTTON) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* (context menu) are ignored so existing behavior is untouched.
|
||||
*/
|
||||
export const handleTabMiddleClickClose = (
|
||||
e: React.MouseEvent,
|
||||
close: () => void,
|
||||
): void => {
|
||||
if (e.button !== MIDDLE_MOUSE_BUTTON) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
};
|
||||
Reference in New Issue
Block a user