fix: avoid duplicate terminal paste shortcuts (#400)
* fix: avoid duplicate terminal paste shortcuts * chore: format with prettier [skip ci] --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
7ac1e2caa2
commit
f15021c19d
|
|
@ -277,16 +277,54 @@ describe("XtermTerminal", () => {
|
|||
expect(window.ao!.clipboard.writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pastes from the Electron clipboard on Windows/Linux paste shortcuts", async () => {
|
||||
it.each(["Linux x86_64", "Win32"])(
|
||||
"pastes once from the Electron clipboard on Ctrl+Shift+V for %s",
|
||||
async (platform) => {
|
||||
setNavigatorPlatform(platform);
|
||||
const onInput = vi.fn();
|
||||
window.ao!.clipboard.readText = vi.fn().mockResolvedValue("hello\nworld");
|
||||
const { container } = 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);
|
||||
const pasteEvent = new Event("paste", { bubbles: true, cancelable: true }) as ClipboardEvent;
|
||||
Object.defineProperty(pasteEvent, "clipboardData", {
|
||||
value: { getData: vi.fn().mockReturnValue("native paste") },
|
||||
});
|
||||
container.firstElementChild!.dispatchEvent(pasteEvent);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(allowed).toBe(false);
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(event.stopPropagation).toHaveBeenCalled();
|
||||
expect(window.ao!.clipboard.readText).toHaveBeenCalledTimes(1);
|
||||
expect(pasteEvent.defaultPrevented).toBe(true);
|
||||
expect(onInput).toHaveBeenCalledTimes(1);
|
||||
expect(onInput).toHaveBeenCalledWith("hello\rworld", "paste");
|
||||
},
|
||||
);
|
||||
|
||||
it("supports plain Ctrl+V paste on Windows", async () => {
|
||||
setNavigatorPlatform("Win32");
|
||||
const onInput = vi.fn();
|
||||
window.ao!.clipboard.readText = vi.fn().mockResolvedValue("hello\nworld");
|
||||
window.ao!.clipboard.readText = vi.fn().mockResolvedValue("windows paste");
|
||||
render(<XtermTerminal theme="dark" onReady={(terminal) => terminal.onUserInput(onInput)} />);
|
||||
|
||||
const event = {
|
||||
key: "v",
|
||||
metaKey: false,
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
|
|
@ -298,7 +336,36 @@ describe("XtermTerminal", () => {
|
|||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(event.stopPropagation).toHaveBeenCalled();
|
||||
expect(window.ao!.clipboard.readText).toHaveBeenCalled();
|
||||
expect(onInput).toHaveBeenCalledWith("hello\rworld", "paste");
|
||||
expect(onInput).toHaveBeenCalledWith("windows paste", "paste");
|
||||
});
|
||||
|
||||
it("suppresses a queued native paste event after a handled paste shortcut", async () => {
|
||||
const onInput = vi.fn();
|
||||
window.ao!.clipboard.readText = vi.fn().mockResolvedValue("shortcut paste");
|
||||
const { container } = 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;
|
||||
expect(state.lastTerminal!.keyHandler!(event)).toBe(false);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
|
||||
const pasteEvent = new Event("paste", { bubbles: true, cancelable: true }) as ClipboardEvent;
|
||||
Object.defineProperty(pasteEvent, "clipboardData", {
|
||||
value: { getData: vi.fn().mockReturnValue("native paste") },
|
||||
});
|
||||
container.firstElementChild!.dispatchEvent(pasteEvent);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(pasteEvent.defaultPrevented).toBe(true);
|
||||
expect(onInput).toHaveBeenCalledTimes(1);
|
||||
expect(onInput).toHaveBeenCalledWith("shortcut paste", "paste");
|
||||
});
|
||||
|
||||
it("supports classic Windows terminal copy and paste shortcuts", async () => {
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ function loadRenderer(term: Terminal): void {
|
|||
// xterm palette tracks the app theme (see lib/terminal-themes.ts + --term-* in
|
||||
// styles.css). The PTY content is still the agent's own ANSI output.
|
||||
const terminalThemes = buildTerminalThemes();
|
||||
const SUPPRESS_NATIVE_PASTE_MS = 100;
|
||||
|
||||
// Erase scrollback (3J) + display (2J) and home the cursor — yyork's
|
||||
// terminalResetSequence. Deliberately NOT term.reset(): every pane PTY is a
|
||||
|
|
@ -102,7 +103,8 @@ 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;
|
||||
if (event.ctrlKey && event.shiftKey && !event.altKey) return true;
|
||||
return isWindowsPlatform() && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey;
|
||||
}
|
||||
|
||||
function consumeTerminalShortcut(event: KeyboardEvent): void {
|
||||
|
|
@ -298,6 +300,20 @@ export function XtermTerminal(props: XtermTerminalProps) {
|
|||
const bracketed = term.modes.bracketedPasteMode && term.options.ignoreBracketedPasteMode !== true;
|
||||
emitUserInput(bracketPastedText(prepared, bracketed), "paste");
|
||||
};
|
||||
let suppressNextNativePaste = false;
|
||||
let suppressPasteTimer: number | null = null;
|
||||
const clearSuppressNativePaste = () => {
|
||||
suppressNextNativePaste = false;
|
||||
if (suppressPasteTimer !== null) {
|
||||
window.clearTimeout(suppressPasteTimer);
|
||||
suppressPasteTimer = null;
|
||||
}
|
||||
};
|
||||
const suppressNativePasteOnce = () => {
|
||||
suppressNextNativePaste = true;
|
||||
if (suppressPasteTimer !== null) window.clearTimeout(suppressPasteTimer);
|
||||
suppressPasteTimer = window.setTimeout(clearSuppressNativePaste, SUPPRESS_NATIVE_PASTE_MS);
|
||||
};
|
||||
const pasteFromClipboard = () => {
|
||||
void aoBridge.clipboard
|
||||
.readText()
|
||||
|
|
@ -320,6 +336,7 @@ export function XtermTerminal(props: XtermTerminalProps) {
|
|||
}
|
||||
if (isTerminalPasteShortcut(event)) {
|
||||
consumeTerminalShortcut(event);
|
||||
suppressNativePasteOnce();
|
||||
pasteFromClipboard();
|
||||
return false;
|
||||
}
|
||||
|
|
@ -468,6 +485,10 @@ export function XtermTerminal(props: XtermTerminalProps) {
|
|||
const pasteInput = (event: ClipboardEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (suppressNextNativePaste) {
|
||||
clearSuppressNativePaste();
|
||||
return;
|
||||
}
|
||||
const text = event.clipboardData?.getData("text/plain") ?? "";
|
||||
pasteText(text);
|
||||
};
|
||||
|
|
@ -510,6 +531,7 @@ export function XtermTerminal(props: XtermTerminalProps) {
|
|||
selectionChange.dispose();
|
||||
host.removeEventListener("paste", pasteInput, true);
|
||||
host.removeEventListener("compositionend", compositionInput, true);
|
||||
clearSuppressNativePaste();
|
||||
keyInput.dispose();
|
||||
userInputListeners.clear();
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in New Issue