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:
+15
-7
@@ -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
@@ -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" },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user