diff --git a/desktop/src-tauri/src/app_menu.rs b/desktop/src-tauri/src/app_menu.rs new file mode 100644 index 000000000..e6d7944a1 --- /dev/null +++ b/desktop/src-tauri/src/app_menu.rs @@ -0,0 +1,115 @@ +//! The macOS application menu. +//! +//! Buzz never called `Builder::menu()`, so Tauri installed `Menu::default()` +//! for us (`tauri::app::Builder::build`, macOS arm). That default puts a +//! `close_window` item in both the File and Window submenus, and muda gives +//! that item a Cmd+W key equivalent bound to `performClose:`. +//! +//! Two consequences, both wrong for Buzz: +//! +//! 1. `CloseRequested` on the main window is intercepted in `lib.rs` and turned +//! into hide-to-tray, so Cmd+W never closed a window -- it hid the whole +//! app. That is already redundant with Cmd+H (Hide), which stays. +//! 2. macOS resolves a menu key equivalent before the webview receives any key +//! event, so Buzz Term could never bind Cmd+W to "close this terminal tab" +//! while the accelerator was claimed here. +//! +//! So this module builds the standard menu minus both `close_window` items. +//! Everything else matches `Menu::default()` deliberately: the goal is to drop +//! one item, not to design a menu. +//! +//! If hide-on-Cmd+W is ever wanted back in Buzz mode, the revisit path is to +//! restore the item and disable it while the terminal owns input (a disabled +//! item does not consume its key equivalent) -- at the cost of an owner->Rust +//! IPC hop this approach does not need. + +#[cfg(target_os = "macos")] +use tauri::menu::{ + AboutMetadata, Menu, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID, +}; +#[cfg(target_os = "macos")] +use tauri::AppHandle; +use tauri::{Builder, Runtime}; + +/// Installs Buzz's menu, replacing the `Menu::default()` Tauri would otherwise +/// auto-install. A no-op off macOS, where that default is never created and +/// the Cmd+W accelerator does not exist. +pub fn install(builder: Builder) -> Builder { + #[cfg(target_os = "macos")] + let builder = builder.menu(build); + builder +} + +/// Mirrors `Menu::default()` with every `close_window` item omitted. +/// +/// The Window and Help submenus keep Tauri's well-known ids: `init_app_menu` +/// looks them up by id to call `set_as_windows_menu_for_nsapp` and +/// `set_as_help_menu_for_nsapp`, and a plain `with_items` submenu would skip +/// both silently -- no error, just a Window menu AppKit no longer manages. +#[cfg(target_os = "macos")] +pub fn build(app: &AppHandle) -> tauri::Result> { + let pkg_info = app.package_info(); + let config = app.config(); + let about_metadata = AboutMetadata { + name: Some(pkg_info.name.clone()), + version: Some(pkg_info.version.to_string()), + copyright: config.bundle.copyright.clone(), + authors: config.bundle.publisher.clone().map(|p| vec![p]), + ..Default::default() + }; + + Menu::with_items( + app, + &[ + &Submenu::with_items( + app, + pkg_info.name.clone(), + true, + &[ + &PredefinedMenuItem::about(app, None, Some(about_metadata))?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::services(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::hide(app, None)?, + &PredefinedMenuItem::hide_others(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::quit(app, None)?, + ], + )?, + // `Menu::default()`'s File submenu holds exactly one item on macOS + // -- close_window -- so dropping that item drops the submenu too. + &Submenu::with_items( + app, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(app, None)?, + &PredefinedMenuItem::redo(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + ], + )?, + &Submenu::with_items( + app, + "View", + true, + &[&PredefinedMenuItem::fullscreen(app, None)?], + )?, + &Submenu::with_id_and_items( + app, + WINDOW_SUBMENU_ID, + "Window", + true, + &[ + &PredefinedMenuItem::minimize(app, None)?, + &PredefinedMenuItem::maximize(app, None)?, + ], + )?, + // Empty upstream too on macOS: About lives in the app submenu. + &Submenu::with_id_and_items(app, HELP_SUBMENU_ID, "Help", true, &[])?, + ], + ) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 52e89f04f..a73ef9919 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ #![recursion_limit = "256"] // Deep Tauri command futures exceed the default layout query depth. +mod app_menu; mod app_state; mod archive; mod builderlab; @@ -342,7 +343,7 @@ pub fn run() { builder.plugin(tauri_plugin_updater::Builder::new().build()) }; - let app = builder + let app = app_menu::install(builder) .register_asynchronous_uri_scheme_protocol("buzz-media", |ctx, request, responder| { let app = ctx.app_handle().clone(); tauri::async_runtime::spawn(async move { diff --git a/desktop/src/features/terminal/TerminalSubstrate.test.mjs b/desktop/src/features/terminal/TerminalSubstrate.test.mjs index 4759d2b35..6b4fb28b9 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.test.mjs +++ b/desktop/src/features/terminal/TerminalSubstrate.test.mjs @@ -26,6 +26,18 @@ before(async () => { window: dom.window, IS_REACT_ACT_ENVIRONMENT: true, }); + // `navigator` is a getter-only global on Node, and the chord layer branches + // on `isMacPlatform()`. Pin it so these assertions describe macOS behaviour + // on every host instead of quietly inverting on a Linux runner. + Object.defineProperty(dom.window.navigator, "platform", { + configurable: true, + value: "MacIntel", + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); dom.window.matchMedia = () => ({ get matches() { return reducedMotion; @@ -379,3 +391,139 @@ test("reduced motion keeps the terminal cursor solid", async () => { window.setInterval = originalSetInterval; } }); + +function tabFixture(overrides = {}) { + const calls = { close: [], input: [], select: [], spawn: 0 }; + const subject = fixture({ + onCloseSession(id) { + calls.close.push(id); + }, + onInput(value) { + calls.input.push(value); + }, + onNewSession() { + calls.spawn += 1; + }, + onSelectSession(id) { + calls.select.push(id); + }, + sessions: [ + { active: true, closing: false, id: "one", title: "SHELL" }, + { active: false, closing: false, id: "two", title: "LOG" }, + ], + ...overrides, + }); + // Spread order matters: `fixture` returns its own `calls`, so ours goes last. + return { ...subject, calls }; +} + +function press(init) { + // Dispatched at the window, which is where the substrate's capture-phase + // listener lives -- firing at the textarea would test React's synthetic + // tree instead of the layer actually under test. + const event = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + ...init, + }); + act(() => { + window.dispatchEvent(event); + }); + return event; +} + +async function revealed(overrides) { + const subject = tabFixture(overrides); + await ready(subject.view); + await reveal(subject.view); + return subject; +} + +test("tab chords drive new, close, and select while the terminal owns input", async () => { + const subject = await revealed({ + sessions: [ + { active: false, closing: false, id: "one", title: "SHELL" }, + { active: true, closing: false, id: "two", title: "LOG" }, + { active: false, closing: false, id: "three", title: "TAIL" }, + ], + }); + + const spawn = press({ code: "KeyT", metaKey: true }); + assert.equal(subject.calls.spawn, 1); + assert.equal(spawn.defaultPrevented, true, "⌘T must not reach the page"); + + const close = press({ code: "KeyW", metaKey: true }); + assert.deepEqual(subject.calls.close, ["two"]); + assert.equal(close.defaultPrevented, true, "⌘W must not reach the page"); + + press({ code: "ArrowRight", metaKey: true, shiftKey: true }); + press({ code: "ArrowLeft", metaKey: true, shiftKey: true }); + // Three sessions, active in the middle: with only two tabs, prev and next + // resolve to the same id and a direction swap would pass unnoticed. + assert.deepEqual(subject.calls.select, ["three", "one"]); + + // Nothing was mistaken for terminal input along the way. + assert.deepEqual(subject.calls.input, []); +}); + +test("tab chords stay inert while Buzz owns input", async () => { + const subject = tabFixture(); + await ready(subject.view); + // Deliberately not revealed: owner is "buzz". + const spawn = press({ code: "KeyT", metaKey: true }); + press({ code: "KeyW", metaKey: true }); + press({ code: "ArrowRight", metaKey: true, shiftKey: true }); + + assert.equal(subject.calls.spawn, 0); + assert.deepEqual(subject.calls.close, []); + assert.deepEqual(subject.calls.select, []); + assert.equal( + spawn.defaultPrevented, + false, + "Buzz-mode ⌘T belongs to the rest of the app", + ); +}); + +test("control chords keep reaching the PTY instead of the tab layer", async () => { + const subject = await revealed(); + const werase = press({ code: "KeyW", ctrlKey: true }); + + assert.deepEqual(subject.calls.close, [], "^W is werase, not close-tab"); + assert.equal(subject.calls.spawn, 0); + assert.equal( + werase.defaultPrevented, + false, + "the chord layer must not consume ^W before the textarea encodes it", + ); +}); + +test("⌘W on an already-closing tab does not re-fire close", async () => { + const subject = await revealed({ + sessions: [ + { active: true, closing: true, id: "one", title: "SHELL" }, + { active: false, closing: false, id: "two", title: "LOG" }, + ], + }); + const close = press({ code: "KeyW", metaKey: true }); + + assert.deepEqual(subject.calls.close, []); + assert.equal( + close.defaultPrevented, + true, + "the chord is still ours -- swallowed, not forwarded", + ); +}); + +test("the handoff chord still toggles with the tab layer installed", async () => { + const subject = tabFixture(); + await ready(subject.view); + const substrate = subject.view.container.querySelector( + ".buzz-terminal-substrate", + ); + toggleChord(); + await waitFor(() => + assert.equal(substrate.dataset.terminalOwner, "terminal"), + ); + toggleChord(); + await waitFor(() => assert.equal(substrate.dataset.terminalOwner, "buzz")); +}); diff --git a/desktop/src/features/terminal/TerminalSubstrate.tsx b/desktop/src/features/terminal/TerminalSubstrate.tsx index 4b069af9b..46177e463 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.tsx +++ b/desktop/src/features/terminal/TerminalSubstrate.tsx @@ -2,13 +2,16 @@ import * as React from "react"; import { useTheme } from "@/shared/theme/ThemeProvider"; import { cn } from "@/shared/lib/cn"; +import { isMacPlatform } from "@/shared/lib/platform"; import { FadeController } from "./fadeController"; import { INITIAL_HANDOFF_STATE, accumulateScrollLines, encodePaste, encodeTerminalKey, + matchTabChord, reduceHandoff, + stepSession, } from "./terminalState"; import { buildTerminalBanner } from "./terminalBanner"; import { paintTerminalBanner } from "./terminalBannerPainter"; @@ -182,6 +185,30 @@ export function TerminalSubstrate({ const consumeFrame = React.useEffectEvent((nextFrame: TerminalFrame) => { onFrameConsumed?.(nextFrame); }); + /** + * Tab chords are handled at the window in capture phase, like the ⌘J + * handoff, so they win over the focused textarea. Gated on terminal + * ownership: in Buzz mode these keys belong to the rest of the app. + */ + const runTabChord = React.useEffectEvent((event: KeyboardEvent): boolean => { + if (owner !== "terminal" || event.isComposing) return false; + const chord = matchTabChord(event, isMacPlatform()); + if (!chord) return false; + if (chord === "new") { + onNewSession(); + return true; + } + if (chord === "close") { + // A tab already closing has a disabled × in the tab bar; re-firing close + // on it would send a second shutdown for a session on its way out. + if (!activeSession || activeSession.closing) return true; + onCloseSession(activeSession.id); + return true; + } + const next = stepSession(sessions, chord === "next" ? 1 : -1); + if (next) onSelectSession(next); + return true; + }); const reportViewportSize = React.useEffectEvent(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -259,7 +286,13 @@ export function TerminalSubstrate({ if (!appSurface) return; fadeRef.current = new FadeController(appSurface); const handleKeyDown = (event: KeyboardEvent) => { - if (!enabled || !isToggleChord(event)) return; + if (!enabled) return; + if (runTabChord(event)) { + event.preventDefault(); + event.stopImmediatePropagation(); + return; + } + if (!isToggleChord(event)) return; if (event.isComposing) { handoffRef.current = reduceHandoff(handoffRef.current, { type: "focus-lost", diff --git a/desktop/src/features/terminal/terminalState.test.mjs b/desktop/src/features/terminal/terminalState.test.mjs index c9b42f72c..dded3e512 100644 --- a/desktop/src/features/terminal/terminalState.test.mjs +++ b/desktop/src/features/terminal/terminalState.test.mjs @@ -6,7 +6,9 @@ import { accumulateScrollLines, encodePaste, encodeTerminalKey, + matchTabChord, reduceHandoff, + stepSession, } from "./terminalState.ts"; test("ownership changes on completed chord, never key-down or repeat", () => { @@ -95,3 +97,119 @@ test("pixel scrolling retains fractional lines in both directions", () => { assert.equal(result.lines, -1); assert.equal(result.state.remainderPx, -1); }); + +function chord(overrides) { + return { + altKey: false, + code: "KeyT", + ctrlKey: false, + metaKey: false, + shiftKey: false, + ...overrides, + }; +} + +test("tab chords match the platform's primary modifier only", () => { + assert.equal(matchTabChord(chord({ metaKey: true }), true), "new"); + assert.equal( + matchTabChord(chord({ code: "KeyW", metaKey: true }), true), + "close", + ); + assert.equal( + matchTabChord( + chord({ code: "ArrowLeft", metaKey: true, shiftKey: true }), + true, + ), + "previous", + ); + assert.equal( + matchTabChord( + chord({ code: "ArrowRight", metaKey: true, shiftKey: true }), + true, + ), + "next", + ); + + // The whole reason the matcher takes `isMac`: on macOS ^W is werase and ^T + // transposes. Claiming them here would swallow them before the textarea + // could encode them for the PTY. + assert.equal(matchTabChord(chord({ ctrlKey: true }), true), null); + assert.equal( + matchTabChord(chord({ code: "KeyW", ctrlKey: true }), true), + null, + ); + assert.equal( + matchTabChord(chord({ ctrlKey: true, metaKey: true }), true), + null, + "Control must veto even alongside Command", + ); + + // Non-mac mirrors it: Ctrl is primary, Meta is not. + assert.equal(matchTabChord(chord({ ctrlKey: true }), false), "new"); + assert.equal(matchTabChord(chord({ metaKey: true }), false), null); + + // Unmodified and Alt-modified keys stay with the terminal. + assert.equal(matchTabChord(chord({}), true), null); + assert.equal( + matchTabChord(chord({ altKey: true, metaKey: true }), true), + null, + ); + // Arrows without Shift are cursor keys, not tab switches. + assert.equal( + matchTabChord(chord({ code: "ArrowLeft", metaKey: true }), true), + null, + ); + // Shift+Cmd+T/W are not chords we claim. + assert.equal( + matchTabChord(chord({ metaKey: true, shiftKey: true }), true), + null, + ); +}); + +test("tab stepping wraps and skips tabs whose select button is disabled", () => { + const three = [ + { active: false, closing: false, id: "a" }, + { active: true, closing: false, id: "b" }, + { active: false, closing: false, id: "c" }, + ]; + assert.equal(stepSession(three, 1), "c"); + assert.equal(stepSession(three, -1), "a"); + + // Wrap at both ends. + const first = [ + { active: true, closing: false, id: "a" }, + { active: false, closing: false, id: "b" }, + ]; + assert.equal(stepSession(first, -1), "b"); + assert.equal(stepSession(first, 1), "b"); + + // A closing tab is disabled in the tab bar, so the keyboard skips past it. + assert.equal( + stepSession( + [ + { active: true, closing: false, id: "a" }, + { active: false, closing: true, id: "b" }, + { active: false, closing: false, id: "c" }, + ], + 1, + ), + "c", + ); + + // Nowhere to go: single tab, and all-others-closing. + assert.equal( + stepSession([{ active: true, closing: false, id: "a" }], 1), + null, + ); + assert.equal( + stepSession( + [ + { active: true, closing: false, id: "a" }, + { active: false, closing: true, id: "b" }, + ], + 1, + ), + null, + ); + assert.equal(stepSession([], 1), null); +}); diff --git a/desktop/src/features/terminal/terminalState.ts b/desktop/src/features/terminal/terminalState.ts index 95c23932c..5598b3860 100644 --- a/desktop/src/features/terminal/terminalState.ts +++ b/desktop/src/features/terminal/terminalState.ts @@ -93,6 +93,72 @@ export function encodeTerminalKey(event: { return null; } +export type TabChord = "close" | "new" | "next" | "previous"; + +/** + * Tab-management chords, matched on `code` so they survive alternate layouts. + * `isMac` is a parameter rather than a `navigator` read so matching stays pure, + * the same shape as `matchBackForwardChord`. + * + * The modifier is the platform's primary one and *only* that one — deliberately + * narrower than the sibling `isToggleChord`, which accepts either. On macOS + * `^W` is werase and `^T` is transpose; a capture-phase listener that claimed + * them would consume the event before the textarea could encode it, so Control + * must keep falling through to the PTY. Non-mac platforms pay the mirror cost + * on Ctrl (matching gnome-terminal, which moves its own tab chords onto + * Ctrl+Shift for exactly this reason) — see the report for why that is scoped + * out rather than solved here. + * + * ⌘W is matched even though macOS currently resolves it as the File > Close + * Window key equivalent before the webview sees any key event: the accelerator + * has to be released natively for this arm to ever run, and matching it now + * means the frontend needs no further change when it is. + */ +export function matchTabChord( + event: { + altKey: boolean; + code: string; + ctrlKey: boolean; + metaKey: boolean; + shiftKey: boolean; + }, + isMac: boolean, +): TabChord | null { + if (event.altKey) return null; + if (isMac ? !event.metaKey || event.ctrlKey : !event.ctrlKey || event.metaKey) + return null; + if (event.shiftKey) { + if (event.code === "ArrowLeft") return "previous"; + if (event.code === "ArrowRight") return "next"; + return null; + } + if (event.code === "KeyT") return "new"; + if (event.code === "KeyW") return "close"; + return null; +} + +/** + * The id ⇧⌘←/→ moves to, wrapping at both ends. + * + * Closing tabs are skipped because the tab bar disables their select button — + * the keyboard path must not reach a tab the mouse path cannot. + */ +export function stepSession( + sessions: readonly { active: boolean; closing: boolean; id: string }[], + direction: -1 | 1, +): string | null { + const count = sessions.length; + const activeIndex = sessions.findIndex((session) => session.active); + if (activeIndex < 0) return null; + for (let offset = 1; offset < count; offset += 1) { + const index = + (((activeIndex + direction * offset) % count) + count) % count; + const candidate = sessions[index]; + if (!candidate.closing) return candidate.id; + } + return null; +} + export function encodePaste(text: string, bracketed: boolean): string { return bracketed ? `\u001b[200~${text}\u001b[201~` : text; }