Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions desktop/src/features/terminal/TerminalSubstrate.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,105 @@ test("control chords keep reaching the PTY instead of the tab layer", async () =
);
});

test("Windows paste chords reach paste instead of sending ^V", async (t) => {
Object.defineProperty(dom.window.navigator, "platform", {
configurable: true,
value: "Win32",
});
t.after(() => {
Object.defineProperty(dom.window.navigator, "platform", {
configurable: true,
value: "MacIntel",
});
});

const subject = await revealed({ bracketedPaste: true });
const input = subject.view.getByLabelText("Terminal input");
const keyDown = (shiftKey) => {
const event = new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
code: "KeyV",
ctrlKey: true,
key: "v",
shiftKey,
});
act(() => input.dispatchEvent(event));
return event;
};

const plain = keyDown(false);
assert.equal(plain.defaultPrevented, false);
assert.deepEqual(subject.calls.input, [], "Ctrl+V must not send ^V");
fireEvent.paste(input, {
clipboardData: { getData: () => "first" },
});

const shifted = keyDown(true);
assert.equal(shifted.defaultPrevented, false);
assert.deepEqual(
subject.calls.input,
["\u001b[200~first\u001b[201~"],
"Ctrl+Shift+V must not send ^V",
);
fireEvent.paste(input, {
clipboardData: { getData: () => "second" },
});

assert.deepEqual(subject.calls.input, [
"\u001b[200~first\u001b[201~",
"\u001b[200~second\u001b[201~",
]);
});

test("Linux keeps Ctrl+V quote-next and lets Ctrl+Shift+V paste", async (t) => {
Object.defineProperty(dom.window.navigator, "platform", {
configurable: true,
value: "Linux x86_64",
});
t.after(() => {
Object.defineProperty(dom.window.navigator, "platform", {
configurable: true,
value: "MacIntel",
});
});

const subject = await revealed({ bracketedPaste: true });
const input = subject.view.getByLabelText("Terminal input");
const keyDown = (shiftKey) => {
const event = new KeyboardEvent("keydown", {
bubbles: true,
cancelable: true,
code: "KeyV",
ctrlKey: true,
key: "v",
shiftKey,
});
act(() => input.dispatchEvent(event));
return event;
};

const quoteNext = keyDown(false);
assert.equal(quoteNext.defaultPrevented, true);
assert.deepEqual(subject.calls.input, ["\u0016"]);

const pasteChord = keyDown(true);
assert.equal(pasteChord.defaultPrevented, false);
assert.deepEqual(
subject.calls.input,
["\u0016"],
"Ctrl+Shift+V must wait for the paste event",
);
fireEvent.paste(input, {
clipboardData: { getData: () => "from Linux" },
});

assert.deepEqual(subject.calls.input, [
"\u0016",
"\u001b[200~from Linux\u001b[201~",
]);
});

test("⌘W on an already-closing tab does not re-fire close", async () => {
const subject = await revealed({
sessions: [
Expand Down
9 changes: 8 additions & 1 deletion desktop/src/features/terminal/TerminalSubstrate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { ChevronRight, Maximize2, Minimize2, Plus, X } from "lucide-react";

import { useTheme } from "@/shared/theme/ThemeProvider";
import { cn } from "@/shared/lib/cn";
import { isMacPlatform } from "@/shared/lib/platform";
import { isLinuxPlatform, isMacPlatform } from "@/shared/lib/platform";
import {
INITIAL_HANDOFF_STATE,
accumulateScrollLines,
encodePaste,
encodeTerminalKey,
isTerminalPasteChord,
matchTabChord,
reduceHandoff,
stepSession,
Expand Down Expand Up @@ -785,6 +786,12 @@ export function TerminalSubstrate({
return;
}
if (isToggleChord(event.nativeEvent)) return;
const pastePlatform = isMacPlatform()
? "mac"
: isLinuxPlatform()
? "linux"
: "windows";
if (isTerminalPasteChord(event.nativeEvent, pastePlatform)) return;
const encoded = encodeTerminalKey(event);
if (encoded) {
event.preventDefault();
Expand Down
32 changes: 32 additions & 0 deletions desktop/src/features/terminal/terminalState.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
accumulateScrollLines,
encodePaste,
encodeTerminalKey,
isTerminalPasteChord,
matchTabChord,
reduceHandoff,
stepSession,
Expand Down Expand Up @@ -86,6 +87,37 @@ test("bracketed paste wraps the entire payload exactly once", () => {
assert.equal(encodePaste("a\nb", true), "\u001b[200~a\nb\u001b[201~");
});

test("paste chords preserve Windows and Linux terminal conventions", () => {
const chord = (overrides = {}) => ({
altKey: false,
code: "KeyV",
ctrlKey: true,
key: "v",
metaKey: false,
shiftKey: false,
...overrides,
});

assert.equal(isTerminalPasteChord(chord(), "windows"), true);
assert.equal(
isTerminalPasteChord(chord({ shiftKey: true }), "windows"),
true,
);
assert.equal(isTerminalPasteChord(chord(), "linux"), false);
assert.equal(isTerminalPasteChord(chord({ shiftKey: true }), "linux"), true);
assert.equal(
isTerminalPasteChord(chord({ ctrlKey: false, metaKey: true }), "mac"),
true,
);
assert.equal(isTerminalPasteChord(chord({ altKey: true }), "windows"), false);
assert.equal(
isTerminalPasteChord(chord({ code: "Period", key: "v" }), "windows"),
true,
"matching follows the typed key across keyboard layouts",
);
assert.equal(isTerminalPasteChord(chord({ key: "c" }), "windows"), false);
});

test("pixel scrolling retains fractional lines in both directions", () => {
const state = { remainderPx: 0 };
let result = accumulateScrollLines(state, 3, 10);
Expand Down
27 changes: 27 additions & 0 deletions desktop/src/features/terminal/terminalState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,33 @@ export function encodeTerminalKey(event: {
return null;
}

export type TerminalPastePlatform = "linux" | "mac" | "windows";

/**
* Keep the platform's paste chords out of `encodeTerminalKey` so the focused
* textarea can emit its synchronous `paste` event. In particular, Windows
* treats both Ctrl+V and Ctrl+Shift+V as paste; encoding them first would turn
* them into the control byte ^V and `preventDefault()` the clipboard event.
* Linux retains the shell's traditional Ctrl+V quote-next byte and reserves
* Ctrl+Shift+V for paste.
*/
export function isTerminalPasteChord(
event: {
altKey: boolean;
code: string;
ctrlKey: boolean;
key: string;
metaKey: boolean;
shiftKey: boolean;
},
platform: TerminalPastePlatform,
): boolean {
if (event.altKey || event.key.toLowerCase() !== "v") return false;
if (platform === "mac") return event.metaKey && !event.ctrlKey;
if (!event.ctrlKey || event.metaKey) return false;
return platform === "windows" || event.shiftKey;
}

export type TabChord = "close" | "new" | "next" | "previous";

/**
Expand Down