mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
fix(terminal): only Cmd+F opens Find on macOS; Ctrl+F reaches the shell
This commit is contained in:
@@ -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
Reference in New Issue
Block a user