diff --git a/README.md b/README.md index 56dcf4f..b7e024b 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,15 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu a standard titlebar. - **Menu-bar (tray) icon**: template icon that adapts to light/dark; click it to bring the app to the front. -- **Edit files** (F4 / toolbar / File menu): a built-in editor works on local +- **Edit files** (F4 / ⌘E / toolbar / File menu): a built-in editor works on local and remote files directly; or pick an external app (Settings → Editor → Custom → Choose…). External edits of remote files download to a temp folder, are watched, and every save auto-uploads (visible in the transfer list) until the session disconnects. Leftover temp files from a crash are offered for cleanup at the next start. -- Browse, sort (name/size/modified, folders first), keyboard navigation. +- Browse, sort (name/size/modified, folders first), full keyboard control + with remappable shortcuts (see below). Enter / double-click on a local file + can open it in its default app (Settings → File list; off by default). - Create folder / file, rename, delete (recursive), change permissions (chmod). - Upload, download, and remote→remote send — files **and** whole folders — with a transfer queue, per-job progress, and cancel. @@ -178,14 +180,102 @@ Saved connections live in a plain JSON file in the OS app-data directory ## Keyboard shortcuts -`Tab` switch panel · `↑`/`↓`/`PgUp`/`PgDn`/`Home`/`End` move cursor · -`Space` select · `Enter` open folder · `Backspace` parent · -`Ctrl/Cmd+A` select all · `F5` send to other panel · `F2` rename · -`F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab · -`Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab · `Cmd/Ctrl+,` settings · -`Cmd/Ctrl+=`/`-`/`0` interface bigger / smaller / 100%. +The whole app works from the keyboard, and **every command can be remapped** in +**Settings → Keyboard**: press **+** on a row and type the keys, see conflicts, +reset one row or all. Remaps apply live in every window, the right-click menus' +hints and the toolbar tooltips follow, and so does the macOS menu bar. The +defaults: -(On Macs, function keys may need `fn` — every action also has a toolbar button.) +| Command | macOS | Windows / Linux | +| --- | --- | --- | +| **File list** | | | +| Move up | `↑` | `↑` | +| Move down | `↓` | `↓` | +| Page up | `PgUp` | `PgUp` | +| Page down | `PgDn` | `PgDn` | +| First item | `Home` | `Home` | +| Last item | `End` | `End` | +| Select / deselect | `Space` | `Space` `Ins` | +| Select all | `⌘A` | `Ctrl+A` | +| Open | `↩` `⌘↓` | `Enter` | +| Parent folder | `⌫` `⌘↑` | `Backspace` `Alt+↑` | +| Switch panel | `⇥` `⇧⇥` | `Tab` `Shift+Tab` | +| Show the item's menu | `⇧F10` `⌃↩` | `Shift+F10` `Menu` | +| **Files** | | | +| Send to the other panel | `F5` | `F5` | +| Edit | `⌘E` `F4` | `F4` `Ctrl+E` | +| Rename | `F2` | `F2` | +| New folder | `⇧⌘N` `F7` | `F7` `Ctrl+Shift+N` | +| New file | `⌘N` `⇧F4` | `Shift+F4` `Ctrl+N` | +| Permissions | `⌘I` | `Ctrl+I` | +| Delete | `F8` `⌦` | `F8` `Del` | +| Move to Trash | `⌘⌫` | `Ctrl+Del` | +| Copy path | `⌥⌘C` | `Ctrl+Shift+C` | +| Reveal in file manager | `⌥⌘R` | `Alt+Shift+R` | +| Open in default app | `⌘O` | `Ctrl+O` | +| Refresh | `⌘R` | `Ctrl+R` | +| Go to path | `⌘L` | `Ctrl+L` | +| Choose what the panel shows | `⌘K` | `Ctrl+K` | +| **Sorting** | | | +| Sort by name | `⌥1` | `Alt+1` | +| Sort by size | `⌥2` | `Alt+2` | +| Sort by modified | `⌥3` | `Alt+3` | +| Sort by kind | `⌥4` | `Alt+4` | +| Sort by rights | `⌥5` | `Alt+5` | +| Sort by owner | `⌥6` | `Alt+6` | +| Sort by group | `⌥7` | `Alt+7` | +| **Tabs** | | | +| New tab | `⌘T` | `Ctrl+T` | +| Close tab | `⌘W` | `Ctrl+W` | +| Close other tabs | `⌥⌘W` | `Ctrl+Shift+W` | +| Close tabs to the right | — | — | +| Next tab | `⌃⇥` `⇧⌘]` | `Ctrl+Tab` `Ctrl+PgDn` | +| Previous tab | `⌃⇧⇥` `⇧⌘[` | `Ctrl+Shift+Tab` `Ctrl+PgUp` | +| Go to tab 1 | `⌘1` | `Ctrl+1` | +| Go to tab 2 | `⌘2` | `Ctrl+2` | +| Go to tab 3 | `⌘3` | `Ctrl+3` | +| Go to tab 4 | `⌘4` | `Ctrl+4` | +| Go to tab 5 | `⌘5` | `Ctrl+5` | +| Go to tab 6 | `⌘6` | `Ctrl+6` | +| Go to tab 7 | `⌘7` | `Ctrl+7` | +| Go to tab 8 | `⌘8` | `Ctrl+8` | +| Go to last tab | `⌘9` | `Ctrl+9` | +| Move tab left | `⌃⇧PgUp` | `Ctrl+Shift+PgUp` | +| Move tab right | `⌃⇧PgDn` | `Ctrl+Shift+PgDn` | +| Disconnect tab | `⌘D` | `Ctrl+D` | +| **Window** | | | +| Settings | `⌘,` | `Ctrl+,` | +| Connection manager | `⇧⌘K` | `Ctrl+Shift+K` | +| Show / hide transfers | `⌘J` | `Ctrl+J` | +| Install update | — | — | +| Bigger interface | `⌘=` `⇧⌘=` `⌘Num +` | `Ctrl+=` `Ctrl+Shift+=` `Ctrl+Num +` | +| Smaller interface | `⌘-` | `Ctrl+-` | +| Interface at 100% | `⌘0` | `Ctrl+0` | +| Close window | `⌘W` | `Ctrl+W` | +| **Editor** | | | +| Save | `⌘S` | `Ctrl+S` | +| Find | `⌘F` | `Ctrl+F` | +| Find next | `⌘G` | `F3` | +| Find previous | `⇧⌘G` | `Shift+F3` | +| Wrap lines | `⌥Z` | `Alt+Z` | +| **Connection manager** | | | +| New connection | `⌘N` | `Ctrl+N` | +| New stack | `⇧⌘N` | `Ctrl+Shift+N` | +| Save connection | `⌘S` | `Ctrl+S` | +| Connect / open stack | `⌘↩` | `Ctrl+Enter` | +| Delete connection or stack | `⌘⌫` | `Del` | +| Rename stack | `F2` | `F2` | +| Import from WinSCP | `⌘O` | `Ctrl+O` | + +**Fixed keys** (not remappable): Esc closes menus, dialogs and the find bar — +in the file list it clears the filter, then cancels a connect, then clears the +selection. Enter / Esc confirm / cancel dialogs and Tab walks their controls +(on macOS too, without the system's Keyboard navigation setting). Typing in the +file list filters it (Backspace trims); Shift + any movement key selects the +rows the cursor passes. ↑ ↓ Home End Enter work in every menu. + +(On Macs, function keys may need `fn` — every F-key command also has a ⌘ key +or a toolbar button.) ## Same-file protection diff --git a/docs/architecture.md b/docs/architecture.md index 9357b38..8d20a9c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,10 +131,27 @@ main) and, on macOS, the app menu's "Settings…". Its RPC serves only `getSettings`/`saveSettings`/`pickEditorApp`/`showAbout`/`settingsClose`. Its page claims its own close-window key (`settingsClose`); a ⌘W it doesn't claim falls through to the app menu's Close Tab, and bun checks the Settings -window's focus FIRST and closes it (see gotchas). Sidebar sections: Appearance, -Transfers, Editor, File list (visible columns — the column-header right-click -menu toggles the same setting), About (opens the native About box). The -native title is `options.title`, retitled on a locale switch. +window's focus FIRST and closes it (see gotchas). Sidebar sections (↑ / ↓ move +between them, focus starts on the current one, Tab is walked by `cycleFocus`): +Appearance, Transfers, Editor, File list (visible columns — the column-header +right-click menu toggles the same setting — and "Enter / double-click on a +file": nothing | open local files in their default app), Keyboard, About +(opens the native About box). The native title is `options.title`, retitled on +a locale switch. + +**Keyboard** is the remap editor over the shared keymap: rows by group with a +search field (label or key text), a chip per combo (× removes), **+** records +(the next non-modifier keydown, taken before every other key in the window so +⌘W records instead of closing; Esc cancels; a click elsewhere or a blur ends +it), ↺ resets a row, Reset all (inline confirm). Reserved combos are refused +without `preventDefault` (⌘Q still quits); editor-scope combos must be chords +or F-keys (the editor's text area always has focus); a combo owned by an +overlapping-scope command offers **Replace** (moves it). Rows flag conflicts +and plain character keys on file-list rows (they no longer start a filter). +Fixed keys are listed read-only below. Every edit is `ShortcutsService.rebind` +→ `settings.keybindings` (only rows that differ from the defaults) → the +ordinary save + broadcast, so menus, tooltips and the macOS menu bar follow +live. ## Bun modules @@ -185,9 +202,16 @@ native title is `options.title`, retitled on a locale switch. atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults spread-merge on load, so NEW SETTINGS KEYS need no backend change. - `menu.ts` — macOS-only app menu, rebuilt on every connections change (the - Connect submenu mirrors the store; "Settings…" carries ⌘,). ⌘W is intercepted - by the menu, so bun routes `close-tab` to a focused Settings or editor window - first. Labels go through + Connect submenu mirrors the store), language switch and keyboard remap. + **Accelerators come from the shared keymap** (`effectiveKeymap` + + `toAccelerator`, the first combo the native parser can express faithfully: + ⌘/⌃ chords only — a bare key means ⌘+key to it — no ⌫ / ⌦ / Shift+punctuation); + ⌘Q stays fixed. The keybindings are read through a getter AFTER the + connections load, so a remap saved meanwhile isn't lost. The page claims the + keys it handles first, so the menu only runs what falls through; a + `close-tab` that still arrives while a Settings or editor window is focused + closes THAT window, and other main-targeted actions bring the main window + forward first. Labels go through backend `i18n.t` and the menu is rebuilt on a locale change too. - `i18n.ts` — backend translator (native menus + thrown error messages, which no Angular service can reach). `setLocale(setting)` caches the resolved @@ -402,6 +426,10 @@ ends with `blockReloadKeys`. Keys that land on `
` (a click dropped focus) are forwarded to the active list (`PanelsService.forwardListKey`), and `App`'s `(mousedown)` keeps focus on the list after chrome-button clicks. +**Remaps** live in `AppSettings.keybindings` (sanitized on load), edited in +Settings → Keyboard (see the Settings window). `ShortcutsService.rebind` +stores only rows that differ from their defaults. + **macOS key equivalents are page-first.** WKWebView hands ⌘-keys to the page before the app menu (WebKit `WebViewImpl::performKeyEquivalent`); only keys the page doesn't `preventDefault` reach the menu bar. So the page owns every app diff --git a/docs/gotchas.md b/docs/gotchas.md index 58d6736..fdb57a7 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -311,6 +311,14 @@ touching the adjacent code. path bar) hides keys from it — don't add one. - **Hold-to-repeat** fired ⌘W once per repeat — every tab closed. `claim()` repeats only movement-like commands (`isRepeatable`). +- **Electrobun's native menu accelerator turns a combo with no modifier token + into ⌘+key** ("f7" shows and fires ⌘F7), its `backspace` / `delete` tokens + are characters AppKit matches unreliably, and AppKit matches Shift+punctuation + by the shifted character ("}" for ⇧]). `toAccelerator` returns null for all of + those; the menu item then shows no key and the page still handles it. +- **Recording a key must take the keydown before every window handler** — and + must NOT take reserved ones: preventDefault on ⌘Q while recording would make + the app unquittable from the keyboard. - **Focus after a state change needs `afterNextRender({ injector })`.** Under zoneless CD `queueMicrotask` runs before the render that creates an `@if` element (the editor's find box and textarea), so `.focus()` hit nothing. diff --git a/docs/platform-notes.md b/docs/platform-notes.md index 9973864..16faa54 100644 --- a/docs/platform-notes.md +++ b/docs/platform-notes.md @@ -23,7 +23,7 @@ Every platform seam carries a `PORTABILITY:` comment at the branch site. | Tab focus | `core/focus-cycle.ts cycleFocus` | WKWebView skips buttons/checkboxes without Keyboard navigation → walked by hand | native order (same walk) | native order (same walk) | | Reload keys | `platform.ts blockReloadKeys` | — | WebView2 reloads on F5 / Ctrl+R → swallowed when unclaimed | — | | Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored (needs AppIndicator, see below) | -| App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — Settings opens from the tab-bar gear (Ctrl+,), About from its About section | none (unsupported upstream) | +| App menu | `src/bun/menu.ts` | full menu incl. About; accelerators follow the keymap (remaps included), ⌘Q fixed | none (early return) — Settings opens from the tab-bar gear (Ctrl+,), About from its About section | none (unsupported upstream) | | connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 | | App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config | | ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK | diff --git a/src/bun/index.ts b/src/bun/index.ts index 1bf774c..3c3f865 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -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 }); }); diff --git a/src/bun/menu.ts b/src/bun/menu.ts index 84f22d6..f4b72ee 100644 --- a/src/bun/menu.ts +++ b/src/bun/menu.ts @@ -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{{ i18n.t('options.openFiles.hint') }}
+ } + + @case ('keyboard') { + +{{ i18n.t('keys.ui.intro') }}
+ + @for (group of keyGroups(); track group.id) { ++ {{ i18n.t('keys.ui.plainLetter', { key }) }} +
+ } + } +{{ i18n.t('keys.ui.noResults') }}
+ } + +