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
216 lines
7.3 KiB
TypeScript
216 lines
7.3 KiB
TypeScript
import type { MiddleClickBehavior, RightClickBehavior, TerminalSettings } from "../../../domain/models";
|
|
|
|
type MiddleClickSettings = Partial<Pick<TerminalSettings, "middleClickBehavior" | "middleClickPaste">>;
|
|
const MIDDLE_CONTEXT_MENU_EVENT_KEY = "__netcattyMiddleContextMenu";
|
|
|
|
type MiddleClickContextMenuEvent = MouseEvent & {
|
|
[MIDDLE_CONTEXT_MENU_EVENT_KEY]?: boolean;
|
|
};
|
|
|
|
const SHIFT_SELECTION_REPLAY_EVENT_KEY = "__netcattyShiftSelectionReplay";
|
|
|
|
type ShiftSelectionReplayMouseEvent = MouseEvent & {
|
|
[SHIFT_SELECTION_REPLAY_EVENT_KEY]?: boolean;
|
|
};
|
|
|
|
export interface MouseTrackingContextMenuCaptureState {
|
|
event: MouseEvent;
|
|
mouseTracking: boolean;
|
|
/** Current xterm mouse tracking mode, when available at event time. */
|
|
terminalMouseTrackingMode?: string;
|
|
status?: string | null;
|
|
/** The user's configured right-click action. */
|
|
rightClickBehavior?: RightClickBehavior;
|
|
/** When true, show the app context menu over fullscreen apps (tmux/vim). */
|
|
forceMenuInAlternateScreen?: boolean;
|
|
}
|
|
|
|
export interface ShiftMouseSelectionReplayState {
|
|
event: MouseEvent;
|
|
mouseTracking: boolean;
|
|
status?: string | null;
|
|
isMacPlatform: boolean;
|
|
}
|
|
|
|
export interface ShiftRightClickMouseDownCaptureState {
|
|
event: MouseEvent;
|
|
mouseTracking: boolean;
|
|
/** Current xterm mouse tracking mode, when available at event time. */
|
|
terminalMouseTrackingMode?: string;
|
|
status?: string | null;
|
|
/** The user's configured right-click action. */
|
|
rightClickBehavior?: RightClickBehavior;
|
|
/** When true, show the app context menu over fullscreen apps (tmux/vim). */
|
|
forceMenuInAlternateScreen?: boolean;
|
|
}
|
|
|
|
export const resolveMiddleClickBehavior = (
|
|
settings?: MiddleClickSettings | null,
|
|
): MiddleClickBehavior => {
|
|
const behavior = settings?.middleClickBehavior;
|
|
if (
|
|
behavior === "context-menu" ||
|
|
behavior === "paste" ||
|
|
behavior === "disabled"
|
|
) {
|
|
return behavior;
|
|
}
|
|
|
|
return settings?.middleClickPaste === false ? "disabled" : "paste";
|
|
};
|
|
|
|
export const markMiddleClickContextMenuEvent = (event: MouseEvent): MouseEvent => {
|
|
Object.defineProperty(event, MIDDLE_CONTEXT_MENU_EVENT_KEY, {
|
|
value: true,
|
|
configurable: true,
|
|
});
|
|
return event;
|
|
};
|
|
|
|
export const isMiddleClickContextMenuEvent = (event: MouseEvent): boolean =>
|
|
(event as MiddleClickContextMenuEvent)[MIDDLE_CONTEXT_MENU_EVENT_KEY] === true;
|
|
|
|
export const markShiftSelectionReplayMouseEvent = (event: MouseEvent): MouseEvent => {
|
|
Object.defineProperty(event, SHIFT_SELECTION_REPLAY_EVENT_KEY, {
|
|
value: true,
|
|
configurable: true,
|
|
});
|
|
return event;
|
|
};
|
|
|
|
export const isShiftSelectionReplayMouseEvent = (event: MouseEvent): boolean =>
|
|
(event as ShiftSelectionReplayMouseEvent)[SHIFT_SELECTION_REPLAY_EVENT_KEY] === true;
|
|
|
|
// When the "show context menu over fullscreen apps" setting is on and the
|
|
// right-click action is the context menu, an unmodified right-click should
|
|
// behave like Shift+right-click: let the contextmenu event through so Radix
|
|
// opens the app menu, and stop the button press from reaching the TUI. Paste /
|
|
// select-word actions are unaffected — the setting is about the menu only.
|
|
const forcesMenuOverMouseTracking = ({
|
|
rightClickBehavior,
|
|
forceMenuInAlternateScreen,
|
|
}: {
|
|
rightClickBehavior?: RightClickBehavior;
|
|
forceMenuInAlternateScreen?: boolean;
|
|
}): boolean => Boolean(forceMenuInAlternateScreen && rightClickBehavior === "context-menu");
|
|
|
|
export const isMouseTrackingActive = ({
|
|
mouseTracking,
|
|
terminalMouseTrackingMode,
|
|
}: Pick<MouseTrackingContextMenuCaptureState, "mouseTracking" | "terminalMouseTrackingMode">): boolean =>
|
|
terminalMouseTrackingMode === undefined
|
|
? mouseTracking
|
|
: terminalMouseTrackingMode !== "none";
|
|
|
|
export const shouldInterceptMouseTrackingContextMenu = ({
|
|
event,
|
|
mouseTracking,
|
|
terminalMouseTrackingMode,
|
|
status,
|
|
rightClickBehavior,
|
|
forceMenuInAlternateScreen,
|
|
}: MouseTrackingContextMenuCaptureState): boolean =>
|
|
isMouseTrackingActive({ mouseTracking, terminalMouseTrackingMode })
|
|
&& status === "connected"
|
|
&& !event.shiftKey
|
|
&& !isMiddleClickContextMenuEvent(event)
|
|
&& !forcesMenuOverMouseTracking({ rightClickBehavior, forceMenuInAlternateScreen });
|
|
|
|
export const shouldReplayShiftMouseSelectionAsMacOption = ({
|
|
event,
|
|
mouseTracking,
|
|
status,
|
|
isMacPlatform,
|
|
}: ShiftMouseSelectionReplayState): boolean =>
|
|
isMacPlatform
|
|
&& mouseTracking
|
|
&& status === "connected"
|
|
&& event.button === 0
|
|
&& event.shiftKey
|
|
&& !event.altKey
|
|
&& !event.ctrlKey
|
|
&& !event.metaKey
|
|
&& !isShiftSelectionReplayMouseEvent(event);
|
|
|
|
export const shouldStopShiftRightClickMouseTrackingMouseDown = ({
|
|
event,
|
|
mouseTracking,
|
|
terminalMouseTrackingMode,
|
|
status,
|
|
rightClickBehavior,
|
|
forceMenuInAlternateScreen,
|
|
}: ShiftRightClickMouseDownCaptureState): boolean =>
|
|
isMouseTrackingActive({ mouseTracking, terminalMouseTrackingMode })
|
|
&& status === "connected"
|
|
&& event.button === 2
|
|
&& (event.shiftKey || forcesMenuOverMouseTracking({ rightClickBehavior, forceMenuInAlternateScreen }));
|
|
|
|
// Pair mouseup with mousedown ownership. When Netcatty claims the press
|
|
// (Shift / fullscreen-apps menu), also swallow the release so xterm never
|
|
// reports a lone button-up. When the TUI owns the press (Herdr, tmux menus,
|
|
// vim, ...), the release must reach xterm too - otherwise the app stays stuck
|
|
// with the right button held and mouse UI dies until restart (#2721).
|
|
// Ownership is remembered from the actual mousedown claim — never re-derived
|
|
// from mouseup modifiers (Shift may change between press and release).
|
|
export interface RightClickMouseTrackingPressClaim {
|
|
/** Evaluate + record whether this right-button mousedown was claimed. */
|
|
noteMouseDown: (state: ShiftRightClickMouseDownCaptureState) => boolean;
|
|
/** Consume the pending claim for a right-button mouseup (clears state). */
|
|
consumeMouseUpClaim: (event: MouseEvent) => boolean;
|
|
}
|
|
|
|
export const createRightClickMouseTrackingPressClaim = (): RightClickMouseTrackingPressClaim => {
|
|
let claimedMatchingMouseDown = false;
|
|
|
|
return {
|
|
noteMouseDown(state) {
|
|
const shouldStop = shouldStopShiftRightClickMouseTrackingMouseDown(state);
|
|
if (state.event.button === 2) {
|
|
claimedMatchingMouseDown = shouldStop;
|
|
}
|
|
return shouldStop;
|
|
},
|
|
consumeMouseUpClaim(event) {
|
|
if (event.button !== 2) return false;
|
|
const claimed = claimedMatchingMouseDown;
|
|
claimedMatchingMouseDown = false;
|
|
return claimed;
|
|
},
|
|
};
|
|
};
|
|
|
|
export const shouldStopRightClickMouseTrackingMouseUp = ({
|
|
event,
|
|
claimedMatchingMouseDown,
|
|
}: {
|
|
event: MouseEvent;
|
|
claimedMatchingMouseDown: boolean;
|
|
}): boolean => event.button === 2 && claimedMatchingMouseDown;
|
|
|
|
export const createMacOptionForcedSelectionMouseEvent = (event: MouseEvent): MouseEvent =>
|
|
markShiftSelectionReplayMouseEvent(new MouseEvent(event.type, {
|
|
bubbles: event.bubbles,
|
|
cancelable: event.cancelable,
|
|
composed: event.composed,
|
|
detail: event.detail,
|
|
view: event.view,
|
|
screenX: event.screenX,
|
|
screenY: event.screenY,
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
ctrlKey: event.ctrlKey,
|
|
altKey: true,
|
|
shiftKey: false,
|
|
metaKey: event.metaKey,
|
|
button: event.button,
|
|
buttons: event.buttons,
|
|
relatedTarget: event.relatedTarget,
|
|
}));
|
|
|
|
export const captureMiddleClickTerminalMouseEvent = (event: MouseEvent): boolean => {
|
|
if (event.button !== 1) return false;
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
return true;
|
|
};
|