mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): bind terminal tab chords and free Cmd+W
Buzz Term had no keyboard path to its tab actions: `encodeTerminalKey` rejects every metaKey event and the substrate handled only the Cmd+J handoff, so `onNewSession`/`onCloseSession`/`onSelectSession` existed as props with nothing bound to them. Add a capture-phase chord layer alongside the Cmd+J listener, gated on `enabled && owner === "terminal"`: Cmd+T spawns, Cmd+W closes the active tab, Shift+Cmd+Left/Right step between tabs. Matching lives in `matchTabChord`/`stepSession` as pure functions, mirroring the existing `matchBackForwardChord` split. Cmd+W additionally needed the native layer. Buzz never called `Builder::menu()`, so Tauri auto-installed `Menu::default()`, whose File and Window submenus each carry a `close_window` item bound to Cmd+W -- and macOS resolves a menu key equivalent before the webview sees any key event, so no JS listener could ever claim it. That accelerator was also already wrong on its own terms: `CloseRequested` on the main window is intercepted and turned into hide-to-tray, so Cmd+W hid the whole app rather than closing anything, duplicating Cmd+H. `app_menu` now builds the standard menu minus both `close_window` items, keeping Tauri's well-known Window/Help submenu ids so `init_app_menu` still hands them to AppKit. Control is deliberately excluded as a chord modifier on macOS: Ctrl-W is werase and Ctrl-T transposes, and consuming them in capture phase would starve the PTY. Tests pin each chord through the real window listener, plus the guards a matcher-only test would miss: chords inert in Buzz mode, Ctrl-W still reaching the PTY, and no repeat close on an already-closing tab. Six mutants (dispatch removed, owner gate dropped, closing guard dropped, Control accepted, closing-skip dropped, direction swapped) each fail at least one test; the direction pair needs three sessions, since with two tabs previous and next resolve to the same id. Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz> Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz>
This commit is contained in:
parent
1350422539
commit
3b7a4895ee
@@ -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<R: Runtime>(builder: Builder<R>) -> Builder<R> {
|
||||
#[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<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
|
||||
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, &[])?,
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user