Skip to content
Merged
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
87 changes: 87 additions & 0 deletions apps/desktop/src/main/browser/app-shortcut-relay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { BrowserWindow } from "electron";
import type { BrowserAppShortcutEvent } from "@volli/shared";
import { describe, expect, it, vi } from "vite-plus/test";

import { createBrowserShortcutRelay } from "./app-shortcut-relay";

const EVENT: BrowserAppShortcutEvent = { tabId: "opaque-1", shortcut: { kind: "close-tab" } };

function appWindow(destroyed = false) {
const order: string[] = [];
const window = {
isDestroyed: () => destroyed,
webContents: { focus: vi.fn(() => order.push("focus")) },
};
return { window: window as unknown as BrowserWindow, focus: window.webContents.focus, order };
}

describe("createBrowserShortcutRelay (VC-301)", () => {
it("gives the app window focus before any subscriber hears the shortcut", () => {
const relay = createBrowserShortcutRelay(vi.fn());
const { window, order } = appWindow();
relay.subscribe(() => order.push("heard"));

relay.deliver(window, EVENT);

expect(order).toEqual(["focus", "heard"]);
});

it("hands every subscriber the shortcut until it unsubscribes", () => {
const relay = createBrowserShortcutRelay(vi.fn());
const { window } = appWindow();
const first = vi.fn();
const second = vi.fn();
const stopFirst = relay.subscribe(first);
relay.subscribe(second);

relay.deliver(window, EVENT);
stopFirst();
relay.deliver(window, EVENT);

expect(first).toHaveBeenCalledOnce();
expect(second).toHaveBeenCalledTimes(2);
expect(second).toHaveBeenCalledWith(EVENT);
});

it("keeps the same function subscribed twice as two streams", () => {
const relay = createBrowserShortcutRelay(vi.fn());
const { window } = appWindow();
const listener = vi.fn();
const stop = relay.subscribe(listener);
relay.subscribe(listener);
stop();

relay.deliver(window, EVENT);

expect(listener).toHaveBeenCalledOnce();
});

it("reports a subscriber that throws and still reaches the rest", () => {
const onListenerError = vi.fn();
const relay = createBrowserShortcutRelay(onListenerError);
const { window } = appWindow();
const failure = new Error("stream gone");
relay.subscribe(() => {
throw failure;
});
const after = vi.fn();
relay.subscribe(after);

relay.deliver(window, EVENT);

expect(onListenerError).toHaveBeenCalledWith(failure);
expect(after).toHaveBeenCalledWith(EVENT);
});

it("does nothing for a window that has gone", () => {
const relay = createBrowserShortcutRelay(vi.fn());
const { window, focus } = appWindow(true);
const listener = vi.fn();
relay.subscribe(listener);

relay.deliver(window, EVENT);

expect(focus).not.toHaveBeenCalled();
expect(listener).not.toHaveBeenCalled();
});
});
46 changes: 46 additions & 0 deletions apps/desktop/src/main/browser/app-shortcut-relay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { BrowserWindow } from "electron";
import type { BrowserAppShortcutEvent } from "@volli/shared";
import type { BrowserShortcutsPort } from "@volli/host-core/handlers";

/**
* Where the Browser Tab host's app shortcuts go (VC-301): the window that
* draws the page gets keyboard focus back, then every window following the
* desktop-only `browserShortcuts.subscribe` entry hears the shortcut.
*
* {@link deliver} is the host's `deliverAppShortcut`; {@link subscribe} is the
* half of the map's `BrowserShortcutsPort` this relay owns. Focus comes back
* to the app FIRST: the palette's field, the address bar and the pane a chord
* moves to are all renderer DOM, unreachable while the page holds focus.
*/
export interface BrowserShortcutRelay {
deliver(window: BrowserWindow, event: BrowserAppShortcutEvent): void;
subscribe: BrowserShortcutsPort["subscribe"];
}

export function createBrowserShortcutRelay(
onListenerError: (error: unknown) => void,
): BrowserShortcutRelay {
const listeners = new Set<(event: BrowserAppShortcutEvent) => void>();
return {
deliver(window, event) {
if (window.isDestroyed()) return;
window.webContents.focus();
for (const listener of listeners) {
// One window's broken stream must not keep the key from another.
try {
listener(event);
} catch (error) {
onListenerError(error);
}
}
},
subscribe(listener) {
// A wrapper per subscription, so the same function subscribed twice is two streams.
const one = (event: BrowserAppShortcutEvent) => listener(event);
listeners.add(one);
return () => {
listeners.delete(one);
};
},
};
}
151 changes: 151 additions & 0 deletions apps/desktop/src/main/browser/app-shortcuts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { describe, expect, it } from "vite-plus/test";

import { isCommandPaletteKeyEvent } from "@renderer/lib/command-palette-shortcut";
import { projectIndexForKeyEvent } from "@renderer/lib/project-shortcut";
import { splitShortcutForKeyEvent } from "@renderer/lib/split-shortcut";

import { browserKeyRoute, type BrowserKeyInput } from "./app-shortcuts";

/** A key down with ⌘ alone held, unless a test says otherwise. */
function key(overrides: Partial<BrowserKeyInput>): BrowserKeyInput {
return {
type: "keyDown",
key: "k",
code: "KeyK",
meta: true,
control: false,
alt: false,
shift: false,
isAutoRepeat: false,
isComposing: false,
...overrides,
};
}

const letter = (char: string, overrides: Partial<BrowserKeyInput> = {}): BrowserKeyInput =>
key({ key: char, code: `Key${char.toUpperCase()}`, ...overrides });

describe("browserKeyRoute — what the app takes", () => {
it("replays ⌘K, ⌘1–⌘9 and ⌃⌘ + an arrow as the chord they are", () => {
const pressed = [
letter("k"),
key({ key: "1", code: "Digit1" }),
key({ key: "9", code: "Digit9" }),
key({ key: "ArrowLeft", code: "ArrowLeft", control: true }),
key({ key: "ArrowRight", code: "ArrowRight", control: true }),
key({ key: "ArrowUp", code: "ArrowUp", control: true }),
key({ key: "ArrowDown", code: "ArrowDown", control: true }),
];
for (const input of pressed) {
expect(browserKeyRoute(input)).toEqual({
kind: "app",
shortcut: {
kind: "replay",
chord: {
key: input.key,
code: input.code,
metaKey: true,
ctrlKey: input.control,
altKey: false,
shiftKey: false,
repeat: false,
},
},
});
}
});

it("matches ⌘K whatever case the layout reports", () => {
expect(browserKeyRoute(letter("K"))).toMatchObject({ kind: "app" });
});

it("carries a replayed chord's repeat, so the renderer's own rule decides", () => {
expect(
browserKeyRoute(
key({ key: "ArrowLeft", code: "ArrowLeft", control: true, isAutoRepeat: true }),
),
).toMatchObject({ kind: "app", shortcut: { kind: "replay", chord: { repeat: true } } });
});

it("names ⌘W as this tab's close and ⌘L as the way back to its address bar", () => {
expect(browserKeyRoute(letter("w"))).toEqual({
kind: "app",
shortcut: { kind: "close-tab" },
});
expect(browserKeyRoute(letter("l"))).toEqual({
kind: "app",
shortcut: { kind: "focus-address" },
});
});

it("swallows a held ⌘W or ⌘L rather than acting again or falling through to the menu", () => {
expect(browserKeyRoute(letter("w", { isAutoRepeat: true }))).toEqual({ kind: "swallow" });
expect(browserKeyRoute(letter("l", { isAutoRepeat: true }))).toEqual({ kind: "swallow" });
});
});

describe("browserKeyRoute — what the page keeps", () => {
it.each([
["a key up", letter("k", { type: "keyUp" })],
["a key mid-composition", letter("k", { isComposing: true })],
["a bare key", letter("k", { meta: false })],
["Escape", key({ key: "Escape", code: "Escape", meta: false })],
["Shift+Tab", key({ key: "Tab", code: "Tab", meta: false, shift: true })],
["⌃K, a readline and field binding", letter("k", { meta: false, control: true })],
["⇧⌘K", letter("k", { shift: true })],
["⌥⌘K", letter("k", { alt: true })],
["⌃⌘K", letter("k", { control: true })],
["⌘B, bold in a page editor", letter("b")],
["⌘C, copy", letter("c")],
["⌘V, paste", letter("v")],
["⌘A, select all", letter("a")],
["⌘Z, undo", letter("z")],
["⌘P, the page's print", letter("p")],
["⌘[, outdent in a page editor", key({ key: "[", code: "BracketLeft" })],
["⌘], indent in a page editor", key({ key: "]", code: "BracketRight" })],
["⌘0", key({ key: "0", code: "Digit0" })],
["⌘ + an arrow, caret movement in a field", key({ key: "ArrowLeft", code: "ArrowLeft" })],
["⇧⌃⌘ + an arrow", key({ key: "ArrowLeft", code: "ArrowLeft", control: true, shift: true })],
["⌥⌘ + an arrow", key({ key: "ArrowLeft", code: "ArrowLeft", alt: true, control: true })],
["⌃⌘ + a letter", letter("w", { control: true })],
["⇧⌘W", letter("w", { shift: true })],
])("leaves %s to the page", (_label, input) => {
expect(browserKeyRoute(input)).toEqual({ kind: "page" });
});
});

/** The replayed chord a key routes to, or a failure naming the key that was not one. */
function asKeyboardEvent(input: BrowserKeyInput) {
const route = browserKeyRoute(input);
if (route.kind !== "app" || route.shortcut.kind !== "replay") {
throw new Error(`expected a replayed chord for ${input.key}`);
}
return route.shortcut.chord;
}

/**
* Main decides which chords leave the page; the renderer's own predicates decide
* what they mean. A replayed chord the renderer then ignored would be a key
* taken from the page for nothing, so every one is checked against the
* predicate that owns it.
*/
describe("browserKeyRoute agrees with the renderer's chord predicates", () => {
it("replays a ⌘K the palette's predicate recognises", () => {
expect(isCommandPaletteKeyEvent(asKeyboardEvent(letter("k")))).toBe(true);
});

it("replays ⌘1–⌘9 the project rail's predicate recognises", () => {
for (let digit = 1; digit <= 9; digit += 1) {
const chord = asKeyboardEvent(key({ key: String(digit), code: `Digit${digit}` }));
expect(projectIndexForKeyEvent(chord)).toBe(digit - 1);
}
});

it("replays ⌃⌘ + an arrow the split view's predicate reads as pane focus", () => {
const directions = { ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down" };
for (const [code, direction] of Object.entries(directions)) {
const chord = asKeyboardEvent(key({ key: code, code, control: true }));
expect(splitShortcutForKeyEvent(chord)).toEqual({ kind: "focus", direction });
}
});
});
102 changes: 102 additions & 0 deletions apps/desktop/src/main/browser/app-shortcuts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { BrowserAppShortcut } from "@volli/shared";

/**
* Which keys pressed inside a Browser page belong to the app (VC-301).
*
* A Browser Tab's page is its own native WebContents, so a key pressed in it
* never reaches the renderer's window `keydown` listeners — ⌘K, the project
* chords and pane focus all went dead the moment a page had focus, and ⌘W fell
* through to the menu's Close Window. Main sees every key first
* (`before-input-event`), and this is the whole of its decision.
*
* DELIBERATELY SMALL. Everything not named here is the page's, including every
* editing chord: main cannot see whether the page's focus is a text field, so a
* chord that means something inside one (⌘[ / ⌘] outdent, ⌘B bold, ⌘P print,
* Escape closing a page's own dialog, Tab moving through its form) is never
* taken. What is taken is chosen because no page owns it in a browser either:
*
* • ⌘K — the command palette. The ticket's one exception to "a page field
* keeps its chords": the palette is the way out of anywhere.
* • ⌘1–⌘9 — select the Nth project, as everywhere else in the app.
* • ⌃⌘ + an arrow — move between split panes.
* • ⌘W — close THIS Browser Tab. The browser's own meaning, and the only safe
* one: left alone it falls through to the menu and closes the window.
* • ⌘L — return focus to the app, on this tab's address bar. The browser's
* own "leave the page" chord, where Escape and Tab are the page's.
*
* The first three are replayed into the renderer as the chord they are, so the
* listener that already owns each one — with its own guards, the terminal guard
* on ⌘K included — is the one that decides. Only the two that need to know
* WHICH tab the key came from are named here.
*/

/** The fields of Electron's `Input` this decision reads. */
export interface BrowserKeyInput {
type: string;
key: string;
code: string;
meta: boolean;
control: boolean;
alt: boolean;
shift: boolean;
isAutoRepeat: boolean;
isComposing: boolean;
}

/**
* Where one key goes.
*
* • `page` — not the app's; the page gets it as it always did.
* • `swallow` — the app's, but nothing to do: a held ⌘W's repeats must not
* reach the page or, through it, the menu's Close Window.
* • `app` — the app's; main hands the shortcut to the renderer.
*/
export type BrowserKeyRoute =
| { kind: "page" }
| { kind: "swallow" }
| { kind: "app"; shortcut: BrowserAppShortcut };

const PAGE: BrowserKeyRoute = { kind: "page" };
const SWALLOW: BrowserKeyRoute = { kind: "swallow" };

const ARROWS = new Set(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);

function replay(input: BrowserKeyInput): BrowserKeyRoute {
return {
kind: "app",
shortcut: {
kind: "replay",
chord: {
key: input.key,
code: input.code,
metaKey: input.meta,
ctrlKey: input.control,
altKey: input.alt,
shiftKey: input.shift,
repeat: input.isAutoRepeat,
},
},
};
}

/** An action on the tab itself fires once per press; its repeats are swallowed. */
function once(input: BrowserKeyInput, shortcut: BrowserAppShortcut): BrowserKeyRoute {
return input.isAutoRepeat ? SWALLOW : { kind: "app", shortcut };
}

export function browserKeyRoute(input: BrowserKeyInput): BrowserKeyRoute {
// Key up is the page's: it never saw the down, and a stray up does nothing.
// A key mid-composition belongs to the input method, never to a shortcut.
if (input.type !== "keyDown" || input.isComposing || !input.meta || input.alt) return PAGE;

if (input.control) {
return !input.shift && ARROWS.has(input.code) ? replay(input) : PAGE;
}
if (input.shift) return PAGE;

const key = input.key.toLowerCase();
if (key === "k" || /^[1-9]$/.test(input.key)) return replay(input);
if (key === "w") return once(input, { kind: "close-tab" });
if (key === "l") return once(input, { kind: "focus-address" });
return PAGE;
}
Loading
Loading