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 { +export async function applyApplicationMenu( + store: ConnectionStore, + keybindings: () => Record, +): Promise { 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 { - const keys = en.keys as Record; - for (const b of BINDINGS) expect(typeof keys["n" in b ? "go-to-tab" : b.id]).toBe("string"); - for (const g of GROUPS) expect(typeof en.keys.groups[g]).toBe("string"); +test("every command and group is labelled — in every shipped language", () => { + for (const [locale, dict] of Object.entries(DICTS)) { + const keys = dict.keys as Record & { groups: Record }; + for (const b of BINDINGS) { + expect(`${locale}:${b.id}:${typeof keys["n" in b ? "go-to-tab" : b.id]}`).toBe(`${locale}:${b.id}:string`); + } + for (const g of GROUPS) expect(`${locale}:${g}:${typeof keys.groups[g]}`).toBe(`${locale}:${g}:string`); + } + expect(typeof en.keys.ui.press).toBe("string"); }); test("matching is exact about modifiers", () => { diff --git a/src/shared/keymap.ts b/src/shared/keymap.ts index 679f8fc..9cc7531 100644 --- a/src/shared/keymap.ts +++ b/src/shared/keymap.ts @@ -403,6 +403,12 @@ export function firesInTextField(c: Combo, isMac: boolean): boolean { return c.mod || c.alt; } +/** Whether a plain press of the combo types a character — bound in the file + * list, that key then no longer starts a type-to-filter query. */ +export function typesCharacter(c: Combo): boolean { + return !c.mod && !c.ctrl && !c.alt && (LETTER.test(c.key) || DIGIT.test(c.key) || Object.hasOwn(CHAR_OF, c.key)); +} + /** Commands a held key may repeat; any other held shortcut fires once * (holding ⌘W must not close every tab). */ const REPEATABLE = new Set([ diff --git a/ui/src/app/core/shortcuts.service.ts b/ui/src/app/core/shortcuts.service.ts index 9f623b8..cb36da9 100644 --- a/ui/src/app/core/shortcuts.service.ts +++ b/ui/src/app/core/shortcuts.service.ts @@ -117,11 +117,16 @@ export class ShortcutsService { return keys.length ? `${text} (${keys.join(' / ')})` : text; } - /** The same for a FIXED key — one no remap moves, such as Enter in the find - * box — so it reads in the platform's spelling too (⇧↩ / Shift+Enter). */ - tipFixed(text: string, combo: string): string { + /** A FIXED key — one no remap moves, such as Enter in the find box — in + * the platform's spelling (⇧↩ / Shift+Enter). */ + fixedKey(combo: string): string { const parsed = parseCombo(combo, IS_MAC); - return parsed ? `${text} (${this.format(parsed)})` : text; + return parsed ? this.format(parsed) : combo; + } + + /** tip() for a fixed key. */ + tipFixed(text: string, combo: string): string { + return `${text} (${this.fixedKey(combo)})`; } /** diff --git a/ui/src/app/skins/shadlike/settings-window/settings-window.html b/ui/src/app/skins/shadlike/settings-window/settings-window.html index 04be898..a420b00 100644 --- a/ui/src/app/skins/shadlike/settings-window/settings-window.html +++ b/ui/src/app/skins/shadlike/settings-window/settings-window.html @@ -15,10 +15,16 @@
-
+ + +
+ {{ i18n.t('options.openFiles.label') }} +
+ + +
+
+

{{ i18n.t('options.openFiles.hint') }}

+ } + + @case ('keyboard') { + +
+
+ + +
+ @if (confirmResetAll()) { + + + } @else { + + } +
+

{{ i18n.t('keys.ui.intro') }}

+ + @for (group of keyGroups(); track group.id) { +

+ {{ i18n.t('keys.groups.' + group.id) }} +

+
+ @for (binding of group.rows; track binding.id) { +
+ {{ rowLabel(binding) }} + @if (conflictWith(binding); as others) { + ! + } +
+ @for (combo of binding.combos; track $index) { + + {{ shortcuts.format(combo) }} + + + } + @if (capturing() === binding.id) { + + {{ i18n.t('keys.ui.press', { esc: fixedKeys().esc }) }} + + } @else if (!binding.combos.length) { + {{ i18n.t('keys.ui.unbound') }} + } +
+ + +
+ @if (note()?.id === binding.id) { +
+ {{ noteText(note()!) }} + @if (note()!.kind === 'conflict') { + + } + +
+ } + @if (filterKey(binding); as key) { +

+ {{ i18n.t('keys.ui.plainLetter', { key }) }} +

+ } + } +
+ } @empty { +

{{ i18n.t('keys.ui.noResults') }}

+ } + +

+ {{ i18n.t('keys.ui.fixed') }} +

+
    + @for (line of fixedLines; track line) { +
  • {{ i18n.t('keys.fixed.' + line, fixedKeys()) }}
  • + } +
} @case ('about') { diff --git a/ui/src/app/skins/shadlike/settings-window/settings-window.ts b/ui/src/app/skins/shadlike/settings-window/settings-window.ts index 1956281..31d8429 100644 --- a/ui/src/app/skins/shadlike/settings-window/settings-window.ts +++ b/ui/src/app/skins/shadlike/settings-window/settings-window.ts @@ -1,15 +1,29 @@ -import { Component, computed, inject, signal } from '@angular/core'; +import { Component, ElementRef, afterNextRender, computed, inject, signal, viewChild } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import { BRANDING } from '@shared/branding'; import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n'; +import { + type Combo, + comboFromEvent, + conflicts, + firesInTextField, + GROUPS, + isReserved, + ownerOf, + type ResolvedBinding, + sameCombo, + type ShortcutId, + typesCharacter, +} from '@shared/keymap'; import { COLUMN_KEYS } from '@core/context-menu.service'; +import { cycleFocus } from '@core/focus-cycle'; import { I18nService } from '@core/i18n.service'; -import { blockNativeContextMenu, blockReloadKeys, IS_MAC_CHROME } from '@core/platform'; +import { blockNativeContextMenu, blockReloadKeys, IS_MAC, IS_MAC_CHROME } from '@core/platform'; import { RpcService } from '@core/rpc.service'; import { SettingsService } from '@core/settings.service'; import { ShortcutsService } from '@core/shortcuts.service'; -const SECTIONS = ['appearance', 'transfers', 'editor', 'fileList', 'about'] as const; +const SECTIONS = ['appearance', 'transfers', 'editor', 'fileList', 'keyboard', 'about'] as const; type Section = (typeof SECTIONS)[number]; const THEMES = ['system', 'light', 'dark'] as const; @@ -21,10 +35,23 @@ const SPEED_UNITS = [ const TRANSFER_MODES = ['single', 'multi'] as const; const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; +/** The keys no remap moves, listed read-only under the editable ones. */ +const FIXED_LINES = ['escape', 'escList', 'dialogs', 'menus', 'filter', 'shift', 'find', 'sidebar'] as const; + +/** A recording that could not simply be added: explained under its row. */ +interface CaptureNote { + id: ShortcutId; + kind: 'reserved' | 'needsModifier' | 'conflict'; + combo: Combo; + /** conflict: the command that already owns the combo. */ + owner?: ShortcutId; +} + /** * Root of the Settings window (APP_MODE 'settings'). Every control is a plain * SettingsService setter: the save goes to bun, which rebroadcasts it, so the - * other windows follow live while this one stays open. + * other windows follow live while this one stays open — keyboard remaps + * included (menu hints, tooltips and the macOS menu bar follow at once). */ @Component({ selector: 'app-root', @@ -33,13 +60,15 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; host: { '(window:keydown)': 'onWindowKeydown($event)', '(window:contextmenu)': 'blockContextMenu($event)', + '(window:mousedown)': 'onWindowMousedown($event)', + '(window:blur)': 'capturing.set(null)', }, }) export class SettingsWindow { readonly settings = inject(SettingsService); readonly i18n = inject(I18nService); + readonly shortcuts = inject(ShortcutsService); private readonly rpc = inject(RpcService); - private readonly shortcuts = inject(ShortcutsService); readonly isMacChrome = IS_MAC_CHROME; readonly sections = SECTIONS; @@ -52,11 +81,50 @@ export class SettingsWindow { readonly transferModes = TRANSFER_MODES; readonly concurrencyChoices = CONCURRENCY_CHOICES; readonly columnKeys = COLUMN_KEYS; + readonly fixedLines = FIXED_LINES; readonly appName = BRANDING.appName; readonly buildLabel = BRANDING.buildLabel; /** "100%" for the stepper readout; the value itself lives in settings. */ readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`); + private readonly nav = viewChild.required>('nav'); + + // ---- Settings → Keyboard ---- + readonly keyFilter = signal(''); + /** The command being recorded, if any. */ + readonly capturing = signal(null); + readonly note = signal(null); + readonly confirmResetAll = signal(false); + readonly anyCustom = computed(() => this.shortcuts.keymap().some((b) => b.custom)); + private readonly conflictMap = computed(() => conflicts(this.shortcuts.keymap())); + + /** The editable rows by group, narrowed by the search field (label or keys). */ + readonly keyGroups = computed(() => { + const query = this.keyFilter().trim().toLowerCase(); + const keymap = this.shortcuts.keymap(); + return GROUPS.map((group) => ({ + id: group, + rows: keymap.filter((b) => b.group === group && (!query || this.rowText(b).includes(query))), + })).filter((group) => group.rows.length); + }); + + /** The fixed keys, in this platform's spelling, for the read-only lines. */ + readonly fixedKeys = computed(() => ({ + esc: this.shortcuts.fixedKey('Escape'), + enter: this.shortcuts.fixedKey('Enter'), + shiftEnter: this.shortcuts.fixedKey('Shift+Enter'), + tab: this.shortcuts.fixedKey('Tab'), + shiftTab: this.shortcuts.fixedKey('Shift+Tab'), + backspace: this.shortcuts.fixedKey('Backspace'), + home: this.shortcuts.fixedKey('Home'), + end: this.shortcuts.fixedKey('End'), + })); + + constructor() { + // the keyboard starts on the current section + afterNextRender(() => this.sectionButton(this.section())?.focus()); + } + /** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */ editorAppName(): string { const path = this.settings.editorApp(); @@ -74,16 +142,153 @@ export class SettingsWindow { void this.rpc.request.showAbout({}); } - /** This window's keys: close-window, and the any-window interface scale. */ + // ---- sections ---- + + /** ↑ / ↓ in the sidebar move between sections (and focus follows). */ + onNavKeydown(event: KeyboardEvent): void { + const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; + if (!step) return; + event.preventDefault(); + const next = SECTIONS[(SECTIONS.indexOf(this.section()) + step + SECTIONS.length) % SECTIONS.length]; + this.section.set(next); + this.sectionButton(next)?.focus(); + } + + private sectionButton(section: Section): HTMLElement | null { + return this.nav().nativeElement.querySelector(`[data-section="${section}"]`); + } + + // ---- keyboard remaps ---- + + rowLabel(binding: ResolvedBinding): string { + return binding.n + ? this.i18n.t('keys.go-to-tab', { n: binding.n }) + : this.i18n.t(`keys.${binding.id}`); + } + + private labelOf(id: ShortcutId): string { + const binding = this.shortcuts.binding(id); + return binding ? this.rowLabel(binding) : id; + } + + private rowText(binding: ResolvedBinding): string { + return [this.rowLabel(binding), ...binding.combos.map((c) => this.shortcuts.format(c))] + .join(' ') + .toLowerCase(); + } + + /** "Rename, Edit" when this row shares a key with others that can collide. */ + conflictWith(binding: ResolvedBinding): string { + return (this.conflictMap().get(binding.id) ?? []).map((id) => this.labelOf(id)).join(', '); + } + + /** A plain character key on a file-list row: typing it no longer filters. */ + filterKey(binding: ResolvedBinding): string { + if (!binding.scopes.some((s) => s === 'panel' || s === 'main')) return ''; + const plain = binding.combos.find(typesCharacter); + return plain ? this.shortcuts.format(plain) : ''; + } + + noteText(note: CaptureNote): string { + const key = this.shortcuts.format(note.combo); + if (note.kind === 'reserved') return this.i18n.t('keys.ui.reserved', { key }); + if (note.kind === 'needsModifier') return this.i18n.t('keys.ui.needsModifier', { key }); + return this.i18n.t('keys.ui.usedBy', { key, action: note.owner ? this.labelOf(note.owner) : '' }); + } + + startCapture(id: ShortcutId): void { + this.note.set(null); + this.capturing.set(this.capturing() === id ? null : id); + } + + /** + * One keydown while recording, taken before every other key in this window + * (⌘W records instead of closing). A bare modifier keeps waiting; Esc on its + * own cancels. Reserved combos are refused WITHOUT preventDefault, so ⌘Q + * still quits and ⌘C still copies. + */ + private record(event: KeyboardEvent, id: ShortcutId): void { + const combo = comboFromEvent(event, IS_MAC); + if (combo && isReserved(combo, IS_MAC)) { + this.capturing.set(null); + this.note.set({ id, kind: 'reserved', combo }); + return; + } + event.preventDefault(); + event.stopPropagation(); + if (event.repeat || !combo) return; + const bare = !combo.mod && !combo.ctrl && !combo.alt && !combo.shift; + this.capturing.set(null); + if (bare && combo.key === 'Escape') return; + const binding = this.shortcuts.binding(id); + if (!binding || binding.combos.some((c) => sameCombo(c, combo))) return; + // the editor's text area always has focus: a plain key could never fire there + if (binding.scopes.includes('editor') && !firesInTextField(combo, IS_MAC)) { + this.note.set({ id, kind: 'needsModifier', combo }); + return; + } + const owner = ownerOf(this.shortcuts.keymap(), combo, binding.scopes, id); + if (owner) { + this.note.set({ id, kind: 'conflict', combo, owner: owner.id }); + return; + } + this.shortcuts.rebind({ [id]: [...binding.combos, combo] }); + } + + /** "Replace": the key moves from the command that had it to this one. */ + replaceConflict(): void { + const note = this.note(); + const binding = note && this.shortcuts.binding(note.id); + const owner = note?.owner && this.shortcuts.binding(note.owner); + if (!note || !binding || !owner) return; + this.shortcuts.rebind({ + [note.id]: [...binding.combos, note.combo], + [owner.id]: owner.combos.filter((c) => !sameCombo(c, note.combo)), + }); + this.note.set(null); + } + + removeCombo(binding: ResolvedBinding, index: number): void { + this.shortcuts.rebind({ [binding.id]: binding.combos.filter((_, i) => i !== index) }); + } + + resetRow(binding: ResolvedBinding): void { + this.shortcuts.rebind({ [binding.id]: binding.defaults }); + if (this.note()?.id === binding.id) this.note.set(null); + } + + resetAll(): void { + this.shortcuts.resetAll(); + this.confirmResetAll.set(false); + this.note.set(null); + } + + // ---- window ---- + + /** Recording first; then this window's keys (close-window, the scale) and + * Tab, walked by hand — WKWebView skips buttons on Tab otherwise. */ onWindowKeydown(event: KeyboardEvent): void { + const recording = this.capturing(); + if (recording) { + this.record(event, recording); + return; + } const id = this.shortcuts.claim(event, 'settings'); if (id === 'close-window') void this.rpc.request.settingsClose({}); else if (id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1); else if (id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1); else if (id === 'scale-reset') this.settings.setUiScale(1); + else if (!id) cycleFocus(document.body, event); blockReloadKeys(event); } + /** A click anywhere but a record button ends a recording. */ + onWindowMousedown(event: MouseEvent): void { + if (this.capturing() && !(event.target as Element).closest('[data-record]')) { + this.capturing.set(null); + } + } + blockContextMenu(event: Event): void { blockNativeContextMenu(event); }