fix(terminal): preserve Ctrl+C interrupt with custom copy (#632)

Co-authored-by: lly <lilongyu2022@163.com>
This commit is contained in:
llyVictoryandlly authored and GitHub committed 2026-09-10 18:32:55 +08:00
1 parent 47c99afd15
commit e41ee1dda3
2 files changed
+141 -37

No files matched your search

@@ -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<string, string> = {},
) {
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");
});
});
@@ -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",