Put every shortcut on one shared keymap and make the app keyboard-reachable

The keymap was scattered over ShortcutsService if-chains, the file panel's
key switch, macOS-only menu accelerators and hand-typed hints in 16
tooltips x 7 locales, and large parts of the app had no keyboard path at
all. src/shared/keymap.ts is now the one table: scopes, per-platform
defaults, exact matching, formatting with localized key names, conflicts,
reserved combos and native-menu accelerators, proven by keymap.spec.ts.

- ShortcutsService resolves against the effective keymap: claim() takes
  the key (preventDefault, held keys repeat only movement, never mid-IME),
  and from a text field only chords and F-keys fire. The file panel, App,
  editor, manager and Settings windows all dispatch through it.
- FileActionsService.run is the one file-command dispatcher for the list
  keys, the row menu, the macOS menu bar and the toolbar.
- New keys: the row menu (Shift+F10 / Menu / Ctrl+Return), the source
  picker (Mod+K), the path bar (Mod+L), transfers (Mod+J), sorting
  (Alt+1-7), Shift+movement selection, close other tabs, move and
  disconnect tabs, refresh and edit on Windows/Linux, permissions, copy
  path, reveal, trash, open in default app, editor find next/previous.
- Right-click menus: soft shortcut hints, icons on the everyday verbs
  (the toolbar's glyphs), section headers, scrolling, item keys while
  open, a highlighted first item when opened from the keyboard. The panel
  source picker now renders through the same menu.
- Dialogs: role=dialog, a Tab trap via cycleFocus (WKWebView skips
  buttons on Tab without Keyboard navigation), initial focus, Esc that
  stops propagating; the chmod dialog takes focus and applies on Enter.
- Fixed: Ctrl+Tab also flipped the active panel; Esc in a manager dialog
  closed the manager; Ctrl-F/T fired Cmd shortcuts on macOS; shortcuts
  fired under dialogs; keys died after any button click on macOS; the
  editor's save prompt and find box never got focus; WebView2 reload keys;
  a window could save default settings before its first load.
- Settings closes on its own close-window key (new settingsClose request).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-10-05 14:48:11 +02:00
co-authored by Claude
parent 0eb5608873
commit d361297690
44 changed files with 2531 additions and 700 deletions
+54 -5
View File
@@ -136,11 +136,18 @@ touching the adjacent code.
update-initiated) — a plain `Utils.quit()` there quits without installing.
`Utils.quit()` runs the same approval, which is why the guard checks
`quitConfirmed` first.
- **⌘W in any secondary window arrives as the MAIN window's `close-tab`
menu action** (macOS: the app menu intercepts it before the webview sees
the key). bun must check the focused secondary window first — the Settings
window, then editors — and close THAT; a new window kind that skips this
check makes ⌘W close a main-window tab behind it.
- **macOS ⌘-keys go to the PAGE first; only unclaimed ones reach the app
menu.** WKWebView's `performKeyEquivalent` hands key equivalents to the page
while the webview is first responder (WebKit `WebViewImpl.mm`: "lets
webpages have a crack at intercepting key-modified keypresses"); a key the
page `preventDefault`s never reaches the menu. Old comments here claimed the
opposite ("the menu intercepts ⌘W first") — what was really happening is that
windows which didn't claim ⌘W let it fall through to the menu's Close Tab.
Two consequences: a handled shortcut MUST `preventDefault` (`claim()` does),
or the menu runs it a second time; and a ⌘W a secondary window doesn't claim
still arrives as the MAIN window's `close-tab` action — bun checks the
focused Settings window, then editors, first and closes THAT, so a new
window kind must either claim close-window or join that check.
- **One rpc instance per window** — BrowserView binding steals the rpc's
transport; a shared instance silently sends to the wrong webview.
- **RPC before socket open was silently lost on 1.18.1.** 2.x Electroview queues
@@ -270,6 +277,44 @@ touching the adjacent code.
`libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib`
(upstream electrobun's choice, not ours).
## Keyboard (`src/shared/keymap.ts`, `ui/src/app/core/shortcuts.service.ts`)
- **WKWebView skips buttons, checkboxes and radios on Tab** unless the user
turned on System Settings → Keyboard → Keyboard navigation (or presses ⌥Tab).
Dialogs, Settings and the manager were mouse-only on a default Mac. Tab is
walked by hand: `cycleFocus(root, event)` (core/focus-cycle.ts) — use it in
any new dialog or window.
- **WebKit doesn't focus a clicked button — focus drops to `<body>`** (Windows
parks it on the button, where Space clicks it again). Every list key died
until the list was clicked. `App` `(mousedown)` keeps focus on chrome-button
clicks and forwards `<body>` keys to the active list; keep both.
- **Match modifiers exactly.** The old panel switch caught every Tab, so
Ctrl+Tab flipped the active panel AND switched tabs; `isMod = metaKey ||
ctrlKey` made ⌃F (Cocoa's forward-one-character) open the editor's find.
`matchesEvent` compares all four modifiers, platform-exact.
- **AltGr is Ctrl+Alt on Windows/Linux.** A Ctrl+Alt+letter binding steals
"@", "€", "ł"… — never a default there, never fired from a text field, and
an event with `getModifierState('AltGraph')` matches nothing.
- **Type-to-filter takes every printable key.** A plain-letter binding (vim
j/k) must resolve BEFORE it — and then that letter no longer starts a query
(Settings warns). While a query is being typed, printable keys (Space too)
always extend it.
- **A dialog's Esc must `stopPropagation`.** The connection manager closes on
a window-level Escape; the dialog cleared its own signal first, so the
window handler saw "no dialog open" and closed the whole window.
- **An unclaimed key behind a dialog reaches the macOS menu** — ⌘T opened a
tab behind the rename prompt. The main window swallows window commands and
the menu bar's panel commands while a dialog or menu is up.
- **WebView2 reloads the page on unclaimed F5 / Ctrl+R** (default browser
accelerators), dropping every session and the editor's unsaved text. Every
window root ends with `blockReloadKeys`; a blanket `stopPropagation` (the old
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`).
- **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.
## Domain logic
- **sessionId ≠ filesystem identity.** Two panels on the same server are two
@@ -287,6 +332,10 @@ touching the adjacent code.
- Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so
new keys auto-default, but the UI SettingsService reads field-by-field —
a new setting silently stays at default until wired in all five spots.
- **Saves are whole-object: a window must not save before its first
`getSettings` landed.** A ⌘+ pressed while an editor opened wrote
`DEFAULT_SETTINGS` (no keyboard remaps) over the user's file.
`SettingsService.persist()` is a no-op until `applyAll` ran once.
- **Every window's RPC must serve `saveSettings`.** The scale shortcuts
shipped in editor and manager windows while only the main RPC served it:
the request went unanswered, the change applied locally, and main's next