[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
480
components/terminal/runtime/middleClickBehavior.test.ts
Normal file
480
components/terminal/runtime/middleClickBehavior.test.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createRightClickMouseTrackingPressClaim,
|
||||
isMiddleClickContextMenuEvent,
|
||||
isShiftSelectionReplayMouseEvent,
|
||||
markMiddleClickContextMenuEvent,
|
||||
markShiftSelectionReplayMouseEvent,
|
||||
captureMiddleClickTerminalMouseEvent,
|
||||
resolveMiddleClickBehavior,
|
||||
shouldInterceptMouseTrackingContextMenu,
|
||||
shouldReplayShiftMouseSelectionAsMacOption,
|
||||
shouldStopRightClickMouseTrackingMouseUp,
|
||||
shouldStopShiftRightClickMouseTrackingMouseDown,
|
||||
} from "./middleClickBehavior";
|
||||
|
||||
test("resolveMiddleClickBehavior uses the explicit middle-click behavior", () => {
|
||||
assert.equal(resolveMiddleClickBehavior({ middleClickBehavior: "context-menu" }), "context-menu");
|
||||
assert.equal(resolveMiddleClickBehavior({ middleClickBehavior: "disabled" }), "disabled");
|
||||
});
|
||||
|
||||
test("resolveMiddleClickBehavior ignores unsupported middle-click behavior values", () => {
|
||||
assert.equal(
|
||||
resolveMiddleClickBehavior({ middleClickBehavior: "select-word" as never }),
|
||||
"paste",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveMiddleClickBehavior falls back to the legacy middle-click paste flag", () => {
|
||||
assert.equal(resolveMiddleClickBehavior({ middleClickPaste: true }), "paste");
|
||||
assert.equal(resolveMiddleClickBehavior({ middleClickPaste: false }), "disabled");
|
||||
assert.equal(resolveMiddleClickBehavior(undefined), "paste");
|
||||
});
|
||||
|
||||
test("middle-click context menu events are identifiable", () => {
|
||||
const event = {} as MouseEvent;
|
||||
|
||||
assert.equal(isMiddleClickContextMenuEvent(event), false);
|
||||
assert.equal(isMiddleClickContextMenuEvent(markMiddleClickContextMenuEvent(event)), true);
|
||||
});
|
||||
|
||||
test("mouse-tracking context menu capture lets middle-click menu events pass through", () => {
|
||||
assert.equal(
|
||||
shouldInterceptMouseTrackingContextMenu({
|
||||
event: markMiddleClickContextMenuEvent({} as MouseEvent),
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldInterceptMouseTrackingContextMenu({
|
||||
event: {} as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("mouse-tracking context menu capture lets Shift-modified mouse events pass through", () => {
|
||||
assert.equal(
|
||||
shouldInterceptMouseTrackingContextMenu({
|
||||
event: { shiftKey: true } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("mouse-tracking context menu capture prefers the terminal's current mode over stale cached state", () => {
|
||||
assert.equal(
|
||||
shouldInterceptMouseTrackingContextMenu({
|
||||
event: { shiftKey: false } as MouseEvent,
|
||||
mouseTracking: false,
|
||||
terminalMouseTrackingMode: "vt200",
|
||||
status: "connected",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldInterceptMouseTrackingContextMenu({
|
||||
event: { shiftKey: false } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
terminalMouseTrackingMode: "none",
|
||||
status: "connected",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("mouse-tracking context menu capture yields to the fullscreen-apps menu setting for context-menu clicks", () => {
|
||||
// Setting on + context-menu behavior: do NOT intercept, so Radix opens the menu.
|
||||
assert.equal(
|
||||
shouldInterceptMouseTrackingContextMenu({
|
||||
event: { shiftKey: false } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
rightClickBehavior: "context-menu",
|
||||
forceMenuInAlternateScreen: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
// Setting on but paste behavior: still intercept (setting is menu-only).
|
||||
assert.equal(
|
||||
shouldInterceptMouseTrackingContextMenu({
|
||||
event: { shiftKey: false } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
rightClickBehavior: "paste",
|
||||
forceMenuInAlternateScreen: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// Setting off (default): still intercept even for context-menu behavior.
|
||||
assert.equal(
|
||||
shouldInterceptMouseTrackingContextMenu({
|
||||
event: { shiftKey: false } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
rightClickBehavior: "context-menu",
|
||||
forceMenuInAlternateScreen: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("Shift selection replay events are identifiable", () => {
|
||||
const event = {} as MouseEvent;
|
||||
|
||||
assert.equal(isShiftSelectionReplayMouseEvent(event), false);
|
||||
assert.equal(isShiftSelectionReplayMouseEvent(markShiftSelectionReplayMouseEvent(event)), true);
|
||||
});
|
||||
|
||||
test("macOS mouse tracking replays plain Shift left-click as xterm option selection", () => {
|
||||
const event = {
|
||||
button: 0,
|
||||
shiftKey: true,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
} as MouseEvent;
|
||||
|
||||
assert.equal(
|
||||
shouldReplayShiftMouseSelectionAsMacOption({
|
||||
event,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
isMacPlatform: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("Shift selection replay is limited to the macOS connected mouse-tracking case", () => {
|
||||
const baseEvent = {
|
||||
button: 0,
|
||||
shiftKey: true,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
} as MouseEvent;
|
||||
|
||||
assert.equal(
|
||||
shouldReplayShiftMouseSelectionAsMacOption({
|
||||
event: baseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
isMacPlatform: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldReplayShiftMouseSelectionAsMacOption({
|
||||
event: baseEvent,
|
||||
mouseTracking: false,
|
||||
status: "connected",
|
||||
isMacPlatform: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldReplayShiftMouseSelectionAsMacOption({
|
||||
event: baseEvent,
|
||||
mouseTracking: true,
|
||||
status: "disconnected",
|
||||
isMacPlatform: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldReplayShiftMouseSelectionAsMacOption({
|
||||
event: { ...baseEvent, button: 2 } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
isMacPlatform: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldReplayShiftMouseSelectionAsMacOption({
|
||||
event: { ...baseEvent, shiftKey: false } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
isMacPlatform: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Shift selection replay ignores modified and already replayed mouse events", () => {
|
||||
const baseEvent = {
|
||||
button: 0,
|
||||
shiftKey: true,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
} as MouseEvent;
|
||||
|
||||
for (const event of [
|
||||
{ ...baseEvent, altKey: true },
|
||||
{ ...baseEvent, ctrlKey: true },
|
||||
{ ...baseEvent, metaKey: true },
|
||||
markShiftSelectionReplayMouseEvent({ ...baseEvent } as MouseEvent),
|
||||
]) {
|
||||
assert.equal(
|
||||
shouldReplayShiftMouseSelectionAsMacOption({
|
||||
event: event as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
isMacPlatform: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Shift right-click mousedown is stopped while connected mouse tracking is active", () => {
|
||||
assert.equal(
|
||||
shouldStopShiftRightClickMouseTrackingMouseDown({
|
||||
event: {
|
||||
button: 2,
|
||||
shiftKey: true,
|
||||
} as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("right-click mousedown is stopped when the fullscreen-apps menu setting forces the context menu", () => {
|
||||
// Unmodified right-click + setting on + context-menu behavior: stop it, like Shift+right-click.
|
||||
assert.equal(
|
||||
shouldStopShiftRightClickMouseTrackingMouseDown({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
rightClickBehavior: "context-menu",
|
||||
forceMenuInAlternateScreen: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// Setting on but paste behavior: do not stop (menu-only setting).
|
||||
assert.equal(
|
||||
shouldStopShiftRightClickMouseTrackingMouseDown({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
rightClickBehavior: "paste",
|
||||
forceMenuInAlternateScreen: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("right-click mousedown also uses the terminal's current mode", () => {
|
||||
assert.equal(
|
||||
shouldStopShiftRightClickMouseTrackingMouseDown({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
mouseTracking: false,
|
||||
terminalMouseTrackingMode: "vt200",
|
||||
status: "connected",
|
||||
rightClickBehavior: "context-menu",
|
||||
forceMenuInAlternateScreen: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopShiftRightClickMouseTrackingMouseDown({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
terminalMouseTrackingMode: "none",
|
||||
status: "connected",
|
||||
rightClickBehavior: "context-menu",
|
||||
forceMenuInAlternateScreen: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Shift right-click mousedown capture is limited to connected mouse tracking", () => {
|
||||
const baseEvent = {
|
||||
button: 2,
|
||||
shiftKey: true,
|
||||
} as MouseEvent;
|
||||
|
||||
assert.equal(
|
||||
shouldStopShiftRightClickMouseTrackingMouseDown({
|
||||
event: baseEvent,
|
||||
mouseTracking: false,
|
||||
status: "connected",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopShiftRightClickMouseTrackingMouseDown({
|
||||
event: baseEvent,
|
||||
mouseTracking: true,
|
||||
status: "disconnected",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopShiftRightClickMouseTrackingMouseDown({
|
||||
event: {
|
||||
button: 2,
|
||||
shiftKey: false,
|
||||
} as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopShiftRightClickMouseTrackingMouseDown({
|
||||
event: {
|
||||
button: 0,
|
||||
shiftKey: true,
|
||||
} as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("right-click mouseup reaches mouse-tracking apps when Netcatty did not claim the press", () => {
|
||||
// Herdr / Terminal.app: button-down was delivered, so button-up must be too.
|
||||
// Swallowing mouseup leaves the TUI stuck thinking the right button is held (#2721).
|
||||
const claim = createRightClickMouseTrackingPressClaim();
|
||||
assert.equal(
|
||||
claim.noteMouseDown({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
rightClickBehavior: "context-menu",
|
||||
forceMenuInAlternateScreen: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
claimedMatchingMouseDown: claim.consumeMouseUpClaim({ button: 2 } as MouseEvent),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("right-click mouseup is stopped only when Netcatty claimed the matching mousedown", () => {
|
||||
const shiftClaim = createRightClickMouseTrackingPressClaim();
|
||||
assert.equal(
|
||||
shiftClaim.noteMouseDown({
|
||||
event: { button: 2, shiftKey: true } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: { button: 2, shiftKey: true } as MouseEvent,
|
||||
claimedMatchingMouseDown: shiftClaim.consumeMouseUpClaim({ button: 2 } as MouseEvent),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
const forceMenuClaim = createRightClickMouseTrackingPressClaim();
|
||||
assert.equal(
|
||||
forceMenuClaim.noteMouseDown({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
rightClickBehavior: "context-menu",
|
||||
forceMenuInAlternateScreen: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
claimedMatchingMouseDown: forceMenuClaim.consumeMouseUpClaim({ button: 2 } as MouseEvent),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: { button: 0, shiftKey: false } as MouseEvent,
|
||||
claimedMatchingMouseDown: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("right-click mouseup pairs with the claimed mousedown, not the release modifiers", () => {
|
||||
// Shift+right press claimed by Netcatty, then Shift released before mouseup:
|
||||
// still swallow the release so xterm never sees a lone button-up.
|
||||
const claimedThenShiftReleased = createRightClickMouseTrackingPressClaim();
|
||||
assert.equal(
|
||||
claimedThenShiftReleased.noteMouseDown({
|
||||
event: { button: 2, shiftKey: true } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
claimedMatchingMouseDown: claimedThenShiftReleased.consumeMouseUpClaim(
|
||||
{ button: 2, shiftKey: false } as MouseEvent,
|
||||
),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// App-owned press, then Shift held on release: release must still reach xterm.
|
||||
const appOwnedThenShiftAdded = createRightClickMouseTrackingPressClaim();
|
||||
assert.equal(
|
||||
appOwnedThenShiftAdded.noteMouseDown({
|
||||
event: { button: 2, shiftKey: false } as MouseEvent,
|
||||
mouseTracking: true,
|
||||
status: "connected",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldStopRightClickMouseTrackingMouseUp({
|
||||
event: { button: 2, shiftKey: true } as MouseEvent,
|
||||
claimedMatchingMouseDown: appOwnedThenShiftAdded.consumeMouseUpClaim(
|
||||
{ button: 2, shiftKey: true } as MouseEvent,
|
||||
),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("middle-click terminal mouse down/up events are captured before xterm sees them", () => {
|
||||
const calls: string[] = [];
|
||||
const middleClickEvent = {
|
||||
button: 1,
|
||||
preventDefault: () => calls.push("preventDefault"),
|
||||
stopImmediatePropagation: () => calls.push("stopImmediatePropagation"),
|
||||
} as unknown as MouseEvent;
|
||||
|
||||
assert.equal(captureMiddleClickTerminalMouseEvent(middleClickEvent), true);
|
||||
assert.deepEqual(calls, ["preventDefault", "stopImmediatePropagation"]);
|
||||
|
||||
calls.length = 0;
|
||||
assert.equal(captureMiddleClickTerminalMouseEvent({
|
||||
button: 0,
|
||||
preventDefault: () => calls.push("preventDefault"),
|
||||
stopImmediatePropagation: () => calls.push("stopImmediatePropagation"),
|
||||
} as unknown as MouseEvent), false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
Reference in New Issue
Block a user