fix(terminal): preserve Ctrl+C interrupt with custom copy (#632)
Co-authored-by: lly <lilongyu2022@163.com>
This commit is contained in:
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",
|
||||
|
||||
Reference in new issue
Block a user