fix(terminal): only Cmd+F opens Find on macOS; Ctrl+F reaches the shell

This commit is contained in:
Mohamed Boudra
2026-09-15 16:20:00 +02:00
parent f5c90b2758
commit d3f308af32
6 changed files with 206 additions and 94 deletions
+2 -7
View File
@@ -15,6 +15,7 @@ import { isWeb } from "@/constants/platform";
import { hasActiveWebOverlay } from "@/lib/overlay-root";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import type { TerminalEmulatorHandle } from "@/components/terminal-emulator-contract";
import { isFindShortcut } from "../runtime/terminal-find-shortcut";
import type { TerminalFindResult } from "../runtime/terminal-emulator-runtime";
export interface TerminalPaneFindHandle {
@@ -64,13 +65,7 @@ export const TerminalFind = forwardRef<
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
return;
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === "f" &&
terminal.current?.find
) {
if (isFindShortcut(event) && terminal.current?.find) {
event.preventDefault();
show();
}
@@ -192,6 +192,63 @@ describe("terminal-emulator-runtime", () => {
vi.useRealTimers();
});
it.each([
{ isMac: true, ctrlKey: true, metaKey: false, opensFind: false },
{ isMac: true, ctrlKey: false, metaKey: true, opensFind: true },
{ isMac: false, ctrlKey: true, metaKey: false, opensFind: true },
{ isMac: false, ctrlKey: false, metaKey: true, opensFind: false },
{ isMac: true, ctrlKey: false, metaKey: true, shiftKey: true, opensFind: false },
{ isMac: true, ctrlKey: false, metaKey: true, altKey: true, opensFind: false },
{ isMac: false, ctrlKey: true, metaKey: false, shiftKey: true, opensFind: false },
{ isMac: false, ctrlKey: true, metaKey: false, altKey: true, opensFind: false },
{ isMac: true, ctrlKey: true, metaKey: true, opensFind: false },
{ isMac: false, ctrlKey: true, metaKey: true, opensFind: false },
])(
"routes Find with isMac=$isMac ctrl=$ctrlKey meta=$metaKey shift=$shiftKey alt=$altKey",
({ isMac, ctrlKey, metaKey, shiftKey = false, altKey = false, opensFind }) => {
const runtime = new TerminalEmulatorRuntime({ isMac });
let findRequests = 0;
let prevented = false;
let stopped = false;
runtime.setCallbacks({
callbacks: {
onFindRequest: () => {
findRequests += 1;
},
},
});
const event = {
type: "keydown",
key: "f",
ctrlKey,
metaKey,
shiftKey,
altKey,
isComposing: false,
preventDefault: () => {
prevented = true;
},
stopPropagation: () => {
stopped = true;
},
} as KeyboardEvent;
let passedToXterm: boolean | undefined;
runtime.attachKeyEventHandler({
attachCustomKeyEventHandler: (handler) => {
passedToXterm = handler(event);
},
hasSelection: () => false,
getSelection: () => "",
paste: () => {},
});
expect(passedToXterm).toBe(!opensFind);
expect(findRequests).toBe(opensFind ? 1 : 0);
expect(prevented).toBe(opensFind);
expect(stopped).toBe(opensFind);
},
);
it("drains contiguous plain writes without waiting for each commit, gating a clear behind them", () => {
const { runtime, terminal, writeCallbacks, writeTexts } = createRuntimeWithTerminal();
const committed: string[] = [];
@@ -28,6 +28,7 @@ import {
type TerminalLocalFileLinkSource,
type TerminalLocalFileLinkTarget,
} from "../local-links/terminal-local-link-provider";
import { isMac, isFindShortcut } from "./terminal-find-shortcut";
import { resolveTerminalFontFamily, resolveTerminalFontSize } from "./terminal-font";
export type TerminalOutputData = Uint8Array;
@@ -140,11 +141,6 @@ declare global {
}
}
const isMac =
typeof navigator !== "undefined" &&
(/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") ||
/Mac/i.test((navigator as Navigator & { platform?: string }).platform ?? ""));
const isAppleHandheld =
typeof navigator !== "undefined" &&
isAppleHandheldPlatform({
@@ -182,6 +178,8 @@ function withOverviewRulerBorderHidden(theme: ITheme): ITheme {
}
export class TerminalEmulatorRuntime {
constructor(private readonly options: { isMac: boolean } = { isMac }) {}
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
private pendingModifiers: PendingTerminalModifiers = {
ctrl: false,
@@ -297,15 +295,93 @@ export class TerminalEmulatorRuntime {
);
}
private handleFindShortcut(event: KeyboardEvent): boolean {
if (
!(event.metaKey || event.ctrlKey) ||
event.shiftKey ||
event.altKey ||
event.key.toLowerCase() !== "f" ||
!this.callbacks.onFindRequest
)
attachKeyEventHandler(
terminal: Pick<
Terminal,
"attachCustomKeyEventHandler" | "hasSelection" | "getSelection" | "paste"
>,
): void {
terminal.attachCustomKeyEventHandler((event) => {
if (event.type !== "keydown" || event.isComposing) {
return true;
}
if (this.handleFindShortcut(event)) return false;
if (
!this.options.isMac &&
event.ctrlKey &&
!event.shiftKey &&
!event.altKey &&
!event.metaKey
) {
const key = event.key.toLowerCase();
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
if (key === "c" && terminal.hasSelection()) {
void navigator.clipboard.writeText(terminal.getSelection());
return false;
}
// Ctrl+V: paste from clipboard into terminal
if (key === "v") {
event.preventDefault();
void navigator.clipboard.readText().then((text) => {
if (text) {
terminal.paste(text);
}
return;
});
return false;
}
return true;
}
const normalizedKey = normalizeDomTerminalKey(event.key);
if (!normalizedKey || isTerminalModifierDomKey(event.key)) {
return true;
}
if (
!shouldInterceptDomTerminalKey({
key: normalizedKey,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
metaKey: event.metaKey,
pendingModifiers: this.pendingModifiers,
enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(),
isAppleHandheld,
})
) {
return true;
}
const modifiers = mergeTerminalModifiers({
pendingModifiers: this.pendingModifiers,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
metaKey: event.metaKey,
});
this.callbacks.onTerminalKey?.({
key: normalizeTerminalTransportKey(normalizedKey),
...modifiers,
});
if (this.pendingModifiers.ctrl || this.pendingModifiers.shift || this.pendingModifiers.alt) {
this.callbacks.onPendingModifiersConsumed?.();
}
event.preventDefault();
event.stopPropagation();
return false;
});
}
private handleFindShortcut(event: KeyboardEvent): boolean {
if (!isFindShortcut(event, this.options) || !this.callbacks.onFindRequest) return false;
event.preventDefault();
event.stopPropagation();
this.callbacks.onFindRequest();
@@ -555,77 +631,7 @@ export class TerminalEmulatorRuntime {
this.callbacks.onInput?.(data);
});
terminal.attachCustomKeyEventHandler((event) => {
if (event.type !== "keydown" || event.isComposing) {
return true;
}
if (this.handleFindShortcut(event)) return false;
if (!isMac && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) {
const key = event.key.toLowerCase();
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
if (key === "c" && terminal.hasSelection()) {
void navigator.clipboard.writeText(terminal.getSelection());
return false;
}
// Ctrl+V: paste from clipboard into terminal
if (key === "v") {
event.preventDefault();
void navigator.clipboard.readText().then((text) => {
if (text) {
terminal.paste(text);
}
return;
});
return false;
}
return true;
}
const normalizedKey = normalizeDomTerminalKey(event.key);
if (!normalizedKey || isTerminalModifierDomKey(event.key)) {
return true;
}
if (
!shouldInterceptDomTerminalKey({
key: normalizedKey,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
metaKey: event.metaKey,
pendingModifiers: this.pendingModifiers,
enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(),
isAppleHandheld,
})
) {
return true;
}
const modifiers = mergeTerminalModifiers({
pendingModifiers: this.pendingModifiers,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
metaKey: event.metaKey,
});
this.callbacks.onTerminalKey?.({
key: normalizeTerminalTransportKey(normalizedKey),
...modifiers,
});
if (this.pendingModifiers.ctrl || this.pendingModifiers.shift || this.pendingModifiers.alt) {
this.callbacks.onPendingModifiersConsumed?.();
}
event.preventDefault();
event.stopPropagation();
return false;
});
this.attachKeyEventHandler(terminal);
const removeTouchListeners = this.setupTouchScrollHandlers({
root: input.root,
@@ -0,0 +1,14 @@
export const isMac =
typeof navigator !== "undefined" &&
(/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") ||
/Mac/i.test((navigator as Navigator & { platform?: string }).platform ?? ""));
export function isFindShortcut(
event: Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "shiftKey" | "altKey">,
platform = { isMac },
): boolean {
const primaryModifier = platform.isMac
? event.metaKey && !event.ctrlKey
: event.ctrlKey && !event.metaKey;
return primaryModifier && !event.shiftKey && !event.altKey && event.key.toLowerCase() === "f";
}
@@ -12,14 +12,14 @@ afterEach(() => {
runtime?.unmount();
root?.remove();
});
function mount() {
function mount(options?: { isMac: boolean }) {
root = document.createElement("div");
Object.assign(root.style, { width: "480px", height: "200px" });
const host = document.createElement("div");
Object.assign(host.style, { width: "100%", height: "100%" });
root.append(host);
document.body.append(root);
runtime = new TerminalEmulatorRuntime();
runtime = new TerminalEmulatorRuntime(options);
const results: TerminalFindResult[] = [];
runtime.setCallbacks({
callbacks: {
@@ -163,3 +163,43 @@ test("preserves inspection through multiple real parser turns of queued output",
expect(term.buffer.active.viewportY).toBe(inspected);
expect(term.getSelection()).toBe("needle");
});
test.each([true, false])("routes Find and shell input through xterm with isMac=%s", (isMac) => {
mount({ isMac });
const input: string[] = [];
let findRequests = 0;
runtime.setCallbacks({
callbacks: {
onInput: (data) => {
input.push(data);
},
onFindRequest: () => {
findRequests += 1;
},
},
});
const textarea = root.querySelector("textarea")!;
textarea.focus();
const ctrlF = new KeyboardEvent("keydown", {
key: "f",
code: "KeyF",
keyCode: 70,
ctrlKey: true,
bubbles: true,
cancelable: true,
});
textarea.dispatchEvent(ctrlF);
expect(findRequests).toBe(isMac ? 0 : 1);
expect(input).toEqual(isMac ? ["\x06"] : []);
const metaF = new KeyboardEvent("keydown", {
key: "f",
code: "KeyF",
keyCode: 70,
metaKey: true,
bubbles: true,
cancelable: true,
});
textarea.dispatchEvent(metaF);
expect(findRequests).toBe(1);
expect(metaF.defaultPrevented).toBe(isMac);
});
File diff suppressed because one or more lines are too long