diff --git a/packages/app/src/terminal/find/index.tsx b/packages/app/src/terminal/find/index.tsx index de17482f0e..6184317ad7 100644 --- a/packages/app/src/terminal/find/index.tsx +++ b/packages/app/src/terminal/find/index.tsx @@ -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(); } diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index c64cc57047..0108815add 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -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[] = []; diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index f6f38095be..094c059231 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -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, diff --git a/packages/app/src/terminal/runtime/terminal-find-shortcut.ts b/packages/app/src/terminal/runtime/terminal-find-shortcut.ts new file mode 100644 index 0000000000..64753b6b2c --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-find-shortcut.ts @@ -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, + 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"; +} diff --git a/packages/app/src/terminal/runtime/terminal-find.browser.test.ts b/packages/app/src/terminal/runtime/terminal-find.browser.test.ts index f14328797e..8336925338 100644 --- a/packages/app/src/terminal/runtime/terminal-find.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-find.browser.test.ts @@ -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); +}); diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts index 8cca592a37..804131db2f 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts @@ -2,4 +2,4 @@ // Do not edit by hand. export const terminalEmulatorWebViewHtml = - '\n\n \n \n \n \n \n \n \n'; + '\n\n \n \n \n \n \n \n \n';