mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
refactor(terminal): share platform policy across keyboard shortcuts
Use the existing platform input for editing, Find, and clipboard behavior. Exercise Mac and Linux modifier chords through the app and a real PTY, and keep pending modifier chips authoritative.
This commit is contained in:
@@ -199,6 +199,49 @@ async function quitVim(page: Page) {
|
||||
});
|
||||
}
|
||||
|
||||
interface TerminalChord {
|
||||
key: string;
|
||||
bytes: string;
|
||||
}
|
||||
|
||||
async function openKeyboardCapture(page: Page) {
|
||||
await test.step("Open a real terminal process that reports received bytes", async () => {
|
||||
const terminal = await harness.createTerminal({
|
||||
name: "Keyboard bytes",
|
||||
command: process.execPath,
|
||||
args: [
|
||||
"-e",
|
||||
`
|
||||
process.stdin.setRawMode(true);
|
||||
let received = Buffer.alloc(0);
|
||||
process.stdin.on('data', chunk => {
|
||||
received = Buffer.concat([received, chunk]);
|
||||
process.stdout.write('\\r\\nBYTES:' + received.toString('hex') + ':END\\r\\n');
|
||||
});
|
||||
console.log('KEYBOARD_READY');
|
||||
`,
|
||||
],
|
||||
});
|
||||
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain("KEYBOARD_READY");
|
||||
});
|
||||
}
|
||||
|
||||
async function expectTerminalChords(page: Page, chords: TerminalChord[]) {
|
||||
let received = "";
|
||||
for (const chord of chords) {
|
||||
await test.step(`${chord.key} sends ${JSON.stringify(chord.bytes)}`, async () => {
|
||||
await pressTerminalShortcut(page, chord.key);
|
||||
// A printable sentinel proves delivery even for chords expected to send nothing.
|
||||
await input(page).pressSequentially("x");
|
||||
received += Buffer.from(chord.bytes + "x").toString("hex");
|
||||
await expect.poll(() => getTerminalBufferText(page)).toContain(`BYTES:${received}:END`);
|
||||
});
|
||||
}
|
||||
await recordTerminalEvidence("keyboard-bytes", await getTerminalBufferText(page));
|
||||
await page.screenshot({ path: test.info().outputPath("keyboard-chords.png") });
|
||||
}
|
||||
|
||||
let harness: TerminalE2EHarness;
|
||||
test.beforeEach(async () => {
|
||||
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-find-" });
|
||||
@@ -213,6 +256,28 @@ test.describe("macOS terminal shortcuts", () => {
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
});
|
||||
|
||||
test("encodes editing chords without confusing Control and Command", async ({ page }) => {
|
||||
await openKeyboardCapture(page);
|
||||
await expectTerminalChords(page, [
|
||||
{ key: "Meta+ArrowLeft", bytes: "\x01" },
|
||||
{ key: "Meta+ArrowRight", bytes: "\x05" },
|
||||
{ key: "Alt+ArrowLeft", bytes: "\x1bb" },
|
||||
{ key: "Alt+ArrowRight", bytes: "\x1bf" },
|
||||
{ key: "Meta+Backspace", bytes: "\x15" },
|
||||
{ key: "Control+c", bytes: "\x03" },
|
||||
{ key: "Control+a", bytes: "\x01" },
|
||||
{ key: "Control+e", bytes: "\x05" },
|
||||
{ key: "Control+v", bytes: "\x16" },
|
||||
{ key: "Control+f", bytes: "\x06" },
|
||||
{ key: "ArrowLeft", bytes: "\x1b[D" },
|
||||
{ key: "ArrowRight", bytes: "\x1b[C" },
|
||||
{ key: "Alt+Backspace", bytes: "\x1b\x7f" },
|
||||
{ key: "Control+Alt+ArrowLeft", bytes: "\x1b[1;7D" },
|
||||
]);
|
||||
await pressTerminalShortcut(page, "Meta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
});
|
||||
|
||||
test("sends Control+f to cat and opens Find with Meta+f", async ({ page }) => {
|
||||
await openControlCharacterTerminal(page);
|
||||
await pressTerminalShortcut(page, "Control+f");
|
||||
@@ -232,6 +297,26 @@ test.describe("macOS terminal shortcuts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("Linux terminal chords keep Control, Alt and Meta distinct", async ({ page }) => {
|
||||
await openKeyboardCapture(page);
|
||||
await expectTerminalChords(page, [
|
||||
{ key: "Meta+ArrowLeft", bytes: "" },
|
||||
{ key: "Meta+ArrowRight", bytes: "" },
|
||||
{ key: "Alt+ArrowLeft", bytes: "\x1b[1;3D" },
|
||||
{ key: "Alt+ArrowRight", bytes: "\x1b[1;3C" },
|
||||
{ key: "Meta+Backspace", bytes: "\x7f" },
|
||||
{ key: "Control+c", bytes: "\x03" },
|
||||
{ key: "Control+a", bytes: "\x01" },
|
||||
{ key: "Control+e", bytes: "\x05" },
|
||||
{ key: "Control+ArrowLeft", bytes: "\x1b[1;5D" },
|
||||
{ key: "Control+ArrowRight", bytes: "\x1b[1;5C" },
|
||||
{ key: "Control+Backspace", bytes: "\x08" },
|
||||
{ key: "Alt+Backspace", bytes: "\x1b\x7f" },
|
||||
]);
|
||||
await pressTerminalShortcut(page, "Control+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
});
|
||||
|
||||
test("opens Find with Control+f on Linux without sending ^F to cat", async ({ page }) => {
|
||||
await openControlCharacterTerminal(page);
|
||||
await pressTerminalShortcut(page, "Control+f");
|
||||
|
||||
@@ -77,7 +77,7 @@ interface CreateTerminalHostInput {
|
||||
width: number;
|
||||
height: number;
|
||||
scrollback?: number;
|
||||
isMacLikePlatform?: boolean;
|
||||
isMac?: boolean;
|
||||
}
|
||||
|
||||
function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
|
||||
@@ -100,7 +100,9 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
|
||||
const terminalKeys: TerminalKeyRecord[] = [];
|
||||
const inputModeChanges: TerminalInputModeState[] = [];
|
||||
const openedUrls: string[] = [];
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
const runtime = new TerminalEmulatorRuntime(
|
||||
input.isMac === undefined ? undefined : { isMac: input.isMac },
|
||||
);
|
||||
runtime.setCallbacks({
|
||||
callbacks: {
|
||||
onInput: (data) => {
|
||||
@@ -125,7 +127,6 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
|
||||
host,
|
||||
initialSnapshot: null,
|
||||
scrollback: input.scrollback ?? 10_000,
|
||||
isMacLikePlatform: input.isMacLikePlatform,
|
||||
theme: {
|
||||
background: "#0b0b0b",
|
||||
foreground: "#e6e6e6",
|
||||
@@ -527,7 +528,7 @@ describe("terminal emulator runtime in a real browser", () => {
|
||||
|
||||
it("translates mac editing shortcuts into shell editing sequences", async () => {
|
||||
await page.viewport(900, 600);
|
||||
const mounted = createTerminalHost({ width: 720, height: 360, isMacLikePlatform: true });
|
||||
const mounted = createTerminalHost({ width: 720, height: 360, isMac: true });
|
||||
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
|
||||
@@ -550,9 +551,23 @@ describe("terminal emulator runtime in a real browser", () => {
|
||||
expect(mounted.terminalKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([true, false])("keeps pending modifier chips authoritative (isMac=%s)", async (isMac) => {
|
||||
const mounted = createTerminalHost({ width: 720, height: 360, isMac });
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
mounted.runtime.setPendingModifiers({
|
||||
pendingModifiers: { ctrl: true, alt: false, shift: false },
|
||||
});
|
||||
dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, altKey: true });
|
||||
await nextFrame();
|
||||
expect(mounted.inputs).toEqual([]);
|
||||
expect(mounted.terminalKeys).toEqual([
|
||||
{ key: "ArrowLeft", ctrl: true, alt: true, shift: false, meta: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps cmd+shift+arrow free for app-level shortcuts", async () => {
|
||||
await page.viewport(900, 600);
|
||||
const mounted = createTerminalHost({ width: 720, height: 360, isMacLikePlatform: true });
|
||||
const mounted = createTerminalHost({ width: 720, height: 360, isMac: true });
|
||||
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
|
||||
@@ -571,7 +586,7 @@ describe("terminal emulator runtime in a real browser", () => {
|
||||
|
||||
it("does not remap modified arrows on non-mac platforms", async () => {
|
||||
await page.viewport(900, 600);
|
||||
const mounted = createTerminalHost({ width: 720, height: 360, isMacLikePlatform: false });
|
||||
const mounted = createTerminalHost({ width: 720, height: 360, isMac: false });
|
||||
|
||||
await waitFor({ predicate: () => mounted.sizes.length > 0 });
|
||||
|
||||
|
||||
@@ -241,6 +241,8 @@ describe("terminal-emulator-runtime", () => {
|
||||
hasSelection: () => false,
|
||||
getSelection: () => "",
|
||||
paste: () => {},
|
||||
input: () => {},
|
||||
scrollToBottom: () => {},
|
||||
});
|
||||
expect(passedToXterm).toBe(!opensFind);
|
||||
expect(findRequests).toBe(opensFind ? 1 : 0);
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
type PendingTerminalModifiers,
|
||||
hasPendingTerminalModifiers,
|
||||
isAppleHandheldPlatform,
|
||||
isMacLikePlatform,
|
||||
isTerminalModifierDomKey,
|
||||
mergeTerminalModifiers,
|
||||
normalizeDomTerminalKey,
|
||||
@@ -60,7 +59,6 @@ export interface TerminalEmulatorRuntimeMountInput {
|
||||
theme: ITheme;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
isMacLikePlatform?: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalEmulatorRuntimeCallbacks {
|
||||
@@ -146,16 +144,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
function detectMacLikePlatform(): boolean {
|
||||
return (
|
||||
typeof navigator !== "undefined" &&
|
||||
isMacLikePlatform({
|
||||
userAgent: navigator.userAgent,
|
||||
platform: (navigator as Navigator & { platform?: string }).platform,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const isAppleHandheld =
|
||||
typeof navigator !== "undefined" &&
|
||||
isAppleHandheldPlatform({
|
||||
@@ -262,7 +250,6 @@ export class TerminalEmulatorRuntime {
|
||||
private hasUngatedWrites = false;
|
||||
private readonly inputModeDecoder = new TextDecoder();
|
||||
private suppressInput = false;
|
||||
private isMacLikePlatform = false;
|
||||
private readonly inputModeTracker = new TerminalInputModeTracker();
|
||||
private lastInputModeState: TerminalInputModeState = this.inputModeTracker.getState();
|
||||
private themeBackgroundElements: HTMLElement[] = [];
|
||||
@@ -395,7 +382,7 @@ export class TerminalEmulatorRuntime {
|
||||
...modifiers,
|
||||
});
|
||||
|
||||
if (this.pendingModifiers.ctrl || this.pendingModifiers.shift || this.pendingModifiers.alt) {
|
||||
if (hasPendingTerminalModifiers(this.pendingModifiers)) {
|
||||
this.callbacks.onPendingModifiersConsumed?.();
|
||||
}
|
||||
|
||||
@@ -454,7 +441,7 @@ export class TerminalEmulatorRuntime {
|
||||
terminal: Pick<Terminal, "input" | "scrollToBottom">,
|
||||
event: KeyboardEvent,
|
||||
): boolean {
|
||||
if (!this.isMacLikePlatform || hasPendingTerminalModifiers(this.pendingModifiers)) {
|
||||
if (!this.options.isMac || hasPendingTerminalModifiers(this.pendingModifiers)) {
|
||||
return false;
|
||||
}
|
||||
const editingShortcutData = resolveMacTerminalEditingShortcut(event);
|
||||
@@ -475,7 +462,6 @@ export class TerminalEmulatorRuntime {
|
||||
|
||||
input.host.innerHTML = "";
|
||||
this.lastSize = null;
|
||||
this.isMacLikePlatform = input.isMacLikePlatform ?? detectMacLikePlatform();
|
||||
this.inputModeTracker.reset();
|
||||
this.emitInputModeChange();
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -82,21 +82,12 @@ export function hasPendingTerminalModifiers(modifiers: PendingTerminalModifiers)
|
||||
return modifiers.ctrl || modifiers.shift || modifiers.alt;
|
||||
}
|
||||
|
||||
interface MacPlatformDetectionInput {
|
||||
interface AppleHandheldDetectionInput {
|
||||
userAgent: string | null | undefined;
|
||||
platform: string | null | undefined;
|
||||
}
|
||||
|
||||
interface AppleHandheldDetectionInput extends MacPlatformDetectionInput {
|
||||
maxTouchPoints: number | null | undefined;
|
||||
}
|
||||
|
||||
// Matches macOS and iPadOS (which reports a Mac user agent in WKWebView); both
|
||||
// follow the same cmd/option editing conventions on hardware keyboards.
|
||||
export function isMacLikePlatform(input: MacPlatformDetectionInput): boolean {
|
||||
return /Macintosh|Mac OS/i.test(input.userAgent ?? "") || /Mac/i.test(input.platform ?? "");
|
||||
}
|
||||
|
||||
// iPadOS 13+ WKWebView reports navigator.platform="MacIntel" and a Mac UA string. Distinguish
|
||||
// iPad/iPhone from real macOS via maxTouchPoints, which is 0 on macOS and >1 on iPadOS/iOS.
|
||||
export function isAppleHandheldPlatform(input: AppleHandheldDetectionInput): boolean {
|
||||
|
||||
Reference in New Issue
Block a user