From e41ee1dda3da94a8d260d636c911a0b16cf6dd4e Mon Sep 17 00:00:00 2001 From: llyVictory Date: Thu, 10 Sep 2026 18:32:55 +0800 Subject: [PATCH] fix(terminal): preserve Ctrl+C interrupt with custom copy (#632) Co-authored-by: lly --- .../xterminalKeyboardController.test.ts | 98 ++++++++++++++++++- .../terminal/xterminalKeyboardController.ts | 80 ++++++++------- 2 files changed, 141 insertions(+), 37 deletions(-) diff --git a/src/components/terminal/xterminalKeyboardController.test.ts b/src/components/terminal/xterminalKeyboardController.test.ts index 6117a483..01b390bb 100644 --- a/src/components/terminal/xterminalKeyboardController.test.ts +++ b/src/components/terminal/xterminalKeyboardController.test.ts @@ -1,11 +1,16 @@ import type { Terminal } from "@xterm/xterm"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { TerminalAppSettings } from "@/context/AppContext"; +import { writeClipboardText } from "@/lib/clipboard"; import { createTerminalInputState } from "@/lib/terminalInputTracker"; import type { SessionType } from "@/types/global"; import type { XTerminalImeKeyboardRoute } from "./xterminalIme"; import { installXTerminalKeyboardController } from "./xterminalKeyboardController"; +vi.mock("@/lib/clipboard", () => ({ + writeClipboardText: vi.fn(() => Promise.resolve()), +})); + function backspaceEvent(keyCode: number, isComposing = false): KeyboardEvent { const event = new KeyboardEvent("keydown", { key: "Backspace", @@ -20,7 +25,11 @@ function backspaceEvent(keyCode: number, isComposing = false): KeyboardEvent { return event; } -function createHarness(imeRoute: XTerminalImeKeyboardRoute, sessionType: SessionType = "Local") { +function createHarness( + imeRoute: XTerminalImeKeyboardRoute, + sessionType: SessionType = "Local", + keybindings: Record = {}, +) { const keyHandlerRef: { current: ((event: KeyboardEvent) => boolean) | null; } = { current: null }; @@ -32,6 +41,7 @@ function createHarness(imeRoute: XTerminalImeKeyboardRoute, sessionType: Session hasSelection: vi.fn(() => false), } as unknown as Terminal; const routeKeyboardEvent = vi.fn(() => imeRoute); + const pasteClipboard = vi.fn(async () => {}); const sendRawInput = vi.fn(async () => {}); const syncSuggestionsWithInputState = vi.fn(); const inputStateRef = { @@ -46,7 +56,7 @@ function createHarness(imeRoute: XTerminalImeKeyboardRoute, sessionType: Session terminal, imeTracker: { routeKeyboardEvent }, terminalAppSettingsRef: { - current: { keybindings: {} } as TerminalAppSettings, + current: { keybindings } as TerminalAppSettings, }, sessionTypeRef: { current: sessionType }, inputStateRef, @@ -55,7 +65,7 @@ function createHarness(imeRoute: XTerminalImeKeyboardRoute, sessionType: Session showSuggestionsRef: { current: false }, suggestionsRef: { current: [] }, doFindRef: { current: vi.fn() }, - pasteClipboard: vi.fn(async () => {}), + pasteClipboard, pasteText: vi.fn(), sendRawInput, triggerSearch: vi.fn(), @@ -82,12 +92,18 @@ function createHarness(imeRoute: XTerminalImeKeyboardRoute, sessionType: Session return { inputStateRef, keyHandler, + pasteClipboard, routeKeyboardEvent, sendRawInput, syncSuggestionsWithInputState, + terminal, }; } +beforeEach(() => { + vi.clearAllMocks(); +}); + describe("installXTerminalKeyboardController IME Backspace routing", () => { it("leaves IME Backspace native without preventing default", () => { const harness = createHarness("native-ime"); @@ -131,4 +147,78 @@ describe("installXTerminalKeyboardController IME Backspace routing", () => { expect(harness.routeKeyboardEvent).not.toHaveBeenCalled(); expect(event.defaultPrevented).toBe(false); }); + + it("honors a custom copy shortcut while terminal text is selected", () => { + const harness = createHarness("application", "SSH", { + "terminal.copy": "ctrl+c", + }); + vi.mocked(harness.terminal.hasSelection).mockReturnValue(true); + vi.mocked(harness.terminal.getSelection).mockReturnValue("selected output"); + const event = new KeyboardEvent("keydown", { + key: "c", + code: "KeyC", + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + + expect(harness.keyHandler(event)).toBe(false); + expect(event.defaultPrevented).toBe(true); + expect(writeClipboardText).toHaveBeenCalledWith("selected output"); + expect(harness.sendRawInput).not.toHaveBeenCalled(); + }); + + it("passes a custom Ctrl+C through to the shell when there is no selection", () => { + const harness = createHarness("application", "SSH", { + "terminal.copy": "ctrl+c", + }); + const event = new KeyboardEvent("keydown", { + key: "c", + code: "KeyC", + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + + expect(harness.keyHandler(event)).toBe(true); + expect(event.defaultPrevented).toBe(false); + expect(writeClipboardText).not.toHaveBeenCalled(); + }); + + it("honors a custom paste shortcut while terminal text is selected", () => { + const harness = createHarness("application", "SSH", { + "terminal.paste": "ctrl+v", + }); + vi.mocked(harness.terminal.hasSelection).mockReturnValue(true); + const event = new KeyboardEvent("keydown", { + key: "v", + code: "KeyV", + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + + expect(harness.keyHandler(event)).toBe(false); + expect(event.defaultPrevented).toBe(true); + expect(harness.pasteClipboard).toHaveBeenCalledOnce(); + expect(harness.sendRawInput).not.toHaveBeenCalled(); + }); + + it("keeps the default Ctrl+Shift+C copy shortcut available", () => { + const harness = createHarness("application", "SSH"); + vi.mocked(harness.terminal.hasSelection).mockReturnValue(true); + vi.mocked(harness.terminal.getSelection).mockReturnValue("selected output"); + const event = new KeyboardEvent("keydown", { + key: "C", + code: "KeyC", + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }); + + expect(harness.keyHandler(event)).toBe(false); + expect(event.defaultPrevented).toBe(true); + expect(writeClipboardText).toHaveBeenCalledWith("selected output"); + }); }); diff --git a/src/components/terminal/xterminalKeyboardController.ts b/src/components/terminal/xterminalKeyboardController.ts index b882b0eb..f50e4f72 100644 --- a/src/components/terminal/xterminalKeyboardController.ts +++ b/src/components/terminal/xterminalKeyboardController.ts @@ -153,6 +153,53 @@ export function installXTerminalKeyboardController({ return false; } + // 终端操作快捷键必须先于选中文本时的 Shell 输入处理,否则 Ctrl 组合键 + // 会被当作控制字符发送到终端,导致自定义终端操作失效。 + if (matchesKeyEvent(resolveShortcutKeys("terminal.copy", kb), e)) { + const sel = terminal.getSelection(); + // Ctrl+C 没有选区时保留 Shell 的中断语义(例如终止 python main.py)。 + if ( + !sel && + e.ctrlKey && + !e.metaKey && + !e.altKey && + !e.shiftKey && + e.code === "KeyC" + ) { + return true; + } + + e.preventDefault(); + if (sel) writeClipboardText(sel).catch(() => {}); + return false; + } + if (matchesKeyEvent(resolveShortcutKeys("terminal.paste", kb), e)) { + e.preventDefault(); + pasteClipboard().catch(() => {}); + return false; + } + if (matchesKeyEvent(resolveShortcutKeys("terminal.find", kb), e)) { + e.preventDefault(); + doFindRef.current(); + return false; + } + if (matchesKeyEvent(resolveShortcutKeys("terminal.clear", kb), e)) { + e.preventDefault(); + sendTerminalClearInput(terminal); + return false; + } + if (matchesKeyEvent(resolveShortcutKeys("terminal.pasteSelected", kb), e)) { + e.preventDefault(); + const sel = terminal.getSelection() || lastSelectionRef.current; + pasteText(sel); + return false; + } + if (matchesKeyEvent(resolveShortcutKeys("terminal.selectAll", kb), e)) { + e.preventDefault(); + terminal.selectAll(); + return false; + } + if ( e.key === "Tab" && !e.ctrlKey && @@ -454,39 +501,6 @@ export function installXTerminalKeyboardController({ } } - if (matchesKeyEvent(resolveShortcutKeys("terminal.copy", kb), e)) { - e.preventDefault(); - const sel = terminal.getSelection(); - if (sel) writeClipboardText(sel).catch(() => {}); - return false; - } - if (matchesKeyEvent(resolveShortcutKeys("terminal.paste", kb), e)) { - e.preventDefault(); - pasteClipboard().catch(() => {}); - return false; - } - if (matchesKeyEvent(resolveShortcutKeys("terminal.find", kb), e)) { - e.preventDefault(); - doFindRef.current(); - return false; - } - if (matchesKeyEvent(resolveShortcutKeys("terminal.clear", kb), e)) { - e.preventDefault(); - sendTerminalClearInput(terminal); - return false; - } - if (matchesKeyEvent(resolveShortcutKeys("terminal.pasteSelected", kb), e)) { - e.preventDefault(); - const sel = terminal.getSelection() || lastSelectionRef.current; - pasteText(sel); - return false; - } - if (matchesKeyEvent(resolveShortcutKeys("terminal.selectAll", kb), e)) { - e.preventDefault(); - terminal.selectAll(); - return false; - } - const swallowIds = [ "tab.newSession", "tab.close",