fix: normalize terminal keyboard shortcuts (#381)
* fix(frontend): normalize terminal shortcuts * fix(frontend): preserve ctrl-c interrupt outside windows
This commit is contained in:
parent
8fa403c480
commit
4c8f92ad39
|
|
@ -114,10 +114,23 @@ vi.mock("@xterm/addon-webgl", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
function setNavigatorPlatform(platform: string) {
|
||||
Object.defineProperty(window.navigator, "platform", {
|
||||
configurable: true,
|
||||
value: platform,
|
||||
});
|
||||
Object.defineProperty(window.navigator, "userAgentData", {
|
||||
configurable: true,
|
||||
value: { platform },
|
||||
});
|
||||
}
|
||||
|
||||
describe("XtermTerminal", () => {
|
||||
beforeEach(() => {
|
||||
state.lastTerminal = null;
|
||||
setNavigatorPlatform("Linux x86_64");
|
||||
window.ao!.clipboard.writeText = vi.fn().mockResolvedValue(undefined);
|
||||
window.ao!.clipboard.readText = vi.fn().mockResolvedValue("");
|
||||
});
|
||||
|
||||
it("copies selected terminal text on the terminal copy shortcut", () => {
|
||||
|
|
@ -130,6 +143,7 @@ describe("XtermTerminal", () => {
|
|||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
} as unknown as KeyboardEvent;
|
||||
const allowed = state.lastTerminal!.keyHandler!(event);
|
||||
|
||||
|
|
@ -186,6 +200,7 @@ describe("XtermTerminal", () => {
|
|||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
} as unknown as KeyboardEvent;
|
||||
const allowed = state.lastTerminal!.keyHandler!(event);
|
||||
|
||||
|
|
@ -194,6 +209,159 @@ describe("XtermTerminal", () => {
|
|||
expect(writeText).toHaveBeenLastCalledWith("retry me");
|
||||
});
|
||||
|
||||
it("leaves plain Ctrl+C as terminal input on non-Windows even when text is selected", () => {
|
||||
render(<XtermTerminal theme="dark" />);
|
||||
state.lastTerminal!.selection = "selected text";
|
||||
|
||||
const event = {
|
||||
key: "c",
|
||||
metaKey: false,
|
||||
ctrlKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
} as unknown as KeyboardEvent;
|
||||
const allowed = state.lastTerminal!.keyHandler!(event);
|
||||
|
||||
expect(allowed).toBe(true);
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(event.stopPropagation).not.toHaveBeenCalled();
|
||||
expect(window.ao!.clipboard.writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copies selected text with plain Ctrl+C on Windows", () => {
|
||||
setNavigatorPlatform("Win32");
|
||||
render(<XtermTerminal theme="dark" />);
|
||||
state.lastTerminal!.selection = "windows copy";
|
||||
|
||||
const event = {
|
||||
key: "c",
|
||||
metaKey: false,
|
||||
ctrlKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
} as unknown as KeyboardEvent;
|
||||
const allowed = state.lastTerminal!.keyHandler!(event);
|
||||
|
||||
expect(allowed).toBe(false);
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(event.stopPropagation).toHaveBeenCalled();
|
||||
expect(window.ao!.clipboard.writeText).toHaveBeenCalledWith("windows copy");
|
||||
});
|
||||
|
||||
it("leaves plain Ctrl+C as terminal input on Windows when nothing is selected", () => {
|
||||
setNavigatorPlatform("Win32");
|
||||
render(<XtermTerminal theme="dark" />);
|
||||
state.lastTerminal!.selection = "";
|
||||
|
||||
const event = {
|
||||
key: "c",
|
||||
metaKey: false,
|
||||
ctrlKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
} as unknown as KeyboardEvent;
|
||||
const allowed = state.lastTerminal!.keyHandler!(event);
|
||||
|
||||
expect(allowed).toBe(true);
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(event.stopPropagation).not.toHaveBeenCalled();
|
||||
expect(window.ao!.clipboard.writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pastes from the Electron clipboard on Windows/Linux paste shortcuts", async () => {
|
||||
const onInput = vi.fn();
|
||||
window.ao!.clipboard.readText = vi.fn().mockResolvedValue("hello\nworld");
|
||||
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
|
||||
|
||||
const event = {
|
||||
key: "v",
|
||||
metaKey: false,
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
altKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
} as unknown as KeyboardEvent;
|
||||
const allowed = state.lastTerminal!.keyHandler!(event);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(allowed).toBe(false);
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(event.stopPropagation).toHaveBeenCalled();
|
||||
expect(window.ao!.clipboard.readText).toHaveBeenCalled();
|
||||
expect(onInput).toHaveBeenCalledWith("hello\rworld", "paste");
|
||||
});
|
||||
|
||||
it("supports classic Windows terminal copy and paste shortcuts", async () => {
|
||||
const onInput = vi.fn();
|
||||
window.ao!.clipboard.readText = vi.fn().mockResolvedValue("insert paste");
|
||||
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
|
||||
state.lastTerminal!.selection = "insert copy";
|
||||
|
||||
const copyEvent = {
|
||||
key: "Insert",
|
||||
metaKey: false,
|
||||
ctrlKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
} as unknown as KeyboardEvent;
|
||||
expect(state.lastTerminal!.keyHandler!(copyEvent)).toBe(false);
|
||||
expect(window.ao!.clipboard.writeText).toHaveBeenCalledWith("insert copy");
|
||||
|
||||
const pasteEvent = {
|
||||
key: "Insert",
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: true,
|
||||
altKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
} as unknown as KeyboardEvent;
|
||||
expect(state.lastTerminal!.keyHandler!(pasteEvent)).toBe(false);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(window.ao!.clipboard.readText).toHaveBeenCalled();
|
||||
expect(onInput).toHaveBeenCalledWith("insert paste", "paste");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Option/Alt+Left", { key: "ArrowLeft", altKey: true }, "\x1bb"],
|
||||
["Option/Alt+Right", { key: "ArrowRight", altKey: true }, "\x1bf"],
|
||||
["Option/Alt+Backspace", { key: "Backspace", altKey: true }, "\x1b\x7f"],
|
||||
["Option/Alt+Delete", { key: "Delete", altKey: true }, "\x1bd"],
|
||||
["Ctrl+Left", { key: "ArrowLeft", ctrlKey: true }, "\x1b[1;5D"],
|
||||
["Ctrl+Right", { key: "ArrowRight", ctrlKey: true }, "\x1b[1;5C"],
|
||||
["Ctrl+Backspace", { key: "Backspace", ctrlKey: true }, "\x1b\x7f"],
|
||||
["Ctrl+Delete", { key: "Delete", ctrlKey: true }, "\x1bd"],
|
||||
])("normalizes %s into terminal input", (_name, init, expected) => {
|
||||
const onInput = vi.fn();
|
||||
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
|
||||
|
||||
const event = {
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
...init,
|
||||
} as unknown as KeyboardEvent;
|
||||
const allowed = state.lastTerminal!.keyHandler!(event);
|
||||
|
||||
expect(allowed).toBe(false);
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(event.stopPropagation).toHaveBeenCalled();
|
||||
expect(onInput).toHaveBeenCalledWith(expected, "terminal");
|
||||
});
|
||||
|
||||
it("forwards generated xterm input data such as wheel scroll reports", () => {
|
||||
const onInput = vi.fn();
|
||||
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
|
||||
|
|
|
|||
|
|
@ -85,11 +85,67 @@ function bracketPastedText(text: string, bracketedPasteMode: boolean): string {
|
|||
}
|
||||
|
||||
function isTerminalCopyShortcut(event: KeyboardEvent): boolean {
|
||||
if (event.key === "Insert") return event.ctrlKey && !event.altKey && !event.metaKey;
|
||||
if (event.key.toLowerCase() !== "c") return false;
|
||||
if (event.metaKey) return true;
|
||||
if (event.ctrlKey && event.shiftKey && !event.altKey) return true;
|
||||
return isWindowsPlatform() && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey;
|
||||
}
|
||||
|
||||
function isWindowsPlatform(): boolean {
|
||||
const platform =
|
||||
(navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform ?? navigator.platform;
|
||||
return platform.toLowerCase().startsWith("win");
|
||||
}
|
||||
|
||||
function isTerminalPasteShortcut(event: KeyboardEvent): boolean {
|
||||
if (event.key === "Insert") return event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey;
|
||||
if (event.key.toLowerCase() !== "v") return false;
|
||||
if (event.metaKey) return true;
|
||||
return event.ctrlKey && event.shiftKey;
|
||||
}
|
||||
|
||||
function consumeTerminalShortcut(event: KeyboardEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function normalizedTerminalShortcut(event: KeyboardEvent): string | null {
|
||||
if (event.metaKey || event.shiftKey) return null;
|
||||
|
||||
if (event.altKey && !event.ctrlKey) {
|
||||
switch (event.key) {
|
||||
case "ArrowLeft":
|
||||
return "\x1bb";
|
||||
case "ArrowRight":
|
||||
return "\x1bf";
|
||||
case "Backspace":
|
||||
return "\x1b\x7f";
|
||||
case "Delete":
|
||||
return "\x1bd";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.ctrlKey && !event.altKey) {
|
||||
switch (event.key) {
|
||||
case "ArrowLeft":
|
||||
return "\x1b[1;5D";
|
||||
case "ArrowRight":
|
||||
return "\x1b[1;5C";
|
||||
case "Backspace":
|
||||
return "\x1b\x7f";
|
||||
case "Delete":
|
||||
return "\x1bd";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function terminalHasFocus(host: HTMLElement): boolean {
|
||||
const activeElement = document.activeElement;
|
||||
return !!activeElement && host.contains(activeElement);
|
||||
|
|
@ -232,9 +288,45 @@ export function XtermTerminal(props: XtermTerminalProps) {
|
|||
const clearCopiedSelection = () => {
|
||||
lastCopiedSelection = "";
|
||||
};
|
||||
const userInputListeners = new Set<(data: string, source: TerminalUserInputSource) => void>();
|
||||
const emitUserInput = (data: string, source: TerminalUserInputSource) => {
|
||||
if (data.length === 0) return;
|
||||
userInputListeners.forEach((listener) => listener(data, source));
|
||||
};
|
||||
const pasteText = (text: string) => {
|
||||
const prepared = preparePastedText(text);
|
||||
const bracketed = term.modes.bracketedPasteMode && term.options.ignoreBracketedPasteMode !== true;
|
||||
emitUserInput(bracketPastedText(prepared, bracketed), "paste");
|
||||
};
|
||||
const pasteFromClipboard = () => {
|
||||
void aoBridge.clipboard
|
||||
.readText()
|
||||
.then(pasteText)
|
||||
.catch((error) => {
|
||||
console.warn("Unable to paste terminal clipboard text", error);
|
||||
});
|
||||
};
|
||||
term.attachCustomKeyEventHandler((event) => {
|
||||
if (!isTerminalCopyShortcut(event) || !copySelection()) return true;
|
||||
event.preventDefault();
|
||||
if (isTerminalCopyShortcut(event)) {
|
||||
if (copySelection()) {
|
||||
consumeTerminalShortcut(event);
|
||||
return false;
|
||||
}
|
||||
if ((event.ctrlKey && event.shiftKey) || (event.key === "Insert" && event.ctrlKey)) {
|
||||
consumeTerminalShortcut(event);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (isTerminalPasteShortcut(event)) {
|
||||
consumeTerminalShortcut(event);
|
||||
pasteFromClipboard();
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizedTerminalShortcut(event);
|
||||
if (!normalized) return true;
|
||||
consumeTerminalShortcut(event);
|
||||
emitUserInput(normalized, "terminal");
|
||||
return false;
|
||||
});
|
||||
const copyInput = (event: ClipboardEvent) => {
|
||||
|
|
@ -338,11 +430,6 @@ export function XtermTerminal(props: XtermTerminalProps) {
|
|||
// misses them. Listen on window directly as a session-long recovery path.
|
||||
window.addEventListener("resize", fitTerminal);
|
||||
|
||||
const userInputListeners = new Set<(data: string, source: TerminalUserInputSource) => void>();
|
||||
const emitUserInput = (data: string, source: TerminalUserInputSource) => {
|
||||
if (data.length === 0) return;
|
||||
userInputListeners.forEach((listener) => listener(data, source));
|
||||
};
|
||||
const terminalInput = term.onData((data) => emitUserInput(data, "terminal"));
|
||||
|
||||
// Translate wheel motion into SGR wheel reports for zellij (see
|
||||
|
|
@ -377,9 +464,7 @@ export function XtermTerminal(props: XtermTerminalProps) {
|
|||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const text = event.clipboardData?.getData("text/plain") ?? "";
|
||||
const prepared = preparePastedText(text);
|
||||
const bracketed = term.modes.bracketedPasteMode && term.options.ignoreBracketedPasteMode !== true;
|
||||
emitUserInput(bracketPastedText(prepared, bracketed), "paste");
|
||||
pasteText(text);
|
||||
};
|
||||
const compositionInput = (event: CompositionEvent) => {
|
||||
emitUserInput(event.data, "composition");
|
||||
|
|
|
|||
Loading…
Reference in New Issue