Remap any shortcut in Settings → Keyboard; the macOS menu bar follows

A Keyboard section in the Settings window edits the shared keymap: rows
by group with a search field, a chip per key (x removes), + to record,
reset per row and Reset all. Recording takes the keydown before every
other key in the window (Cmd+W records instead of closing), refuses
reserved combos without preventDefault (Cmd+Q still quits), requires a
chord or F-key for editor commands, and offers Replace when another
command in an overlapping scope owns the key. Rows flag conflicts and
plain character keys that would stop starting a type-to-filter query.
Fixed keys are listed read-only, in the platform's spelling.

Remaps are ordinary settings (only rows that differ from the defaults),
so every window, the menu hints and the tooltips follow live — and the
macOS menu bar: its accelerators are built from the keymap (only the
combos Electrobun's parser expresses faithfully) and rebuilt on a remap.
A main-window menu action fired from the editor or Settings window
brings the main window forward first.

Also: the "Enter / double-click on a file" setting under File list (do
nothing / open local files in their default app), arrow keys in the
Settings sidebar with focus on open and a hand-walked Tab, translations
for all seven languages (the spec now checks every language labels every
command), and a README shortcut table generated from the keymap.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-10-05 14:57:27 +02:00
co-authored by Claude
parent d361297690
commit 8f8f230711
18 changed files with 1256 additions and 50 deletions
+15 -7
View File
@@ -108,7 +108,7 @@ const manager = new ManagerWindow({
connectionsChanged: () => rpc.send.connectionsChanged({}),
menuAction: (info) => rpc.send.menuAction(info),
},
rebuildMenu: () => void applyApplicationMenu(store),
rebuildMenu: () => void applyApplicationMenu(store, () => currentSettings.keybindings),
onSettingsSaved: (s) => settingsSaved(s, "manager"),
});
const settingsWin = new SettingsWindow({
@@ -134,12 +134,14 @@ function settingsSaved(
): void {
engine.setConcurrency(effectiveConcurrency(s));
const localeChanged = s.locale !== currentSettings.locale;
const keysChanged = JSON.stringify(s.keybindings) !== JSON.stringify(currentSettings.keybindings);
currentSettings = s; // future windows' preloads bake the fresh theme
if (localeChanged) {
setBackendLocale(s.locale);
void applyApplicationMenu(store); // relabel the native menu in the new language
settingsWin.retitle();
}
// relabel the native menu in the new language / give it the remapped keys
if (localeChanged || keysChanged) void applyApplicationMenu(store, () => currentSettings.keybindings);
if (sender !== "main") rpc.send.settingsChanged({ settings: s });
if (sender !== "manager") manager.broadcastSettings(s);
if (sender !== "settings") settingsWin.broadcastSettings(s);
@@ -258,16 +260,19 @@ events.sessionClosed = (info) => {
rpc.send.sessionClosed(info);
editors.notifySessionClosed(info);
};
events.connectionsChanged = () => void applyApplicationMenu(store);
events.connectionsChanged = () => void applyApplicationMenu(store, () => currentSettings.keybindings);
engine.setConcurrency(effectiveConcurrency(currentSettings));
void applyApplicationMenu(store);
void applyApplicationMenu(store, () => currentSettings.keybindings);
ApplicationMenu.on("application-menu-clicked", (event) => {
const action = (event as { data?: { action?: string } }).data?.action;
if (!action) return;
// ⌘W while the Settings window or an editor window is focused closes THAT
// window (an editor with its dirty prompt), not a main-window tab. Other
// menu actions deliberately still target the main window.
// Their pages claim close-window (⌘W) themselves — macOS hands key
// equivalents to the page first — so a close-tab that still arrives while
// the Settings window or an editor is focused is a ⌘W the page let through
// (or the menu item clicked): close THAT window (an editor with its dirty
// prompt), never a main-window tab behind it. Other menu actions
// deliberately still target the main window.
if (action === "close-tab") {
if (settingsWin.isFocused()) {
settingsWin.close();
@@ -299,6 +304,9 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
}
// the manager is modal to the main window — don't drive main behind it
if (manager.isOpen()) return;
// everything below acts on the main window: from a focused editor or the
// Settings window (⌘T, ⌘N…), bring it forward so the effect is visible
if (settingsWin.isFocused() || editors.focusedKey()) win.activate();
rpc.send.menuAction({ action });
});
+33 -11
View File
@@ -1,5 +1,6 @@
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/main";
import { BRANDING } from "../shared/branding";
import { effectiveKeymap, type ShortcutId, toAccelerator } from "../shared/keymap";
import type { SavedConnection } from "../shared/types";
import { t } from "./i18n";
import type { ConnectionStore } from "./store/connections";
@@ -7,13 +8,30 @@ import type { ConnectionStore } from "./store/connections";
/**
* macOS application menu (electrobun menus are macOS-only). Standard roles plus
* app actions; custom actions are forwarded to the webview as menuAction
* messages. Rebuilt whenever saved connections change so the Connect submenu
* stays current.
* messages. Rebuilt whenever saved connections, the language or the keyboard
* remaps change.
*
* Accelerators come from the shared keymap, so the menu bar shows — and
* fires — the user's keys. The page sees ⌘-keys first and claims the ones it
* handles; the menu only runs what falls through (see shortcuts.service.ts).
* ⌘Q stays fixed: quitting must always work.
*/
export async function applyApplicationMenu(store: ConnectionStore): Promise<void> {
export async function applyApplicationMenu(
store: ConnectionStore,
keybindings: () => Record<string, string[]>,
): Promise<void> {
if (process.platform !== "darwin") return;
const { stacks, connections } = await store.load();
// read after the await: a remap saved meanwhile must not be lost
const keymap = effectiveKeymap(keybindings(), true);
const accelerator = (id: ShortcutId): string | undefined => {
for (const combo of keymap.find((b) => b.id === id)?.combos ?? []) {
const native = toAccelerator(combo);
if (native) return native;
}
return undefined;
};
const item = (c: SavedConnection): ApplicationMenuItemConfig => ({
label: c.name,
action: `connect:${c.id}`,
@@ -28,7 +46,11 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
...looseItems,
...stackMenus,
...(looseItems.length || stackMenus.length ? [{ type: "separator" } as const] : []),
{ label: t("menu.connectionManager"), action: "manage-connections" },
{
label: t("menu.connectionManager"),
action: "manage-connections",
accelerator: accelerator("connection-manager"),
},
];
ApplicationMenu.setApplicationMenu([
@@ -36,7 +58,7 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
submenu: [
{ label: t("menu.about", { app: BRANDING.appName }), action: "about" },
{ type: "separator" },
{ label: t("menu.settings"), action: "settings", accelerator: "," },
{ label: t("menu.settings"), action: "settings", accelerator: accelerator("open-settings") },
{ type: "separator" },
{ label: t("menu.checkForUpdates"), action: "check-for-updates" },
{ type: "separator" },
@@ -53,13 +75,13 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
{
label: t("menu.file"),
submenu: [
{ label: t("menu.newTab"), action: "new-tab", accelerator: "t" },
{ label: t("menu.closeTab"), action: "close-tab", accelerator: "w" },
{ label: t("menu.newTab"), action: "new-tab", accelerator: accelerator("new-tab") },
{ label: t("menu.closeTab"), action: "close-tab", accelerator: accelerator("close-tab") },
{ type: "separator" },
{ label: t("menu.newFolder"), action: "new-folder" },
{ label: t("menu.newFile"), action: "new-file" },
{ label: t("menu.newFolder"), action: "new-folder", accelerator: accelerator("new-folder") },
{ label: t("menu.newFile"), action: "new-file", accelerator: accelerator("new-file") },
{ type: "separator" },
{ label: t("menu.editFile"), action: "edit-file", accelerator: "e" },
{ label: t("menu.editFile"), action: "edit-file", accelerator: accelerator("edit") },
{ type: "separator" },
{ label: t("menu.connect"), submenu: connectSubmenu },
],
@@ -81,7 +103,7 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
{
label: t("menu.view"),
submenu: [
{ label: t("menu.refresh"), action: "refresh-active", accelerator: "r" },
{ label: t("menu.refresh"), action: "refresh-active", accelerator: accelerator("refresh") },
{ type: "separator" },
{ role: "toggleFullScreen" },
],