diff --git a/docs/architecture.md b/docs/architecture.md index 9e74d4d..9357b38 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,9 +78,12 @@ Re-opening focuses the existing window. The webview fetches its own content **Close safety** (designed when electrobun window `close` was NOT cancellable — 1.18.1 had no `allow` response like `before-quit`; 2.x adds a cancellable `will-close` for the titlebar close, not used yet — see gotchas): -1. ⌘W (routed via the app menu → `editorCloseRequest`) / Close button → - styled in-window Save/Don't Save/Cancel; the confirm path calls - `editorClose` which sets `suppressPrompt` before `win.close()`. +1. ⌘W (the page's own `close-window` key; a ⌘W it doesn't claim falls through + to the app menu, which bun routes back as `editorCloseRequest`) / Close + button → styled in-window Save/Don't Save/Cancel, which takes focus (Save; + ⌘S = Save, Esc = Cancel, Tab cycles) so keys can't edit the text behind it; + the confirm path calls `editorClose` which sets `suppressPrompt` before + `win.close()`. 2. Traffic-light close → the window is already gone, but the editor mirrored its draft to bun (`editorState`, 400 ms debounce + flush on blur/dirty-flip) → native Save/Discard box from the held draft. A failed save (dead session) @@ -96,7 +99,11 @@ to the main window (panels showing that directory refresh). Editor upgrades: scroll-synced line gutter (hidden when wrap is on — wrapped line numbers would need per-edit height measurement), Alt/⌥Z wrap toggle, custom find bar (matches computed in TS, `setSelectionRange` + scrollTop math — deliberately -not `findInPage`, which is a shim on WebView2). +not `findInPage`, which is a shim on WebView2). Find next / previous (⌘G / +⇧⌘G, F3 / ⇧F3) step from the caret with the bar open or closed — the query +survives closing it. Focus after a state change goes through +`afterNextRender({ injector })`: under zoneless CD a microtask runs BEFORE the +render that creates the `@if` element. ### Connection-manager window (`src/bun/manager-window.ts`) @@ -119,11 +126,12 @@ would float over editor windows). Single instance, centered over main, **not modal**: every control is a plain `SettingsService` setter, so each edit is an ordinary save that bun rebroadcasts — the other windows follow live while it stays open. Opened by -the tab-bar gear and Ctrl+, (`openSettings` from main) and, on macOS, the app -menu's "Settings…" (⌘,, which the menu intercepts first). Its RPC serves only -`getSettings`/`saveSettings`/`pickEditorApp`/`showAbout`. ⌘W reaches bun as -the main window's `close-tab` menu action, so bun checks the Settings window's -focus FIRST and closes it (see gotchas). Sidebar sections: Appearance, +the tab-bar gear and the open-settings key (⌘, / Ctrl+,, `openSettings` from +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. @@ -251,11 +259,22 @@ component; tokens → `ui/src/styles.css`. save too — the scale shortcuts — and the Settings window is nothing but saves); bun's `settingsSaved()` in index.ts runs the side effects once and rebroadcasts to every window **except the sender**, so - there is no echo to fight a held Cmd+. `applyAll` never persists. New setting - = five touches here + types.ts (backend merges automatically). + there is no echo to fight a held Cmd+. `applyAll` never persists. Saves are + whole-object, so **nothing persists until the first `getSettings` landed** + (`loaded`): a ⌘+ pressed while a window opens would otherwise write the + defaults over every remap. New setting = five touches here + types.ts + (backend merges automatically; sanitize hand-editable shapes in + `SettingsStore.load`). - `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the - main window's panels listen. Exists so dialogs don't inject PanelsService - (whose constructor spawns a workspace — fatal in secondary windows). + main window's panels listen — but a panel only takes focus while NO dialog or + menu is up (a run of overwrite questions reuses one dialog instance; focusing + the list behind it handed Enter/Delete to the panel). Exists so dialogs don't + inject PanelsService (whose constructor spawns a workspace — fatal in + secondary windows). +- `shortcuts.service.ts` — the keyboard layer over `src/shared/keymap.ts`; see + "Keyboard" below. +- `focus-cycle.ts` — `cycleFocus(root, event)`: Tab / ⇧Tab by hand (see + "Keyboard"). - `connections.service.ts` — store mirror + ssh-config hosts; reloads on the `connectionsChanged` push. - `i18n.service.ts` — runtime translator. `locale` signal + `t('ns.key', params)` @@ -267,20 +286,42 @@ component; tokens → `ui/src/styles.css`. /`speed()` so the file list reformats on switch. `SettingsService.applyLocale` resolves the `locale` setting (`system` → `navigator.language`) into it, on boot and on the `settingsChanged` broadcast (so editor/manager windows follow). -- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always - against an explicit panel; `menuTarget` remembers the right-clicked row. +- `file-actions.service.ts` — **one file-command dispatcher**, + `run(id, { side, panel, entry })`, for every entry point: list keys (the + cursor row), row-menu picks (the right-clicked row, captured in the menu's + `onPick` closure), the macOS menu bar's File/View items and the panel + toolbar. Ids are keymap `FileCommand`s (`open`, `open-default`, `send`, + `edit`, `rename`, `new-folder`, `new-file`, `chmod`, `delete`, `trash`, + `copy-path`, `reveal`, `refresh`, `sort-*`). Selection if any, else the + target row; `..` stands for the folder. `open` enters folders and opens a + LOCAL file in its default app only when `openFilesOnEnter` is set (Settings + → File list); `open-default` (the menu's Open on a local file) always does. + `trash` walks the selection one `moveToTrash` at a time. Main-window only + (it injects TransfersService). - `context-menu.service.ts` + `skins/shadlike/context-menu.ts` — the panel and tab right-click menus, in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was retired on ALL platforms rather than kept as a second code path (see platform-notes.md). The service builds the item list (row / empty-space menu, - and the header column picker) and holds the open request as a signal; the - component renders it at the pointer with edge-flip clamping, arrow/Enter/Escape - keys, and close-on-blur/resize/scroll. Picked items are emitted verbatim as - `ctx-*` / `col:*` / `tab:*` strings through `RpcService.emitMenuAction`, so they - land in the SAME `App.handleMenuAction` dispatcher the macOS app menubar uses — - one handler, two sources. The three shell actions the webview cannot perform - (reveal, open-with-default, copy path) go back to bun via the `shellItemAction` - RPC, which is all that remains of the old native menu. + the header column picker, and the panel **source picker** — Local, drives, + connections under per-stack `header` items, `~/.ssh/config` hosts — which + replaced a bespoke popover so it gets the same keys and look) and holds the + open request as a signal; the component renders it at a point + (`MenuPoint`: a MouseEvent, or a row rect for a keyboard-opened menu) with + edge-flip clamping, a scrolling clamped box, and close-on-blur/resize/scroll. + Items carry `shortcut` (a keymap id: its first key is the soft hint at 60% + of the row colour, and pressing it while the menu is open picks the item) + and `icon` (drawn in the check column; only the everyday verbs get one — + Send, Edit, Rename, Delete, New Folder, New/Close Tab, the toolbar's own + glyphs). Keys: ↑↓ Home End move the one highlight (mouse hover shares it), + Enter/Space pick, Esc closes, items are not Tab stops; every handled key + stops propagation so the window handler never runs it twice. A keyboard-opened + menu starts with its first item highlighted (`initialIndex`). A request's + `onPick` takes the pick (row menu → `FileActionsService.run`, source picker → + `FilePanel`); otherwise `col:*` / `tab:*` strings go through + `RpcService.emitMenuAction` to the SAME `App.handleMenuAction` dispatcher the + macOS app menubar uses. The three shell actions the webview cannot perform + (reveal, open-with-default, copy path) go back to bun via the + `shellItemAction` RPC, which is all that remains of the old native menu. `tabItems()` builds the workspace-tab menu (`tab:new`, and `tab:close|close-others|close-right|disconnect:`), served by `PanelsService.closeOtherWorkspaces` / `closeWorkspacesToRight` / @@ -298,9 +339,12 @@ component; tokens → `ui/src/styles.css`. the build never checks them: an unregistered name renders nothing (console error only), so every name used anywhere must be listed here. - `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`, - `EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`, - `MOD_LABEL`/`ALT_LABEL`) + `blockNativeContextMenu` (kills WebKit's - Reload/Inspect menu outside text fields — the app draws its own menu instead). + `EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`) + + `blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside text + fields — the app draws its own menu instead) + `blockReloadKeys` (swallows + unclaimed F5 / Ctrl+R / Ctrl+F5 / Ctrl+Shift+R on Windows, where WebView2 + would reload the page and drop every session; each window root calls it + last). - `updater.service.ts` — auto-updater state for the tab-bar pill. Signals `state` (`idle→available→downloading→ready`|`error`), `version`, `progress`. Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on @@ -310,6 +354,75 @@ component; tokens → `ui/src/styles.css`. `src/bun/index.ts`; feed is `release.baseUrl` (`https://miniscp.com/dl`, the `/dl` host in `site/`). See build-and-release.md "Auto-updater". +## Keyboard + +**One keymap** — `src/shared/keymap.ts`, shared so the webviews and bun (the +macOS menu bar) read the same table. `BINDINGS` lists every command: `id`, +`group` (Settings sections), `scopes`, and default combos per platform +(`mac` / `other`, best first: the first is the hint menus and tooltips show). +Combos are platform-neutral strings (`Mod+Shift+N`: Mod = ⌘ on macOS, Ctrl +elsewhere; `Ctrl` a literal ⌃ only macOS tells apart). Letters match the +layout's character (AZERTY's A is A) and fall back to the physical key under +Alt (⌥Z types Ω) and on non-Latin layouts; digits match physically (AZERTY +types "&" on 1); punctuation by character first (AZERTY's comma is on the M +key), physical second (⇧] makes "}"). **Matching is exact**: ⌃F is not ⌘F, +Ctrl+Tab is not Tab, and neither the Windows key nor AltGr is ever part of a +binding. `effectiveKeymap(overrides)` applies `AppSettings.keybindings` +(rows that differ from the defaults; `[]` unbinds) and drops anything +malformed. `conflicts()` / `ownerOf()` work over overlapping scopes; the +spec (`keymap.spec.ts`) proves the defaults conflict-free, round-trippable and +unshadowed on both platforms. + +**Scopes.** `panel` = the focused file list (navigation, file commands, +sorting); `main` = anywhere in the main window (tabs, window commands); `editor` +/ `manager` / `settings` per window; `any` = every window (interface scale). +`panel` sits inside `main`, so they conflict-check together. + +**`ShortcutsService`** resolves keydowns against the effective keymap +(reactive: a remap applies live in every window via the settings broadcast) and +formats hints — `label(id)` / `labels(id)` / `tip(text, id)` / +`tipFixed(text, 'Shift+Enter')`, with **localized key names** off macOS +(`keys.names.*`: German says Strg+Entf). `claim()` = resolve + `preventDefault` +(so the macOS menu never runs it twice) + auto-repeat only for movement-like +commands (holding ⌘W closes one tab). IME composition is never a shortcut. +From a text field only `firesInTextField()` combos count: ⌘/Ctrl and Alt chords +and F-keys, but not plain keys, not text-editing chords (⌘⌫, ⌥←, ⌘A) and not +Ctrl+Alt off macOS (AltGr). + +**Dispatch** (one place per window): an open context menu owns its keys; an +open dialog owns Enter / Esc / Tab while the main window swallows window +commands and the panel commands the macOS menu bar carries (an unclaimed ⌘T +would otherwise reach the menu and open a tab behind the dialog); the focused +list (`FilePanel.onKeydown`) runs, in order, the fixed filter-editing keys +while a query is typed, the `panel` keymap (Shift + a movement key extends the +selection over the rows passed — a fixed rule that follows remaps), lets +`main` matches bubble, then type-to-filter, then Esc's fixed chain (clear the +filter → cancel a connect → clear the selection); `App` runs `main`. Every root +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. + +**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 +shortcut, and a window that doesn't claim a key lets it fall through to the +menu (⌘W in a window whose close-window was remapped away still hits Close Tab, +which bun routes to the focused Settings / editor window first). + +**Focus without Keyboard navigation.** WKWebView's Tab skips buttons, +checkboxes and radios unless the user turned on System Settings → Keyboard → +Keyboard navigation, so `cycleFocus(root, event)` walks Tab / ⇧Tab by hand +(DOM order, wrap, a radio group is one stop): every dialog (it is also their +focus trap), the transfer list (⌘J opens it focused), the editor's save prompt. +Dialogs carry `role="dialog" aria-modal`, take initial focus (the chmod dialog +on its octal field, Enter applies) and stop Esc from propagating — a +window-level Esc (the connection manager closes on it) must not see it too. A +base-layer `:focus-visible` ring in `--ring` marks the focused control. + +**Opening a row's menu from the keyboard**: ⇧F10 / the Menu key / ⌃↩ (macOS) +open the cursor row's menu under the row, first item highlighted; the +selection is kept, its commands act on it like the F-keys do. + ## Theming `ui/src/styles.css`: OKLCH tokens adapted from **Oxide's design system** diff --git a/docs/gotchas.md b/docs/gotchas.md index e5808ab..58d6736 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -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 ``** (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 `` 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 diff --git a/docs/platform-notes.md b/docs/platform-notes.md index dd9ca72..9973864 100644 --- a/docs/platform-notes.md +++ b/docs/platform-notes.md @@ -17,7 +17,11 @@ Every platform seam carries a `PORTABILITY:` comment at the branch site. | Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty | | chmod/Permissions | context-menu.service.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full | | Shell wording | `ui context-menu.service.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager | -| Shortcut labels | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ | +| Shortcut labels | `src/shared/keymap.ts formatCombo` (+ `keys.names.*`) | ⌘⇧⌥⌃ glyphs: ⇧⌘N | Ctrl+Shift+N, localized key names (Strg+Entf) | as Windows | +| Command modifier | `keymap.ts` `Mod` | ⌘ only (⌃ stays Cocoa's text keys) | Ctrl | Ctrl | +| Key equivalents | `shortcuts.service.ts claim` | page first, unclaimed → app menu | page only (no app menu) | page only | +| 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) | | connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 | diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts index dc8e862..1936255 100644 --- a/src/bun/rpc-handlers.ts +++ b/src/bun/rpc-handlers.ts @@ -467,6 +467,8 @@ export interface SettingsRpcHooks { /** Every edit here is a settings save — rebroadcast it to the other windows. */ onSettingsSaved: (next: AppSettings) => void; showAbout: () => void; + /** The window's own close-window key. */ + close: () => void; } /** RPC for the single Settings window (per-window instance). */ @@ -483,6 +485,11 @@ export function createSettingsRPC(deps: { settings: SettingsStore }, hooks: Sett hooks.showAbout(); return {}; }), + settingsClose: () => + wrap(async () => { + hooks.close(); + return {}; + }), }, }, }); diff --git a/src/bun/settings-window.ts b/src/bun/settings-window.ts index 511ac5b..fd15a34 100644 --- a/src/bun/settings-window.ts +++ b/src/bun/settings-window.ts @@ -23,9 +23,9 @@ export interface SettingsWindowDeps { /** * The single Settings window. Unlike the connection manager it is NOT modal: * every edit is an ordinary settings save, so the other windows follow live - * while it stays open. On macOS ⌘W reaches bun as the main window's close-tab - * menu action, so index.ts asks isFocused() first and closes this window - * instead of a main-window tab. + * while it stays open. Its page claims its own close-window key (settingsClose). + * A ⌘W it doesn't claim (remapped away) falls through to the app menu's Close + * Tab, which index.ts routes here while this window is focused. */ export class SettingsWindow { private win: BrowserWindow | null = null; @@ -49,6 +49,7 @@ export class SettingsWindow { { onSettingsSaved: (next) => this.deps.onSettingsSaved(next), showAbout: () => this.deps.showAbout(), + close: () => this.close(), }, ); diff --git a/src/bun/store/settings.ts b/src/bun/store/settings.ts index d7cefb5..4212183 100644 --- a/src/bun/store/settings.ts +++ b/src/bun/store/settings.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { sanitizeKeybindings } from "../../shared/keymap"; import { clampUiScale, DEFAULT_SETTINGS, type AppSettings } from "../../shared/types"; import { appDataDir } from "./paths"; @@ -24,6 +25,9 @@ export class SettingsStore { // into every window's root font-size, so a hand-edited settings.json // (uiScale: 100, "2", NaN) must not be able to render the UI unusable settings.uiScale = clampUiScale(Number(settings.uiScale)); + // same reasoning: every window resolves keys through this map + settings.keybindings = sanitizeKeybindings(data.keybindings); + settings.openFilesOnEnter = data.openFilesOnEnter === true; return settings; } catch { return { ...DEFAULT_SETTINGS }; diff --git a/src/shared/i18n/de.ts b/src/shared/i18n/de.ts index 30e3d8d..8edc14b 100644 --- a/src/shared/i18n/de.ts +++ b/src/shared/i18n/de.ts @@ -21,8 +21,8 @@ export const de = { tabBar: { connections: "Verbindungen", manageConnections: "Verbindungen verwalten", - newTab: "Neuer Tab ({mod}T)", - closeTab: "Tab schließen ({mod}W)", + newTab: "Neuer Tab", + closeTab: "Tab schließen", options: "Einstellungen", update: "Update verfügbar", updating: "Aktualisiere", @@ -75,17 +75,16 @@ export const de = { panel: { choosePanel: "Wählen, was dieses Panel anzeigt", copy: "Senden", - copyToRight: "An rechtes Panel senden (F5)", - copyToLeft: "An linkes Panel senden (F5)", + copyToRight: "An rechtes Panel senden", + copyToLeft: "An linkes Panel senden", edit: "Bearbeiten", - editFile: "Datei bearbeiten (F4)", - rename: "Umbenennen (F2)", - newFolder: "Neuer Ordner (F7)", + editFile: "Datei bearbeiten", + rename: "Umbenennen", + newFolder: "Neuer Ordner", newFile: "Neue Datei", permissions: "Berechtigungen", delete: "Löschen", - deleteHint: "Löschen (F8 / Entf)", - parentDir: "Übergeordneter Ordner (Rücktaste)", + parentDir: "Übergeordneter Ordner", refresh: "Aktualisieren", clearFilter: "Filter löschen (Esc)", sshConfig: "~/.ssh/config", @@ -144,16 +143,16 @@ export const de = { noFileTitle: "Keine Datei zum Bearbeiten", noFileBody: "Dieses Fenster wurde ohne Editor-Kontext geöffnet. Öffne Dateien aus der App.", wrap: "Umbruch", - wrapHint: "Lange Zeilen umbrechen — blendet Zeilennummern aus ({alt}Z)", - findHint: "Suchen ({mod}F)", + wrapHint: "Lange Zeilen umbrechen — blendet Zeilennummern aus", + findHint: "Suchen", findPlaceholder: "In Datei suchen", - prevMatch: "Vorheriger Treffer (⇧↵)", - nextMatch: "Nächster Treffer (↵)", + prevMatch: "Vorheriger Treffer", + nextMatch: "Nächster Treffer", closeHint: "Schließen (Esc)", disconnected: "Verbindung zu {name} getrennt — Speichern schlägt fehl, aber dein Text bleibt in diesem Fenster erhalten.", cantOpen: "{name} kann nicht geöffnet werden", closeWindow: "Fenster schließen", - shortcuts: "{mod}S speichern · {mod}F suchen · {mod}W schließen", + verbs: { save: "speichern", find: "suchen", close: "schließen" }, lines: { one: "{count} Zeile", other: "{count} Zeilen" }, close: "Schließen", save: "Speichern", @@ -209,6 +208,7 @@ export const de = { }, toasts: { movedToTrash: '„{name}" in den Papierkorb verschoben', + movedManyToTrash: { one: "{count} Objekt in den Papierkorb verschoben", other: "{count} Objekte in den Papierkorb verschoben" }, editingExternal: "Bearbeite {name} — Speichern lädt automatisch hoch", nothingToCopy: "Nichts zu kopieren (symbolische Links werden übersprungen)", nothingToCopyAllSkipped: "Nichts zu kopieren — alle Konflikte wurden übersprungen", @@ -292,6 +292,26 @@ export const de = { tabDisconnect: "Trennen", trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar", }, + keys: { + names: { + ctrl: "Strg", + alt: "Alt", + shift: "Umschalt", + enter: "Eingabe", + backspace: "Rücktaste", + del: "Entf", + esc: "Esc", + space: "Leertaste", + tab: "Tab", + pgup: "Bild↑", + pgdn: "Bild↓", + home: "Pos1", + end: "Ende", + ins: "Einfg", + menu: "Menü", + num: "Num", + }, + }, errors: { sshAgentUnavailable: "ssh-agent nicht verfügbar (SSH_AUTH_SOCK ist nicht gesetzt)", identityFileNotFound: "Identitätsdatei nicht gefunden: {path}", diff --git a/src/shared/i18n/en.ts b/src/shared/i18n/en.ts index b4beaa5..c7aa3d9 100644 --- a/src/shared/i18n/en.ts +++ b/src/shared/i18n/en.ts @@ -25,8 +25,8 @@ export const en = { tabBar: { connections: "Connections", manageConnections: "Manage connections", - newTab: "New tab ({mod}T)", - closeTab: "Close tab ({mod}W)", + newTab: "New tab", + closeTab: "Close tab", options: "Settings", update: "Update available", updating: "Updating", @@ -79,17 +79,16 @@ export const en = { panel: { choosePanel: "Choose what this panel shows", copy: "Send", - copyToRight: "Send to right panel (F5)", - copyToLeft: "Send to left panel (F5)", + copyToRight: "Send to right panel", + copyToLeft: "Send to left panel", edit: "Edit", - editFile: "Edit file (F4)", - rename: "Rename (F2)", - newFolder: "New folder (F7)", + editFile: "Edit file", + rename: "Rename", + newFolder: "New folder", newFile: "New file", permissions: "Permissions", delete: "Delete", - deleteHint: "Delete (F8 / Del)", - parentDir: "Parent directory (Backspace)", + parentDir: "Parent directory", refresh: "Refresh", clearFilter: "Clear filter (Esc)", sshConfig: "~/.ssh/config", @@ -148,16 +147,17 @@ export const en = { noFileTitle: "No file to edit", noFileBody: "This window was opened without an editor context. Launch files from the app.", wrap: "Wrap", - wrapHint: "Wrap long lines — hides line numbers ({alt}Z)", - findHint: "Find ({mod}F)", + wrapHint: "Wrap long lines — hides line numbers", + findHint: "Find", findPlaceholder: "Find in file", - prevMatch: "Previous match (⇧↵)", - nextMatch: "Next match (↵)", + prevMatch: "Previous match", + nextMatch: "Next match", closeHint: "Close (Esc)", disconnected: "Disconnected from {name} — saving will fail, but your text is preserved in this window.", cantOpen: "Can't open {name}", closeWindow: "Close Window", - shortcuts: "{mod}S save · {mod}F find · {mod}W close", + // footer hint: "⌘S save · ⌘F find · ⌘W close", keys from the keymap + verbs: { save: "save", find: "find", close: "close" }, lines: { one: "{count} line", other: "{count} lines" }, close: "Close", save: "Save", @@ -213,6 +213,7 @@ export const en = { }, toasts: { movedToTrash: 'Moved "{name}" to Trash', + movedManyToTrash: { one: "Moved {count} item to Trash", other: "Moved {count} items to Trash" }, editingExternal: "Editing {name} — saves upload automatically", nothingToCopy: "Nothing to copy (symlinks are skipped)", nothingToCopyAllSkipped: "Nothing to copy — all conflicting items were skipped", @@ -296,6 +297,101 @@ export const en = { tabDisconnect: "Disconnect", trashTooltip: "Finder's Restore is not available for items trashed from here", }, + // Keyboard commands — one label per src/shared/keymap.ts BINDINGS id + keys: { + "cursor-up": "Move up", + "cursor-down": "Move down", + "page-up": "Page up", + "page-down": "Page down", + "cursor-first": "First item", + "cursor-last": "Last item", + "toggle-select": "Select / deselect", + "select-all": "Select all", + open: "Open", + parent: "Parent folder", + "switch-panel": "Switch panel", + "row-menu": "Show the item's menu", + send: "Send to the other panel", + edit: "Edit", + rename: "Rename", + "new-folder": "New folder", + "new-file": "New file", + chmod: "Permissions", + delete: "Delete", + trash: "Move to Trash", + "copy-path": "Copy path", + reveal: "Reveal in file manager", + "open-default": "Open in default app", + refresh: "Refresh", + "focus-path": "Go to path", + "source-picker": "Choose what the panel shows", + "sort-name": "Sort by name", + "sort-size": "Sort by size", + "sort-mtime": "Sort by modified", + "sort-kind": "Sort by kind", + "sort-rights": "Sort by rights", + "sort-owner": "Sort by owner", + "sort-group": "Sort by group", + "new-tab": "New tab", + "close-tab": "Close tab", + "close-other-tabs": "Close other tabs", + "close-tabs-right": "Close tabs to the right", + "next-tab": "Next tab", + "prev-tab": "Previous tab", + "go-to-tab": "Go to tab {n}", + "go-to-last-tab": "Go to last tab", + "move-tab-left": "Move tab left", + "move-tab-right": "Move tab right", + "disconnect-tab": "Disconnect tab", + "open-settings": "Settings", + "connection-manager": "Connection manager", + "toggle-transfers": "Show / hide transfers", + "install-update": "Install update", + "scale-up": "Bigger interface", + "scale-down": "Smaller interface", + "scale-reset": "Interface at 100%", + "close-window": "Close window", + save: "Save", + find: "Find", + "find-next": "Find next", + "find-prev": "Find previous", + "toggle-wrap": "Wrap lines", + "conn-new": "New connection", + "conn-new-stack": "New stack", + "conn-save": "Save connection", + "conn-connect": "Connect / open stack", + "conn-delete": "Delete connection or stack", + "conn-rename-stack": "Rename stack", + "conn-import": "Import from WinSCP", + groups: { + panels: "File list", + files: "Files", + sort: "Sorting", + tabs: "Tabs", + window: "Window", + editor: "Editor", + connections: "Connection manager", + }, + // key names for "Ctrl+Shift+Del"-style hints (not used on macOS, which shows ⌘⇧⌫) + names: { + ctrl: "Ctrl", + alt: "Alt", + shift: "Shift", + enter: "Enter", + backspace: "Backspace", + del: "Del", + esc: "Esc", + space: "Space", + tab: "Tab", + pgup: "PgUp", + pgdn: "PgDn", + home: "Home", + end: "End", + ins: "Ins", + menu: "Menu", + num: "Num", + }, + }, errors: { sshAgentUnavailable: "ssh-agent not available (SSH_AUTH_SOCK is not set)", identityFileNotFound: "identity file not found: {path}", diff --git a/src/shared/i18n/es.ts b/src/shared/i18n/es.ts index 3283e5f..65a4ff3 100644 --- a/src/shared/i18n/es.ts +++ b/src/shared/i18n/es.ts @@ -21,8 +21,8 @@ export const es = { tabBar: { connections: "Conexiones", manageConnections: "Gestionar conexiones", - newTab: "Nueva pestaña ({mod}T)", - closeTab: "Cerrar pestaña ({mod}W)", + newTab: "Nueva pestaña", + closeTab: "Cerrar pestaña", options: "Ajustes", update: "Actualización disponible", updating: "Actualizando", @@ -75,17 +75,16 @@ export const es = { panel: { choosePanel: "Elige qué muestra este panel", copy: "Enviar", - copyToRight: "Enviar al panel derecho (F5)", - copyToLeft: "Enviar al panel izquierdo (F5)", + copyToRight: "Enviar al panel derecho", + copyToLeft: "Enviar al panel izquierdo", edit: "Editar", - editFile: "Editar archivo (F4)", - rename: "Renombrar (F2)", - newFolder: "Nueva carpeta (F7)", + editFile: "Editar archivo", + rename: "Renombrar", + newFolder: "Nueva carpeta", newFile: "Nuevo archivo", permissions: "Permisos", delete: "Eliminar", - deleteHint: "Eliminar (F8 / Supr)", - parentDir: "Carpeta superior (Retroceso)", + parentDir: "Carpeta superior", refresh: "Actualizar", clearFilter: "Borrar filtro (Esc)", sshConfig: "~/.ssh/config", @@ -144,16 +143,16 @@ export const es = { noFileTitle: "Ningún archivo que editar", noFileBody: "Esta ventana se abrió sin contexto de editor. Abre archivos desde la aplicación.", wrap: "Ajustar", - wrapHint: "Ajustar líneas largas — oculta los números de línea ({alt}Z)", - findHint: "Buscar ({mod}F)", + wrapHint: "Ajustar líneas largas — oculta los números de línea", + findHint: "Buscar", findPlaceholder: "Buscar en el archivo", - prevMatch: "Coincidencia anterior (⇧↵)", - nextMatch: "Coincidencia siguiente (↵)", + prevMatch: "Coincidencia anterior", + nextMatch: "Coincidencia siguiente", closeHint: "Cerrar (Esc)", disconnected: "Desconectado de {name} — no se podrá guardar, pero tu texto se conserva en esta ventana.", cantOpen: "No se puede abrir {name}", closeWindow: "Cerrar ventana", - shortcuts: "{mod}S guardar · {mod}F buscar · {mod}W cerrar", + verbs: { save: "guardar", find: "buscar", close: "cerrar" }, lines: { one: "{count} línea", other: "{count} líneas" }, close: "Cerrar", save: "Guardar", @@ -209,6 +208,7 @@ export const es = { }, toasts: { movedToTrash: 'Se movió "{name}" a la papelera', + movedManyToTrash: { one: "Se movió {count} elemento a la papelera", other: "Se movieron {count} elementos a la papelera" }, editingExternal: "Editando {name} — los cambios se suben automáticamente", nothingToCopy: "Nada que copiar (los enlaces simbólicos se omiten)", nothingToCopyAllSkipped: "Nada que copiar — se omitieron todos los elementos en conflicto", @@ -292,6 +292,26 @@ export const es = { tabDisconnect: "Desconectar", trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí", }, + keys: { + names: { + ctrl: "Ctrl", + alt: "Alt", + shift: "Mayús", + enter: "Intro", + backspace: "Retroceso", + del: "Supr", + esc: "Esc", + space: "Espacio", + tab: "Tab", + pgup: "RePág", + pgdn: "AvPág", + home: "Inicio", + end: "Fin", + ins: "Insert", + menu: "Menú", + num: "Num", + }, + }, errors: { sshAgentUnavailable: "ssh-agent no disponible (SSH_AUTH_SOCK no está definido)", identityFileNotFound: "archivo de identidad no encontrado: {path}", diff --git a/src/shared/i18n/eu.ts b/src/shared/i18n/eu.ts index 76adaf3..8a62611 100644 --- a/src/shared/i18n/eu.ts +++ b/src/shared/i18n/eu.ts @@ -23,8 +23,8 @@ export const eu = { tabBar: { connections: "Konexioak", manageConnections: "Kudeatu konexioak", - newTab: "Fitxa berria ({mod}T)", - closeTab: "Itxi fitxa ({mod}W)", + newTab: "Fitxa berria", + closeTab: "Itxi fitxa", options: "Ezarpenak", update: "Eguneratzea eskuragarri", updating: "Eguneratzen", @@ -77,17 +77,16 @@ export const eu = { panel: { choosePanel: "Aukeratu panel honek zer erakusten duen", copy: "Bidali", - copyToRight: "Bidali eskuineko panelera (F5)", - copyToLeft: "Bidali ezkerreko panelera (F5)", + copyToRight: "Bidali eskuineko panelera", + copyToLeft: "Bidali ezkerreko panelera", edit: "Editatu", - editFile: "Editatu fitxategia (F4)", - rename: "Berrizendatu (F2)", - newFolder: "Karpeta berria (F7)", + editFile: "Editatu fitxategia", + rename: "Berrizendatu", + newFolder: "Karpeta berria", newFile: "Fitxategi berria", permissions: "Baimenak", delete: "Ezabatu", - deleteHint: "Ezabatu (F8 / Del)", - parentDir: "Karpeta gurasoa (Atzera-tekla)", + parentDir: "Karpeta gurasoa", refresh: "Freskatu", clearFilter: "Garbitu iragazkia (Esc)", sshConfig: "~/.ssh/config", @@ -146,16 +145,16 @@ export const eu = { noFileTitle: "Ez dago editatzeko fitxategirik", noFileBody: "Leiho hau editore-testuingururik gabe ireki da. Ireki fitxategiak aplikaziotik.", wrap: "Doitu", - wrapHint: "Doitu lerro luzeak — lerro-zenbakiak ezkutatzen ditu ({alt}Z)", - findHint: "Bilatu ({mod}F)", + wrapHint: "Doitu lerro luzeak — lerro-zenbakiak ezkutatzen ditu", + findHint: "Bilatu", findPlaceholder: "Bilatu fitxategian", - prevMatch: "Aurreko bat-etortzea (⇧↵)", - nextMatch: "Hurrengo bat-etortzea (↵)", + prevMatch: "Aurreko bat-etortzea", + nextMatch: "Hurrengo bat-etortzea", closeHint: "Itxi (Esc)", disconnected: "{name}(e)tik deskonektatuta — gordetzeak huts egingo du, baina zure testua leiho honetan gordeta dago.", cantOpen: "Ezin da {name} ireki", closeWindow: "Itxi leihoa", - shortcuts: "{mod}S gorde · {mod}F bilatu · {mod}W itxi", + verbs: { save: "gorde", find: "bilatu", close: "itxi" }, lines: { one: "lerro {count}", other: "{count} lerro" }, close: "Itxi", save: "Gorde", @@ -211,6 +210,7 @@ export const eu = { }, toasts: { movedToTrash: '"{name}" zakarrontzira eraman da', + movedManyToTrash: { one: "{count} elementu zakarrontzira eraman da", other: "{count} elementu zakarrontzira eraman dira" }, editingExternal: "{name} editatzen — gordetzean automatikoki igotzen da", nothingToCopy: "Ez dago kopiatzeko ezer (esteka sinbolikoak saltatu egiten dira)", nothingToCopyAllSkipped: "Ez dago kopiatzeko ezer — gatazkan zeuden elementu guztiak saltatu dira", @@ -294,6 +294,26 @@ export const eu = { tabDisconnect: "Deskonektatu", trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat", }, + keys: { + names: { + ctrl: "Ctrl", + alt: "Alt", + shift: "Maius", + enter: "Intro", + backspace: "Atzera", + del: "Supr", + esc: "Esc", + space: "Zuriunea", + tab: "Tab", + pgup: "RePág", + pgdn: "AvPág", + home: "Inicio", + end: "Fin", + ins: "Insert", + menu: "Menua", + num: "Num", + }, + }, errors: { sshAgentUnavailable: "ssh-agent ez dago erabilgarri (SSH_AUTH_SOCK ez dago ezarrita)", identityFileNotFound: "identitate-fitxategia ez da aurkitu: {path}", diff --git a/src/shared/i18n/fr.ts b/src/shared/i18n/fr.ts index 71d19eb..de3e451 100644 --- a/src/shared/i18n/fr.ts +++ b/src/shared/i18n/fr.ts @@ -21,8 +21,8 @@ export const fr = { tabBar: { connections: "Connexions", manageConnections: "Gérer les connexions", - newTab: "Nouvel onglet ({mod}T)", - closeTab: "Fermer l'onglet ({mod}W)", + newTab: "Nouvel onglet", + closeTab: "Fermer l'onglet", options: "Réglages", update: "Mise à jour disponible", updating: "Mise à jour", @@ -75,17 +75,16 @@ export const fr = { panel: { choosePanel: "Choisir ce qu'affiche ce panneau", copy: "Envoyer", - copyToRight: "Envoyer vers le panneau droit (F5)", - copyToLeft: "Envoyer vers le panneau gauche (F5)", + copyToRight: "Envoyer vers le panneau droit", + copyToLeft: "Envoyer vers le panneau gauche", edit: "Éditer", - editFile: "Éditer le fichier (F4)", - rename: "Renommer (F2)", - newFolder: "Nouveau dossier (F7)", + editFile: "Éditer le fichier", + rename: "Renommer", + newFolder: "Nouveau dossier", newFile: "Nouveau fichier", permissions: "Autorisations", delete: "Supprimer", - deleteHint: "Supprimer (F8 / Suppr)", - parentDir: "Dossier parent (Retour arrière)", + parentDir: "Dossier parent", refresh: "Actualiser", clearFilter: "Effacer le filtre (Échap)", sshConfig: "~/.ssh/config", @@ -144,16 +143,16 @@ export const fr = { noFileTitle: "Aucun fichier à éditer", noFileBody: "Cette fenêtre a été ouverte sans contexte d'éditeur. Ouvrez des fichiers depuis l'application.", wrap: "Retour", - wrapHint: "Retour à la ligne — masque les numéros de ligne ({alt}Z)", - findHint: "Rechercher ({mod}F)", + wrapHint: "Retour à la ligne — masque les numéros de ligne", + findHint: "Rechercher", findPlaceholder: "Rechercher dans le fichier", - prevMatch: "Résultat précédent (⇧↵)", - nextMatch: "Résultat suivant (↵)", + prevMatch: "Résultat précédent", + nextMatch: "Résultat suivant", closeHint: "Fermer (Échap)", disconnected: "Déconnecté de {name} — l'enregistrement échouera, mais votre texte est conservé dans cette fenêtre.", cantOpen: "Impossible d'ouvrir {name}", closeWindow: "Fermer la fenêtre", - shortcuts: "{mod}S enregistrer · {mod}F rechercher · {mod}W fermer", + verbs: { save: "enregistrer", find: "rechercher", close: "fermer" }, lines: { one: "{count} ligne", other: "{count} lignes" }, close: "Fermer", save: "Enregistrer", @@ -209,6 +208,7 @@ export const fr = { }, toasts: { movedToTrash: '« {name} » mis à la corbeille', + movedManyToTrash: { one: "{count} élément mis à la corbeille", other: "{count} éléments mis à la corbeille" }, editingExternal: "Édition de {name} — les enregistrements sont envoyés automatiquement", nothingToCopy: "Rien à copier (les liens symboliques sont ignorés)", nothingToCopyAllSkipped: "Rien à copier — tous les éléments en conflit ont été ignorés", @@ -292,6 +292,26 @@ export const fr = { tabDisconnect: "Déconnecter", trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici", }, + keys: { + names: { + ctrl: "Ctrl", + alt: "Alt", + shift: "Maj", + enter: "Entrée", + backspace: "Retour arrière", + del: "Suppr", + esc: "Échap", + space: "Espace", + tab: "Tab", + pgup: "Pg préc", + pgdn: "Pg suiv", + home: "Début", + end: "Fin", + ins: "Inser", + menu: "Menu", + num: "Num", + }, + }, errors: { sshAgentUnavailable: "ssh-agent indisponible (SSH_AUTH_SOCK non défini)", identityFileNotFound: "fichier d'identité introuvable : {path}", diff --git a/src/shared/i18n/ja.ts b/src/shared/i18n/ja.ts index e26d56d..179fa95 100644 --- a/src/shared/i18n/ja.ts +++ b/src/shared/i18n/ja.ts @@ -22,8 +22,8 @@ export const ja = { tabBar: { connections: "接続", manageConnections: "接続を管理", - newTab: "新しいタブ ({mod}T)", - closeTab: "タブを閉じる ({mod}W)", + newTab: "新しいタブ", + closeTab: "タブを閉じる", options: "設定", update: "アップデートあり", updating: "更新中", @@ -76,17 +76,16 @@ export const ja = { panel: { choosePanel: "このパネルに表示する内容を選択", copy: "送信", - copyToRight: "右のパネルへ送信 (F5)", - copyToLeft: "左のパネルへ送信 (F5)", + copyToRight: "右のパネルへ送信", + copyToLeft: "左のパネルへ送信", edit: "編集", - editFile: "ファイルを編集 (F4)", - rename: "名前を変更 (F2)", - newFolder: "新規フォルダ (F7)", + editFile: "ファイルを編集", + rename: "名前を変更", + newFolder: "新規フォルダ", newFile: "新規ファイル", permissions: "アクセス権", delete: "削除", - deleteHint: "削除 (F8 / Del)", - parentDir: "親フォルダ (Backspace)", + parentDir: "親フォルダ", refresh: "更新", clearFilter: "フィルターをクリア (Esc)", sshConfig: "~/.ssh/config", @@ -145,16 +144,16 @@ export const ja = { noFileTitle: "編集するファイルがありません", noFileBody: "このウィンドウはエディタコンテキストなしで開かれました。アプリからファイルを開いてください。", wrap: "折り返し", - wrapHint: "長い行を折り返す — 行番号を非表示 ({alt}Z)", - findHint: "検索 ({mod}F)", + wrapHint: "長い行を折り返す — 行番号を非表示", + findHint: "検索", findPlaceholder: "ファイル内を検索", - prevMatch: "前の一致 (⇧↵)", - nextMatch: "次の一致 (↵)", + prevMatch: "前の一致", + nextMatch: "次の一致", closeHint: "閉じる (Esc)", disconnected: "{name} から切断されました — 保存は失敗しますが、テキストはこのウィンドウに保持されます。", cantOpen: "{name} を開けません", closeWindow: "ウィンドウを閉じる", - shortcuts: "{mod}S 保存 · {mod}F 検索 · {mod}W 閉じる", + verbs: { save: "保存", find: "検索", close: "閉じる" }, lines: { other: "{count} 行" }, close: "閉じる", save: "保存", @@ -210,6 +209,7 @@ export const ja = { }, toasts: { movedToTrash: '"{name}" をゴミ箱に移動しました', + movedManyToTrash: { other: "{count} 個の項目をゴミ箱に移動しました" }, editingExternal: "{name} を編集中 — 保存すると自動でアップロードされます", nothingToCopy: "コピーする項目がありません(シンボリックリンクはスキップされます)", nothingToCopyAllSkipped: "コピーする項目がありません — 競合する項目はすべてスキップされました", @@ -292,6 +292,26 @@ export const ja = { tabDisconnect: "切断", trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません", }, + keys: { + names: { + ctrl: "Ctrl", + alt: "Alt", + shift: "Shift", + enter: "Enter", + backspace: "Backspace", + del: "Del", + esc: "Esc", + space: "スペース", + tab: "Tab", + pgup: "PgUp", + pgdn: "PgDn", + home: "Home", + end: "End", + ins: "Ins", + menu: "メニュー", + num: "Num", + }, + }, errors: { sshAgentUnavailable: "ssh-agent が利用できません(SSH_AUTH_SOCK が未設定)", identityFileNotFound: "認証鍵ファイルが見つかりません: {path}", diff --git a/src/shared/i18n/zh.ts b/src/shared/i18n/zh.ts index f119a93..3ab05d5 100644 --- a/src/shared/i18n/zh.ts +++ b/src/shared/i18n/zh.ts @@ -22,8 +22,8 @@ export const zh = { tabBar: { connections: "连接", manageConnections: "管理连接", - newTab: "新建标签页 ({mod}T)", - closeTab: "关闭标签页 ({mod}W)", + newTab: "新建标签页", + closeTab: "关闭标签页", options: "设置", update: "有可用更新", updating: "正在更新", @@ -76,17 +76,16 @@ export const zh = { panel: { choosePanel: "选择此面板显示的内容", copy: "发送", - copyToRight: "发送到右侧面板 (F5)", - copyToLeft: "发送到左侧面板 (F5)", + copyToRight: "发送到右侧面板", + copyToLeft: "发送到左侧面板", edit: "编辑", - editFile: "编辑文件 (F4)", - rename: "重命名 (F2)", - newFolder: "新建文件夹 (F7)", + editFile: "编辑文件", + rename: "重命名", + newFolder: "新建文件夹", newFile: "新建文件", permissions: "权限", delete: "删除", - deleteHint: "删除 (F8 / Del)", - parentDir: "上级目录 (退格)", + parentDir: "上级目录", refresh: "刷新", clearFilter: "清除筛选 (Esc)", sshConfig: "~/.ssh/config", @@ -145,16 +144,16 @@ export const zh = { noFileTitle: "没有要编辑的文件", noFileBody: "此窗口在没有编辑器上下文的情况下打开。请从应用中打开文件。", wrap: "换行", - wrapHint: "长行自动换行 — 隐藏行号 ({alt}Z)", - findHint: "查找 ({mod}F)", + wrapHint: "长行自动换行 — 隐藏行号", + findHint: "查找", findPlaceholder: "在文件中查找", - prevMatch: "上一个匹配 (⇧↵)", - nextMatch: "下一个匹配 (↵)", + prevMatch: "上一个匹配", + nextMatch: "下一个匹配", closeHint: "关闭 (Esc)", disconnected: "已从 {name} 断开 — 保存将失败,但你的文本会保留在此窗口中。", cantOpen: "无法打开 {name}", closeWindow: "关闭窗口", - shortcuts: "{mod}S 保存 · {mod}F 查找 · {mod}W 关闭", + verbs: { save: "保存", find: "查找", close: "关闭" }, lines: { other: "{count} 行" }, close: "关闭", save: "保存", @@ -210,6 +209,7 @@ export const zh = { }, toasts: { movedToTrash: '已将 "{name}" 移到废纸篓', + movedManyToTrash: { other: "已将 {count} 个项目移到废纸篓" }, editingExternal: "正在编辑 {name} — 保存后会自动上传", nothingToCopy: "没有可复制的内容(符号链接将被跳过)", nothingToCopyAllSkipped: "没有可复制的内容 — 所有冲突项都被跳过", @@ -292,6 +292,26 @@ export const zh = { tabDisconnect: "断开连接", trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能", }, + keys: { + names: { + ctrl: "Ctrl", + alt: "Alt", + shift: "Shift", + enter: "Enter", + backspace: "退格", + del: "Del", + esc: "Esc", + space: "空格", + tab: "Tab", + pgup: "PgUp", + pgdn: "PgDn", + home: "Home", + end: "End", + ins: "Ins", + menu: "菜单", + num: "Num", + }, + }, errors: { sshAgentUnavailable: "ssh-agent 不可用(未设置 SSH_AUTH_SOCK)", identityFileNotFound: "找不到身份文件:{path}", diff --git a/src/shared/keymap.spec.ts b/src/shared/keymap.spec.ts new file mode 100644 index 0000000..6e84714 --- /dev/null +++ b/src/shared/keymap.spec.ts @@ -0,0 +1,215 @@ +import { expect, test } from "bun:test"; +import { en } from "./i18n/en"; +import { + BINDINGS, + type Combo, + comboFromEvent, + conflicts, + EN_KEY_NAMES, + effectiveKeymap, + firesInTextField, + formatCombo, + GROUPS, + isReserved, + type KeyEventLike, + matchesEvent, + NATIVE_MENU_IDS, + parseCombo, + serializeCombo, + scopesOverlap, + toAccelerator, +} from "./keymap"; + +const US_CHARS: Record = { + Comma: ",", + Period: ".", + Slash: "/", + Semicolon: ";", + Quote: "'", + BracketLeft: "[", + BracketRight: "]", + Backslash: "\\", + Backquote: "`", + Equal: "=", + Minus: "-", + NumpadAdd: "+", +}; + +/** The keydown a US keyboard produces for `c`. */ +function eventFor(c: Combo, isMac: boolean): KeyEventLike { + let key = c.key; + let code = c.key; + if (/^[A-Z]$/.test(c.key)) { + key = c.shift ? c.key : c.key.toLowerCase(); + code = `Key${c.key}`; + } else if (/^[0-9]$/.test(c.key)) { + code = `Digit${c.key}`; + } else if (c.key in US_CHARS) { + key = US_CHARS[c.key]; + } else if (c.key === "Space") { + key = " "; + } + return { + key, + code, + metaKey: isMac && c.mod, + ctrlKey: isMac ? c.ctrl : c.mod, + altKey: c.alt, + shiftKey: c.shift, + }; +} + +const ev = (over: Partial): KeyEventLike => ({ + key: "", + code: "", + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...over, +}); + +const combo = (text: string, isMac: boolean) => { + const c = parseCombo(text, isMac); + if (!c) throw new Error(`unparsable ${text}`); + return c; +}; + +for (const isMac of [true, false]) { + const os = isMac ? "macOS" : "Windows/Linux"; + const keymap = effectiveKeymap({}, isMac); + + test(`${os}: the defaults have no conflicts`, () => { + expect([...conflicts(keymap).entries()]).toEqual([]); + }); + + test(`${os}: every default round-trips and resolves to its own command`, () => { + for (const binding of keymap) { + for (const c of binding.combos) { + expect(parseCombo(serializeCombo(c), isMac)).toEqual(c); + const event = eventFor(c, isMac); + // table order is resolution order: nothing earlier may shadow it + const hit = keymap.find( + (other) => scopesOverlap(other.scopes, binding.scopes) && other.combos.some((oc) => matchesEvent(oc, event, isMac)), + ); + expect(`${serializeCombo(c)} → ${hit?.id}`).toBe(`${serializeCombo(c)} → ${binding.id}`); + expect(comboFromEvent(event, isMac)).toEqual(c); + } + } + }); + + test(`${os}: no default is a reserved combo`, () => { + for (const binding of keymap) for (const c of binding.combos) expect(isReserved(c, isMac)).toBe(false); + }); + + test(`${os}: every macOS menu command has an expressible accelerator`, () => { + if (!isMac) return; + for (const id of NATIVE_MENU_IDS) { + const binding = keymap.find((b) => b.id === id)!; + expect(binding.combos.some((c) => toAccelerator(c) !== null)).toBe(true); + } + }); +} + +test("every command and group has an English label", () => { + 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("matching is exact about modifiers", () => { + // ⌃F is Cocoa's forward-one-character, not ⌘F + expect(matchesEvent(combo("Mod+F", true), ev({ key: "f", code: "KeyF", ctrlKey: true }), true)).toBe(false); + expect(matchesEvent(combo("Mod+F", true), ev({ key: "f", code: "KeyF", metaKey: true }), true)).toBe(true); + // Ctrl+Tab is not Tab (the old panel switch caught both) + expect(matchesEvent(combo("Tab", false), ev({ key: "Tab", code: "Tab", ctrlKey: true }), false)).toBe(false); + // the Windows key is never part of a binding + expect(matchesEvent(combo("Mod+T", false), ev({ key: "t", code: "KeyT", ctrlKey: true, metaKey: true }), false)).toBe(false); +}); + +test("letters follow the layout, Alt chords and other scripts use the physical key", () => { + // AZERTY: the comma sits on the M key, the A on the Q key + expect(comboFromEvent(ev({ key: ",", code: "KeyM", metaKey: true }), true)).toEqual(combo("Mod+Comma", true)); + expect(comboFromEvent(ev({ key: "a", code: "KeyQ", ctrlKey: true }), false)).toEqual(combo("Mod+A", false)); + // macOS Option turns ⌥Z into Ω + expect(comboFromEvent(ev({ key: "Ω", code: "KeyZ", altKey: true }), true)).toEqual(combo("Alt+Z", true)); + // Cyrillic ⌘T arrives as "е" on the T key + expect(matchesEvent(combo("Mod+T", true), ev({ key: "е", code: "KeyT", metaKey: true }), true)).toBe(true); + // US ⌘+ is ⇧⌘= — matched by the physical key + expect(matchesEvent(combo("Mod+Shift+Equal", true), ev({ key: "+", code: "Equal", metaKey: true, shiftKey: true }), true)).toBe(true); + // AZERTY ⌘1 types "&" on the 1 key + expect(comboFromEvent(ev({ key: "&", code: "Digit1", metaKey: true }), true)).toEqual(combo("Mod+1", true)); + // a bare modifier press is not a combo + expect(comboFromEvent(ev({ key: "Shift", code: "ShiftLeft", shiftKey: true }), true)).toBeNull(); +}); + +test("formatting per platform, with localized key names off macOS", () => { + expect(formatCombo(combo("Mod+Shift+N", true), true)).toBe("⇧⌘N"); + expect(formatCombo(combo("Ctrl+Tab", true), true)).toBe("⌃⇥"); + expect(formatCombo(combo("Mod+Alt+C", true), true)).toBe("⌥⌘C"); + expect(formatCombo(combo("Mod+Comma", true), true)).toBe("⌘,"); + expect(formatCombo(combo("Shift+F10", true), true)).toBe("⇧F10"); + expect(formatCombo(combo("Mod+Backspace", true), true)).toBe("⌘⌫"); + expect(formatCombo(combo("Mod+Shift+N", false), false)).toBe("Ctrl+Shift+N"); + expect(formatCombo(combo("Alt+Shift+R", false), false)).toBe("Alt+Shift+R"); + expect(formatCombo(combo("Delete", false), false)).toBe("Del"); + const de = { ...EN_KEY_NAMES, ctrl: "Strg", del: "Entf" }; + expect(formatCombo(combo("Mod+Delete", false), false, de)).toBe("Strg+Entf"); +}); + +test("overrides replace a row; garbage is dropped", () => { + const keymap = effectiveKeymap( + { rename: ["Mod+Shift+R", "nonsense", 5, "Mod+Shift+R"], save: "F9", bogus: ["F1"], delete: [] }, + true, + ); + const by = (id: string) => keymap.find((b) => b.id === id)!; + expect(by("rename").combos.map(serializeCombo)).toEqual(["Mod+Shift+R"]); + expect(by("rename").custom).toBe(true); + // not an array: ignored, the defaults stand + expect(by("save").custom).toBe(false); + expect(by("save").combos.map(serializeCombo)).toEqual(["Mod+S"]); + // [] unbinds + expect(by("delete").combos).toEqual([]); + expect(keymap.some((b) => (b.id as string) === "bogus")).toBe(false); + // not an object at all + expect(effectiveKeymap("junk", true).every((b) => !b.custom)).toBe(true); +}); + +test("off macOS a stored literal Ctrl folds into Mod", () => { + expect(parseCombo("Ctrl+Tab", false)).toEqual(parseCombo("Mod+Tab", false)); + expect(parseCombo("Ctrl+Tab", true)).not.toEqual(parseCombo("Mod+Tab", true)); +}); + +test("native menu accelerators", () => { + expect(toAccelerator(combo("Mod+Shift+N", true))).toBe("Command+Shift+n"); + expect(toAccelerator(combo("Mod+Comma", true))).toBe("Command+,"); + expect(toAccelerator(combo("Mod+Minus", true))).toBe("Command+minus"); + expect(toAccelerator(combo("Ctrl+Shift+PageUp", true))).toBe("Control+Shift+pageup"); + // a bare key would mean ⌘+key to the native parser + expect(toAccelerator(combo("F5", true))).toBeNull(); + // ⌫ / ⌦, Shift+punctuation and modifier-less combos have no faithful form + expect(toAccelerator(combo("Mod+Delete", true))).toBeNull(); + expect(toAccelerator(combo("Mod+Backspace", true))).toBeNull(); + expect(toAccelerator(combo("Mod+Shift+BracketRight", true))).toBeNull(); + expect(toAccelerator(combo("Shift+F4", true))).toBeNull(); +}); + +test("text fields keep their own keys", () => { + // plain keys and text-editing chords type / edit + expect(firesInTextField(combo("Delete", true), true)).toBe(false); + expect(firesInTextField(combo("Mod+Backspace", true), true)).toBe(false); + expect(firesInTextField(combo("Mod+A", true), true)).toBe(false); + expect(firesInTextField(combo("Alt+ArrowLeft", true), true)).toBe(false); + // app chords and F-keys still fire (⌘S in the editor, ⌘↩ in the manager form) + expect(firesInTextField(combo("Mod+S", true), true)).toBe(true); + expect(firesInTextField(combo("Mod+Enter", false), false)).toBe(true); + expect(firesInTextField(combo("F3", false), false)).toBe(true); + // Ctrl+Alt off macOS is AltGr typing a character + expect(firesInTextField(combo("Mod+Alt+Q", false), false)).toBe(false); +}); + +test("AltGr is never a shortcut off macOS", () => { + const altGrQ = ev({ key: "@", code: "KeyQ", ctrlKey: true, altKey: true, getModifierState: (k) => k === "AltGraph" }); + expect(matchesEvent(combo("Mod+Alt+Q", false), altGrQ, false)).toBe(false); + expect(comboFromEvent(altGrQ, false)).toBeNull(); +}); diff --git a/src/shared/keymap.ts b/src/shared/keymap.ts new file mode 100644 index 0000000..679f8fc --- /dev/null +++ b/src/shared/keymap.ts @@ -0,0 +1,547 @@ +// The app's one keymap: every keyboard command, where it fires and its default +// keys per platform, plus the pure helpers that parse, match, format and check +// key combos. Shared on purpose: the webviews resolve keys, show hints and edit +// remaps against this table, and bun builds the macOS menu-bar accelerators +// from it, so the two can never disagree. User remaps are stored as overrides +// in AppSettings.keybindings and applied by effectiveKeymap(). + +/** Where a binding fires. `panel` = the focused file list, `main` = anywhere + * in the main window, `any` = every window. `panel` sits inside `main`, so + * the two overlap for conflict purposes. */ +export type Scope = "panel" | "main" | "editor" | "manager" | "settings" | "any"; + +/** Settings → Keyboard sections, in display order. */ +export const GROUPS = ["panels", "files", "sort", "tabs", "window", "editor", "connections"] as const; +export type Group = (typeof GROUPS)[number]; + +interface BindingDef { + readonly id: string; + readonly group: Group; + readonly scopes: readonly Scope[]; + /** Default combos, best first: the first is the one menus and tooltips show. */ + readonly mac: readonly string[]; + readonly other: readonly string[]; + /** go-to-tab-N: the N its label interpolates. */ + readonly n?: number; +} + +const both = (...combos: string[]) => ({ mac: combos, other: combos }); + +/** + * Every command, in Settings display order within its group. Combo strings are + * platform-neutral (see parseCombo): `Mod` is ⌘ on macOS and Ctrl elsewhere, + * `Ctrl` a literal ⌃ that only macOS tells apart. + */ +export const BINDINGS = [ + // ---- panels: the focused file list ---- + { id: "cursor-up", group: "panels", scopes: ["panel"], ...both("ArrowUp") }, + { id: "cursor-down", group: "panels", scopes: ["panel"], ...both("ArrowDown") }, + { id: "page-up", group: "panels", scopes: ["panel"], ...both("PageUp") }, + { id: "page-down", group: "panels", scopes: ["panel"], ...both("PageDown") }, + { id: "cursor-first", group: "panels", scopes: ["panel"], ...both("Home") }, + { id: "cursor-last", group: "panels", scopes: ["panel"], ...both("End") }, + { id: "toggle-select", group: "panels", scopes: ["panel"], mac: ["Space"], other: ["Space", "Insert"] }, + { id: "select-all", group: "panels", scopes: ["panel"], ...both("Mod+A") }, + { id: "open", group: "panels", scopes: ["panel"], mac: ["Enter", "Mod+ArrowDown"], other: ["Enter"] }, + { id: "parent", group: "panels", scopes: ["panel"], mac: ["Backspace", "Mod+ArrowUp"], other: ["Backspace", "Alt+ArrowUp"] }, + { id: "switch-panel", group: "panels", scopes: ["panel"], ...both("Tab", "Shift+Tab") }, + { id: "row-menu", group: "panels", scopes: ["panel"], mac: ["Shift+F10", "Ctrl+Enter"], other: ["Shift+F10", "ContextMenu"] }, + // ---- files: act on the focused panel's selection, else its cursor row ---- + { id: "send", group: "files", scopes: ["panel"], ...both("F5") }, + { id: "edit", group: "files", scopes: ["panel"], mac: ["Mod+E", "F4"], other: ["F4", "Mod+E"] }, + { id: "rename", group: "files", scopes: ["panel"], ...both("F2") }, + { id: "new-folder", group: "files", scopes: ["panel"], mac: ["Mod+Shift+N", "F7"], other: ["F7", "Mod+Shift+N"] }, + { id: "new-file", group: "files", scopes: ["panel"], mac: ["Mod+N", "Shift+F4"], other: ["Shift+F4", "Mod+N"] }, + { id: "chmod", group: "files", scopes: ["panel"], ...both("Mod+I") }, + { id: "delete", group: "files", scopes: ["panel"], ...both("F8", "Delete") }, + { id: "trash", group: "files", scopes: ["panel"], mac: ["Mod+Backspace"], other: ["Mod+Delete"] }, + { id: "copy-path", group: "files", scopes: ["panel"], mac: ["Mod+Alt+C"], other: ["Mod+Shift+C"] }, + { id: "reveal", group: "files", scopes: ["panel"], mac: ["Mod+Alt+R"], other: ["Alt+Shift+R"] }, + { id: "open-default", group: "files", scopes: ["panel"], ...both("Mod+O") }, + { id: "refresh", group: "files", scopes: ["panel"], ...both("Mod+R") }, + { id: "focus-path", group: "files", scopes: ["panel"], ...both("Mod+L") }, + { id: "source-picker", group: "files", scopes: ["panel"], ...both("Mod+K") }, + // ---- sort the focused panel (again = reverse) ---- + { id: "sort-name", group: "sort", scopes: ["panel"], ...both("Alt+1") }, + { id: "sort-size", group: "sort", scopes: ["panel"], ...both("Alt+2") }, + { id: "sort-mtime", group: "sort", scopes: ["panel"], ...both("Alt+3") }, + { id: "sort-kind", group: "sort", scopes: ["panel"], ...both("Alt+4") }, + { id: "sort-rights", group: "sort", scopes: ["panel"], ...both("Alt+5") }, + { id: "sort-owner", group: "sort", scopes: ["panel"], ...both("Alt+6") }, + { id: "sort-group", group: "sort", scopes: ["panel"], ...both("Alt+7") }, + // ---- tabs (Ctrl+Tab is a literal ⌃ on macOS: ⌘Tab is the app switcher) ---- + { id: "new-tab", group: "tabs", scopes: ["main"], ...both("Mod+T") }, + { id: "close-tab", group: "tabs", scopes: ["main"], ...both("Mod+W") }, + // Ctrl+Alt+letter is AltGr typing on many non-mac layouts — never a default there + { id: "close-other-tabs", group: "tabs", scopes: ["main"], mac: ["Mod+Alt+W"], other: ["Mod+Shift+W"] }, + { id: "close-tabs-right", group: "tabs", scopes: ["main"], ...both() }, + { id: "next-tab", group: "tabs", scopes: ["main"], mac: ["Ctrl+Tab", "Mod+Shift+BracketRight"], other: ["Ctrl+Tab", "Mod+PageDown"] }, + { id: "prev-tab", group: "tabs", scopes: ["main"], mac: ["Ctrl+Shift+Tab", "Mod+Shift+BracketLeft"], other: ["Ctrl+Shift+Tab", "Mod+PageUp"] }, + { id: "go-to-tab-1", group: "tabs", scopes: ["main"], n: 1, ...both("Mod+1") }, + { id: "go-to-tab-2", group: "tabs", scopes: ["main"], n: 2, ...both("Mod+2") }, + { id: "go-to-tab-3", group: "tabs", scopes: ["main"], n: 3, ...both("Mod+3") }, + { id: "go-to-tab-4", group: "tabs", scopes: ["main"], n: 4, ...both("Mod+4") }, + { id: "go-to-tab-5", group: "tabs", scopes: ["main"], n: 5, ...both("Mod+5") }, + { id: "go-to-tab-6", group: "tabs", scopes: ["main"], n: 6, ...both("Mod+6") }, + { id: "go-to-tab-7", group: "tabs", scopes: ["main"], n: 7, ...both("Mod+7") }, + { id: "go-to-tab-8", group: "tabs", scopes: ["main"], n: 8, ...both("Mod+8") }, + { id: "go-to-last-tab", group: "tabs", scopes: ["main"], ...both("Mod+9") }, + { id: "move-tab-left", group: "tabs", scopes: ["main"], mac: ["Ctrl+Shift+PageUp"], other: ["Mod+Shift+PageUp"] }, + { id: "move-tab-right", group: "tabs", scopes: ["main"], mac: ["Ctrl+Shift+PageDown"], other: ["Mod+Shift+PageDown"] }, + { id: "disconnect-tab", group: "tabs", scopes: ["main"], ...both("Mod+D") }, + // ---- window ---- + { id: "open-settings", group: "window", scopes: ["main"], ...both("Mod+Comma") }, + { id: "connection-manager", group: "window", scopes: ["main"], ...both("Mod+Shift+K") }, + { id: "toggle-transfers", group: "window", scopes: ["main"], ...both("Mod+J") }, + { id: "install-update", group: "window", scopes: ["main"], ...both() }, + // ⌘+ is ⇧⌘= on US layouts — both spellings, plus the keypad + { id: "scale-up", group: "window", scopes: ["any"], ...both("Mod+Equal", "Mod+Shift+Equal", "Mod+NumpadAdd") }, + { id: "scale-down", group: "window", scopes: ["any"], ...both("Mod+Minus") }, + { id: "scale-reset", group: "window", scopes: ["any"], ...both("Mod+0") }, + { id: "close-window", group: "window", scopes: ["editor", "manager", "settings"], ...both("Mod+W") }, + // ---- editor window ---- + { id: "save", group: "editor", scopes: ["editor"], ...both("Mod+S") }, + { id: "find", group: "editor", scopes: ["editor"], ...both("Mod+F") }, + { id: "find-next", group: "editor", scopes: ["editor"], mac: ["Mod+G"], other: ["F3"] }, + { id: "find-prev", group: "editor", scopes: ["editor"], mac: ["Mod+Shift+G"], other: ["Shift+F3"] }, + { id: "toggle-wrap", group: "editor", scopes: ["editor"], ...both("Alt+Z") }, + // ---- connection manager ---- + { id: "conn-new", group: "connections", scopes: ["manager"], ...both("Mod+N") }, + { id: "conn-new-stack", group: "connections", scopes: ["manager"], ...both("Mod+Shift+N") }, + { id: "conn-save", group: "connections", scopes: ["manager"], ...both("Mod+S") }, + { id: "conn-connect", group: "connections", scopes: ["manager"], ...both("Mod+Enter") }, + { id: "conn-delete", group: "connections", scopes: ["manager"], mac: ["Mod+Backspace"], other: ["Delete"] }, + { id: "conn-rename-stack", group: "connections", scopes: ["manager"], ...both("F2") }, + { id: "conn-import", group: "connections", scopes: ["manager"], ...both("Mod+O") }, +] as const satisfies readonly BindingDef[]; + +export type ShortcutId = (typeof BINDINGS)[number]["id"]; + +/** Commands the macOS menu bar carries; their accelerators follow the keymap. */ +export const NATIVE_MENU_IDS: readonly ShortcutId[] = [ + "open-settings", + "new-tab", + "close-tab", + "new-folder", + "new-file", + "edit", + "connection-manager", + "refresh", +]; + +export interface Combo { + /** A–Z, 0–9, a punctuation event.code (Comma, Equal…) or a key name (Enter, F2…). */ + key: string; + /** ⌘ on macOS, Ctrl elsewhere. */ + mod: boolean; + /** A literal ⌃, macOS only — elsewhere Ctrl IS Mod. */ + ctrl: boolean; + alt: boolean; + shift: boolean; +} + +/** The KeyboardEvent fields matching needs (tests build these by hand). */ +export interface KeyEventLike { + key: string; + code: string; + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; + getModifierState?(key: string): boolean; +} + +export interface ResolvedBinding { + id: ShortcutId; + group: Group; + scopes: readonly Scope[]; + n?: number; + /** What fires: the user's override when there is one, else the defaults. */ + combos: Combo[]; + defaults: Combo[]; + /** The user replaced this row's defaults (possibly with nothing). */ + custom: boolean; +} + +const LETTER = /^[A-Z]$/; +const DIGIT = /^[0-9]$/; +const FKEY = /^F([1-9]|1[0-2])$/; +/** Punctuation tokens and the character each stands for. Matched by character + * first (AZERTY's comma is on the M key) and by physical key second (⇧] makes + * "}", the US ⌘+ is ⇧⌘=). */ +const CHAR_OF: Record = { + Comma: ",", + Period: ".", + Slash: "/", + Semicolon: ";", + Quote: "'", + BracketLeft: "[", + BracketRight: "]", + Backslash: "\\", + Backquote: "`", + Equal: "=", + Minus: "-", +}; +const TOKEN_OF_CHAR: Record = Object.fromEntries( + Object.entries(CHAR_OF).map(([token, char]) => [char, token]), +); +/** Physical-only keys: the keypad's + (its − already types "-", which is Minus). */ +const KEYPAD = new Set(["NumpadAdd"]); +/** KeyboardEvent.key names, the same on every layout. */ +const NAMED = new Set([ + "Enter", + "Escape", + "Backspace", + "Delete", + "Tab", + "Space", + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "Home", + "End", + "PageUp", + "PageDown", + "Insert", + "ContextMenu", +]); + +function isToken(key: string): boolean { + return ( + LETTER.test(key) || DIGIT.test(key) || FKEY.test(key) || Object.hasOwn(CHAR_OF, key) || KEYPAD.has(key) || NAMED.has(key) + ); +} + +/** + * An event's key token, or null for modifier-only presses and keys the keymap + * doesn't cover. Letters follow the layout (AZERTY's A is A); under Alt + * (macOS Option turns ⌥Z into Ω) and on non-Latin layouts they fall back to the + * physical key. Digits are physical too: AZERTY types "&" on the 1 key. + */ +export function keyToken(e: Pick): string | null { + const { key, code } = e; + const upper = key.length === 1 ? key.toUpperCase() : ""; + if (!e.altKey && LETTER.test(upper)) return upper; + if (/^Key[A-Z]$/.test(code) && (e.altKey || /\p{L}/u.test(key))) return code.slice(3); + if (/^Digit[0-9]$/.test(code)) return code.slice(5); + if (/^Numpad[0-9]$/.test(code)) return DIGIT.test(key) ? key : NAMED.has(key) ? key : null; + if (Object.hasOwn(TOKEN_OF_CHAR, key)) return TOKEN_OF_CHAR[key]; + if (Object.hasOwn(CHAR_OF, code) || KEYPAD.has(code)) return code; + if (key === " " || code === "Space") return "Space"; + if (FKEY.test(key) || NAMED.has(key)) return key; + return null; +} + +function eventMods(e: KeyEventLike, isMac: boolean) { + return isMac + ? { mod: e.metaKey, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, other: false } + : // never part of a binding: the Windows / Super key, and AltGr — which + // arrives as Ctrl+Alt while it types "@", "€", "{"… + { + mod: e.ctrlKey, + ctrl: false, + alt: e.altKey, + shift: e.shiftKey, + other: e.metaKey || !!e.getModifierState?.("AltGraph"), + }; +} + +/** Exact match: the same key AND exactly the same modifiers. ⌃ is not ⌘ on + * macOS (⌃F is Cocoa's forward-one-character), and Ctrl+Tab is not Tab. */ +export function matchesEvent(combo: Combo, e: KeyEventLike, isMac: boolean): boolean { + const m = eventMods(e, isMac); + if (m.other || m.mod !== combo.mod || m.ctrl !== combo.ctrl || m.alt !== combo.alt || m.shift !== combo.shift) { + return false; + } + return keyToken(e) === combo.key || ((Object.hasOwn(CHAR_OF, combo.key) || KEYPAD.has(combo.key)) && e.code === combo.key); +} + +/** The combo a keydown spells (Settings → Keyboard recording), or null. */ +export function comboFromEvent(e: KeyEventLike, isMac: boolean): Combo | null { + const key = keyToken(e); + const m = eventMods(e, isMac); + if (!key || m.other) return null; + return { key, mod: m.mod, ctrl: m.ctrl, alt: m.alt, shift: m.shift }; +} + +/** "Mod+Shift+N" → Combo; null for anything malformed. Off macOS a stored + * literal Ctrl folds into Mod — on those keyboards they are the same key. */ +export function parseCombo(text: unknown, isMac: boolean): Combo | null { + if (typeof text !== "string" || !text) return null; + const parts = text.split("+"); + let key = parts.pop() ?? ""; + if (key.length === 1) key = key.toUpperCase(); + if (!isToken(key)) return null; + const combo: Combo = { key, mod: false, ctrl: false, alt: false, shift: false }; + for (const part of parts) { + const name = part.toLowerCase(); + if (name === "mod") combo.mod = true; + else if (name === "ctrl") combo.ctrl = true; + else if (name === "alt") combo.alt = true; + else if (name === "shift") combo.shift = true; + else return null; + } + if (!isMac && combo.ctrl) { + combo.ctrl = false; + combo.mod = true; + } + return combo; +} + +export function serializeCombo(c: Combo): string { + return [c.mod && "Mod", c.ctrl && "Ctrl", c.alt && "Alt", c.shift && "Shift", c.key].filter(Boolean).join("+"); +} + +export function sameCombo(a: Combo, b: Combo): boolean { + return serializeCombo(a) === serializeCombo(b); +} + +function parseList(list: readonly unknown[], isMac: boolean): Combo[] { + const out: Combo[] = []; + for (const text of list) { + const combo = parseCombo(text, isMac); + if (combo && !out.some((c) => sameCombo(c, combo))) out.push(combo); + } + return out; +} + +/** + * The table with the user's overrides applied — the single place a hand-edited + * settings.json is sanitized: unknown ids, non-arrays and malformed combos are + * dropped. An override replaces a row's whole list; [] unbinds it. + */ +export function effectiveKeymap(overrides: unknown, isMac: boolean): ResolvedBinding[] { + const table: Record = + overrides && typeof overrides === "object" && !Array.isArray(overrides) + ? (overrides as Record) + : {}; + return BINDINGS.map((b): ResolvedBinding => { + const defaults = parseList(isMac ? b.mac : b.other, isMac); + const raw = Object.hasOwn(table, b.id) ? table[b.id] : undefined; + const custom = Array.isArray(raw); + return { + id: b.id, + group: b.group, + scopes: b.scopes, + n: "n" in b ? b.n : undefined, + defaults, + custom, + combos: custom ? parseList(raw, isMac) : defaults, + }; + }); +} + +/** The stored shape, coerced to arrays of combo strings, for + * SettingsStore.load: a hand-edited settings.json can't break boot. Unknown + * ids are kept (a newer build's commands survive a downgrade round trip); + * effectiveKeymap ignores them. */ +export function sanitizeKeybindings(raw: unknown): Record { + const out: Record = {}; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out; + for (const [id, list] of Object.entries(raw)) { + if (Array.isArray(list)) out[id] = list.filter((t): t is string => parseCombo(t, true) !== null); + } + return out; +} + +/** True when a binding in `a` and one in `b` can fire on the same keystroke. */ +export function scopesOverlap(a: readonly Scope[], b: readonly Scope[]): boolean { + return a.some((x) => + b.some( + (y) => + x === y || + x === "any" || + y === "any" || + (x === "panel" && y === "main") || + (x === "main" && y === "panel"), + ), + ); +} + +/** The binding that already owns `combo` where `scopes` fire, if any. */ +export function ownerOf( + keymap: readonly ResolvedBinding[], + combo: Combo, + scopes: readonly Scope[], + except?: ShortcutId, +): ResolvedBinding | undefined { + return keymap.find( + (b) => b.id !== except && scopesOverlap(b.scopes, scopes) && b.combos.some((c) => sameCombo(c, combo)), + ); +} + +/** Every binding that shares a combo with another one it can collide with. */ +export function conflicts(keymap: readonly ResolvedBinding[]): Map { + const out = new Map(); + const link = (from: ShortcutId, to: ShortcutId) => out.set(from, [...(out.get(from) ?? []), to]); + for (let i = 0; i < keymap.length; i++) { + for (let j = i + 1; j < keymap.length; j++) { + const a = keymap[i]; + const b = keymap[j]; + if (!scopesOverlap(a.scopes, b.scopes)) continue; + if (a.combos.some((ca) => b.combos.some((cb) => sameCombo(ca, cb)))) { + link(a.id, b.id); + link(b.id, a.id); + } + } + } + return out; +} + +/** Keys whose modified forms edit text: ⌘⌫ deletes to line start, ⌥← jumps a word… */ +const TEXT_EDITING = new Set(["Backspace", "Delete", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End"]); + +/** Whether a combo may fire while focus is in a text field. Plain and + * Shift-only keys type there, and so do the text-editing chords (⌘⌫, ⌥←, ⌘A); + * other ⌘/Ctrl and Alt chords and F-keys fire — except Ctrl+Alt off macOS, + * which is AltGr. */ +export function firesInTextField(c: Combo, isMac: boolean): boolean { + if (FKEY.test(c.key)) return true; + if (TEXT_EDITING.has(c.key) || (c.mod && c.key === "A")) return false; + if (!isMac && c.mod && c.alt) return false; + return c.mod || c.alt; +} + +/** Commands a held key may repeat; any other held shortcut fires once + * (holding ⌘W must not close every tab). */ +const REPEATABLE = new Set([ + "cursor-up", + "cursor-down", + "page-up", + "page-down", + "toggle-select", + "next-tab", + "prev-tab", + "move-tab-left", + "move-tab-right", + "scale-up", + "scale-down", + "find-next", + "find-prev", +]); + +export function isRepeatable(id: ShortcutId): boolean { + return REPEATABLE.has(id); +} + +const RESERVED = { + mac: ["Mod+Q", "Mod+X", "Mod+C", "Mod+V", "Mod+Z", "Mod+Shift+Z", "Mod+H", "Mod+Alt+H", "Mod+M", "Mod+Backquote"], + other: ["Mod+X", "Mod+C", "Mod+V", "Mod+Z", "Mod+Y"], +}; + +/** Quit, the clipboard / undo keys and macOS's window keys (⌘H ⌘M ⌘`) can't be rebound. */ +export function isReserved(c: Combo, isMac: boolean): boolean { + return parseList(isMac ? RESERVED.mac : RESERVED.other, isMac).some((r) => sameCombo(r, c)); +} + +/** Key names for the non-macOS spelling ("Ctrl+Shift+Del"); i18n supplies + * localized ones (German keyboards say Strg and Entf). */ +export interface KeyNames { + ctrl: string; + alt: string; + shift: string; + enter: string; + backspace: string; + del: string; + esc: string; + space: string; + tab: string; + pgup: string; + pgdn: string; + home: string; + end: string; + ins: string; + menu: string; + num: string; +} + +export const EN_KEY_NAMES: KeyNames = { + ctrl: "Ctrl", + alt: "Alt", + shift: "Shift", + enter: "Enter", + backspace: "Backspace", + del: "Del", + esc: "Esc", + space: "Space", + tab: "Tab", + pgup: "PgUp", + pgdn: "PgDn", + home: "Home", + end: "End", + ins: "Ins", + menu: "Menu", + num: "Num", +}; + +const ARROWS: Record = { ArrowUp: "↑", ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→" }; +const MAC_GLYPHS: Record = { Enter: "↩", Escape: "⎋", Backspace: "⌫", Delete: "⌦", Tab: "⇥" }; + +function keyLabel(key: string, isMac: boolean, names: KeyNames): string { + if (Object.hasOwn(CHAR_OF, key)) return CHAR_OF[key]; + if (Object.hasOwn(ARROWS, key)) return ARROWS[key]; + if (isMac && Object.hasOwn(MAC_GLYPHS, key)) return MAC_GLYPHS[key]; + const named: Record = { + Enter: names.enter, + Escape: names.esc, + Backspace: names.backspace, + Delete: names.del, + Tab: names.tab, + Space: names.space, + Home: names.home, + End: names.end, + PageUp: names.pgup, + PageDown: names.pgdn, + Insert: names.ins, + ContextMenu: names.menu, + NumpadAdd: `${names.num} +`, + }; + return Object.hasOwn(named, key) ? named[key] : key; +} + +/** "⇧⌘N" on macOS (Apple's ⌃⌥⇧⌘ order), "Ctrl+Shift+N" elsewhere. */ +export function formatCombo(c: Combo, isMac: boolean, names: KeyNames = EN_KEY_NAMES): string { + const key = keyLabel(c.key, isMac, names); + if (isMac) return `${c.ctrl ? "⌃" : ""}${c.alt ? "⌥" : ""}${c.shift ? "⇧" : ""}${c.mod ? "⌘" : ""}${key}`; + return [c.mod && names.ctrl, c.alt && names.alt, c.shift && names.shift, key].filter(Boolean).join("+"); +} + +const ACCELERATOR_KEYS: Record = { + Enter: "return", + Escape: "escape", + Tab: "tab", + Space: "space", + ArrowUp: "up", + ArrowDown: "down", + ArrowLeft: "left", + ArrowRight: "right", + Home: "home", + End: "end", + PageUp: "pageup", + PageDown: "pagedown", +}; + +/** + * Electrobun's native menu accelerator ("Command+Shift+n"), or null when the + * menu can't express the combo faithfully: + * - only ⌘ / ⌃ chords — the native parser turns any combo without a modifier + * token into ⌘+key; + * - no ⌫ / ⌦ (its "backspace" and "delete" tokens map to characters AppKit + * matches unreliably), Insert, the Menu key or the keypad; + * - no Shift+punctuation — AppKit matches the shifted character ("}"), not "]". + */ +export function toAccelerator(c: Combo): string | null { + if (!c.mod && !c.ctrl) return null; + if (c.shift && Object.hasOwn(CHAR_OF, c.key)) return null; + let key: string | undefined; + if (LETTER.test(c.key)) key = c.key.toLowerCase(); + else if (DIGIT.test(c.key)) key = c.key; + else if (FKEY.test(c.key)) key = c.key.toLowerCase(); + else if (Object.hasOwn(CHAR_OF, c.key)) key = c.key === "Minus" ? "minus" : CHAR_OF[c.key]; + else if (Object.hasOwn(ACCELERATOR_KEYS, c.key)) key = ACCELERATOR_KEYS[c.key]; + if (!key) return null; + return [c.mod && "Command", c.ctrl && "Control", c.alt && "Alt", c.shift && "Shift", key] + .filter(Boolean) + .join("+"); +} diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index 255fd47..a64cc67 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -157,6 +157,8 @@ export type AppRPC = { // settings window /** Main window: open (or focus) the single Settings window. */ openSettings: { params: Record; response: Result }; + /** Settings window only: its close-window key (Ctrl+W off macOS, ⌘W). */ + settingsClose: { params: Record; response: Result }; // auto-updater (electrobun Updater) /** Main window: re-run the update check on demand (menu item). */ checkForUpdate: { diff --git a/src/shared/types.ts b/src/shared/types.ts index c50d929..5579fb4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -91,6 +91,14 @@ export interface AppSettings { editor: "builtin" | "custom"; /** Full path to the external editor .app; empty = OS default application. */ editorApp: string; + /** + * Keyboard remaps from Settings → Keyboard: command id → combo strings, only + * for rows that differ from the defaults ([] unbinds). Format and sanitizing + * live in keymap.ts — a hand-edited entry it can't parse is simply ignored. + */ + keybindings: Record; + /** Enter / double-click on a LOCAL file opens it in its default app. */ + openFilesOnEnter: boolean; // Auto-refresh the active tab's panels (local watched, remote polled). Always // on for now; uncomment (+ the settings.service and tab-bar breadcrumbs, and // gate the poll/watch in panels.service) to expose a toggle later. @@ -109,6 +117,8 @@ export const DEFAULT_SETTINGS: AppSettings = { autoClearCompleted: true, editor: "builtin", editorApp: "", + keybindings: {}, + openFilesOnEnter: false, // autoRefresh: true, }; diff --git a/ui/src/app/core/context-menu.service.ts b/ui/src/app/core/context-menu.service.ts index 721fc22..303e72b 100644 --- a/ui/src/app/core/context-menu.service.ts +++ b/ui/src/app/core/context-menu.service.ts @@ -1,30 +1,50 @@ import { Injectable, inject, signal } from '@angular/core'; import type { ColumnKey, EntryType } from '@shared/types'; +import type { FileCommand } from './file-actions.service'; import { I18nService } from './i18n.service'; -import { IS_MAC, IS_WINDOWS, MOD_LABEL } from './platform'; +import { stepIndex } from './menu-nav'; +import type { Side } from './panels.service'; +import { IS_MAC, IS_WINDOWS } from './platform'; import { RpcService } from './rpc.service'; +import type { ShortcutId } from './shortcuts.service'; /** Optional list columns, in display order (Name is fixed). */ export const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group']; export interface MenuItem { - type?: 'separator'; + /** separator: a rule; header: a non-pickable section title (the source picker). */ + type?: 'separator' | 'header'; label?: string; - /** Emitted verbatim to the action dispatcher (`ctx-*` / `col:*` / `tab:*`). */ + /** Handed to the request's onPick, else emitted to the action dispatcher + * (`col:*` / `tab:*`). */ action?: string; checked?: boolean; tooltip?: string; - /** Shortcut hint, rendered right-aligned. */ - accelerator?: string; + /** The keymap command the item runs: its first key shows as the soft hint, + * and pressing that key while the menu is open picks the item. */ + shortcut?: ShortcutId; + /** A registered icon (core/icons.ts), drawn in the check column. */ + icon?: string; /** Greyed and unclickable. Menus disable rather than hide, so the set of * commands stays in the same place from one right-click to the next. */ disabled?: boolean; } +/** Anything with a viewport point: a MouseEvent, or a row's rect for a + * keyboard-opened menu. */ +export interface MenuPoint { + clientX: number; + clientY: number; +} + export interface MenuRequest { x: number; y: number; items: MenuItem[]; + /** Highlighted on open: the first item when the keyboard opened the menu. */ + initialIndex: number; + /** Where a pick goes instead of the shared dispatcher (App.handleMenuAction). */ + onPick?: (action: string) => void; } export interface TabMenuContext { @@ -38,25 +58,27 @@ export interface TabMenuContext { } export interface RowMenuContext { - path: string; + side: Side; name: string; type: EntryType; isLocal: boolean; hasEntry: boolean; // false = right-click on empty panel space } +/** Items the highlight can land on. */ +export const isSelectable = (item: MenuItem): boolean => !item.type && !item.disabled; + // PORTABILITY: shell-integration wording per OS (resolved at build time for locale) const revealKey = (): string => IS_MAC ? 'ctx.revealFinder' : IS_WINDOWS ? 'ctx.revealExplorer' : 'ctx.revealFileManager'; const trashKey = (): string => (IS_WINDOWS ? 'ctx.moveToRecycleBin' : 'ctx.moveToTrash'); /** - * Builds and opens the panel right-click menus. This used to be a native + * Builds and opens the HTML context menus. This used to be a native * electrobun ContextMenu on the bun side, but `showContextMenu` is a documented * no-op on Linux (electrobun's own .hutch/devkit/api/sdks/main/proc/linux.md), * which left the whole right-click surface dead there. The menu is plain HTML - * now — one code path on every platform — and still emits the exact same - * action strings, so the dispatcher in app.ts is unchanged. + * now — one code path on every platform. */ @Injectable({ providedIn: 'root' }) export class ContextMenuService { @@ -65,76 +87,91 @@ export class ContextMenuService { readonly request = signal(null); - open(event: MouseEvent, items: MenuItem[]): void { - this.request.set({ x: event.clientX, y: event.clientY, items }); + open( + point: MenuPoint, + items: MenuItem[], + opts: { keyboard?: boolean; onPick?: (action: string) => void } = {}, + ): void { + const initialIndex = opts.keyboard ? stepIndex(items.map(isSelectable), -1, 1) : -1; + this.request.set({ x: point.clientX, y: point.clientY, items, initialIndex, onPick: opts.onPick }); } close(): void { this.request.set(null); } - /** A picked item goes through the same dispatcher the app menubar uses. */ - onPick(action: string): void { + /** A pick without its own handler goes through the dispatcher the app + * menubar uses. */ + emit(action: string): void { this.rpc.emitMenuAction(action); } - /** Row / empty-space menu for a file panel. */ + /** + * Row / empty-space menu for a file panel. Actions are FileCommand ids, + * picked through FileActionsService.run. Icons only on the everyday verbs — + * the same glyphs as their toolbar buttons. + */ rowItems(ctx: RowMenuContext): MenuItem[] { const t = (key: string) => this.i18n.t(key); + const cmd = (id: FileCommand, label: string, extra: Partial = {}): MenuItem => ({ + label, + action: id, + shortcut: id, + ...extra, + }); const items: MenuItem[] = []; if (!ctx.hasEntry || ctx.name === '..') { // empty space (or the parent row): folder-level actions - items.push({ label: t('ctx.newFolder'), action: 'ctx-new-folder' }); - items.push({ label: t('ctx.newFile'), action: 'ctx-new-file' }); + items.push(cmd('new-folder', t('ctx.newFolder'), { icon: 'tdesignFolderAdd' })); + items.push(cmd('new-file', t('ctx.newFile'))); items.push({ type: 'separator' }); - items.push({ label: t('ctx.refresh'), action: 'ctx-refresh' }); + items.push(cmd('refresh', t('ctx.refresh'))); if (ctx.isLocal) { items.push({ type: 'separator' }); - items.push({ label: t(revealKey()), action: 'ctx-reveal' }); + items.push(cmd('reveal', t(revealKey()))); } return items; } - items.push({ label: t('ctx.copyToOther'), action: 'ctx-copy' }); + // the arrow points at the other panel, as on the toolbar's Send button + const sendIcon = ctx.side === 'left' ? 'tdesignArrowRight' : 'tdesignArrowLeft'; + items.push(cmd('send', t('ctx.copyToOther'), { icon: sendIcon })); if (ctx.type === 'dir') { - items.push({ label: t('ctx.open'), action: 'ctx-open' }); + items.push(cmd('open', t('ctx.open'))); } else { if (ctx.isLocal) { - items.push({ label: t('ctx.open'), action: 'ctx-open-default', tooltip: t('ctx.openDefault') }); + items.push(cmd('open-default', t('ctx.open'), { tooltip: t('ctx.openDefault') })); } - items.push({ label: t('ctx.edit'), action: 'ctx-edit', accelerator: 'F4' }); + items.push(cmd('edit', t('ctx.edit'), { icon: 'tdesignFile1' })); } - items.push({ label: t('ctx.rename'), action: 'ctx-rename' }); + items.push(cmd('rename', t('ctx.rename'), { icon: 'tdesignEdit' })); // chmod on Windows local files only flips the read-only bit — hide the lie if (!(ctx.isLocal && IS_WINDOWS)) { - items.push({ label: t('ctx.permissions'), action: 'ctx-chmod' }); + items.push(cmd('chmod', t('ctx.permissions'))); } items.push({ type: 'separator' }); - items.push({ label: t('ctx.copyPath'), action: 'ctx-copy-path' }); + items.push(cmd('copy-path', t('ctx.copyPath'))); if (ctx.isLocal) { - items.push({ label: t(revealKey()), action: 'ctx-reveal' }); + items.push(cmd('reveal', t(revealKey()))); } items.push({ type: 'separator' }); if (ctx.isLocal) { - items.push({ - label: t(trashKey()), - action: 'ctx-trash', - ...(IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}), - }); + items.push(cmd('trash', t(trashKey()), IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {})); } - items.push({ label: t('ctx.delete'), action: 'ctx-delete' }); + items.push(cmd('delete', t('ctx.delete'), { icon: 'tdesignDelete' })); return items; } /** * The workspace-tab menu. Actions carry the target tab's id, because the * right-clicked tab is not necessarily the active one — nothing here may - * assume `activeWorkspace`. + * assume `activeWorkspace`. (The hints name the keys, which act on the + * ACTIVE tab; picking by key while this menu is open acts on THIS one.) * * Everything illegal is DISABLED, not omitted: a menu whose items move - * between right-clicks is worse than a dead row, and "Close Tab" greyed out explains - * why the last tab cannot be closed better than its absence does. + * between right-clicks is worse than a dead row, and "Close Tab" greyed out + * explains why the last tab cannot be closed better than its absence does. * * Duplicate/Rename/Save-session are deliberately absent until tabs have an * identity to duplicate (a stored connection id) — the sessions round. @@ -143,24 +180,32 @@ export class ContextMenuService { const t = (key: string) => this.i18n.t(key); const last = ctx.tabCount <= 1; return [ - { label: t('ctx.tabNew'), action: 'tab:new', accelerator: `${MOD_LABEL}T` }, + { label: t('ctx.tabNew'), action: 'tab:new', shortcut: 'new-tab', icon: 'tdesignAdd' }, { type: 'separator' }, { label: t('ctx.tabClose'), action: `tab:close:${ctx.id}`, - accelerator: `${MOD_LABEL}W`, + shortcut: 'close-tab', + icon: 'tdesignClose', + disabled: last, + }, + { + label: t('ctx.tabCloseOthers'), + action: `tab:close-others:${ctx.id}`, + shortcut: 'close-other-tabs', disabled: last, }, - { label: t('ctx.tabCloseOthers'), action: `tab:close-others:${ctx.id}`, disabled: last }, { label: t('ctx.tabCloseRight'), action: `tab:close-right:${ctx.id}`, + shortcut: 'close-tabs-right', disabled: ctx.index >= ctx.tabCount - 1, }, { type: 'separator' }, { label: t('ctx.tabDisconnect'), action: `tab:disconnect:${ctx.id}`, + shortcut: 'disconnect-tab', disabled: !ctx.isRemote, }, ]; diff --git a/ui/src/app/core/dialogs.service.ts b/ui/src/app/core/dialogs.service.ts index 3a34e0a..5b757e4 100644 --- a/ui/src/app/core/dialogs.service.ts +++ b/ui/src/app/core/dialogs.service.ts @@ -47,8 +47,10 @@ export class DialogsService { }); } + /** Any dialog up — the overwrite question included. Reads signals, so an + * effect or computed that asks re-runs when a dialog opens or closes. */ get anyOpen(): boolean { - return !!(this.prompt() || this.confirm() || this.chmod()); + return !!(this.prompt() || this.confirm() || this.chmod() || this.overwrite()); } closeAll(): void { diff --git a/ui/src/app/core/file-actions.service.ts b/ui/src/app/core/file-actions.service.ts index ab40e70..2bc3230 100644 --- a/ui/src/app/core/file-actions.service.ts +++ b/ui/src/app/core/file-actions.service.ts @@ -1,25 +1,50 @@ import { Injectable, inject } from '@angular/core'; import type { FileEntry } from '@shared/types'; -import { ContextMenuService } from './context-menu.service'; +import { ContextMenuService, type MenuPoint } from './context-menu.service'; import { DialogsService } from './dialogs.service'; import { joinPath } from './format'; -import { type PanelState, type Side } from './panels.service'; +import { type PanelState, type Side, type SortKey } from './panels.service'; import { IS_WINDOWS } from './platform'; import { RpcService } from './rpc.service'; import { SettingsService } from './settings.service'; +import type { ShortcutId } from './shortcuts.service'; import { I18nService } from './i18n.service'; import { ToastService } from './toast.service'; +import { TransfersService } from './transfers.service'; -/** The row (or empty space) the user last right-clicked; ctx-* actions apply here. */ -export interface MenuTarget { +/** What a file command acts on: a panel, and the row it was invoked on (the + * cursor row from the keyboard, the right-clicked row from a menu; null = + * the folder itself). */ +export interface FileTarget { side: Side; panel: PanelState; entry: FileEntry | null; } +/** The keymap commands that act on files — everything the keyboard, the + * right-click menu, the macOS menu bar and the panel toolbar run, all through + * run() so the four entry points can't drift apart. */ +export type FileCommand = Extract< + ShortcutId, + | 'open' + | 'open-default' + | 'send' + | 'edit' + | 'rename' + | 'new-folder' + | 'new-file' + | 'chmod' + | 'delete' + | 'trash' + | 'copy-path' + | 'reveal' + | 'refresh' + | `sort-${string}` +>; + /** - * File operations invoked from a panel's toolbar / keyboard. They act on the - * GIVEN panel: on the selection if there is one, else on the cursor row. + * File operations, always against an explicit panel: on its selection if there + * is one, else on the target row. */ @Injectable({ providedIn: 'root' }) export class FileActionsService { @@ -29,29 +54,101 @@ export class FileActionsService { private readonly dialogs = inject(DialogsService); private readonly menu = inject(ContextMenuService); private readonly settings = inject(SettingsService); + private readonly transfers = inject(TransfersService); - menuTarget: MenuTarget | null = null; + /** Run a file command. One switch for every entry point. */ + run(id: FileCommand, target: FileTarget): void { + const { side, panel } = target; + const local = panel.sessionId() === 'local'; + // the ".." row stands for the folder itself, as on the empty-space menu + const entry = target.entry && target.entry.name !== '..' ? target.entry : null; + switch (id) { + case 'open': + case 'open-default': + // folders, symlinks and ".." navigate; a LOCAL file opens in its default + // app — from the menu's Open always, from Enter / double-click only + // when Settings → File list says so + if (!target.entry) return; + if (target.entry.type !== 'file') panel.open(target.entry); + else if (local && (id === 'open-default' || this.settings.openFilesOnEnter())) { + this.shell('open', target.entry.path); + } + return; + case 'send': + void this.transfers.copyToOtherPanel(side); + return; + case 'edit': + void this.edit(panel); + return; + case 'rename': + this.rename(panel); + return; + case 'new-folder': + this.mkdir(panel); + return; + case 'new-file': + this.createFile(panel); + return; + case 'chmod': + this.chmod(panel); + return; + case 'delete': + this.delete(panel); + return; + case 'trash': + if (local) void this.trash(panel); + return; + case 'copy-path': + this.shell('copyPath', entry?.path ?? panel.cwd()); + return; + case 'reveal': + if (local) this.shell('reveal', entry?.path ?? panel.cwd()); + return; + case 'refresh': + void panel.refresh(); + return; + default: + // sort-name, sort-size…: again = reverse + if (id.startsWith('sort-')) panel.toggleSort(id.slice('sort-'.length) as SortKey); + } + } - /** Right-click entry point: remember the target, then open the menu on it. */ - openContextMenu(event: MouseEvent, target: MenuTarget): void { - this.menuTarget = target; - const { panel, entry } = target; + /** Open the row / empty-space menu on `target`; its picks run here. */ + openContextMenu(point: MenuPoint, target: FileTarget, keyboard = false): void { + const { side, panel, entry } = target; this.menu.open( - event, + point, this.menu.rowItems({ - path: entry?.path ?? panel.cwd(), + side, name: entry?.name ?? '', type: entry?.type ?? 'dir', isLocal: panel.sessionId() === 'local', hasEntry: !!entry, }), + { keyboard, onPick: (action) => this.run(action as FileCommand, target) }, ); } - async trash(panel: PanelState, entry: FileEntry): Promise { - const res = await this.rpc.request.moveToTrash({ path: entry.path }); - if (!res.ok) this.toast.error(res.error); - else this.toast.info(this.i18n.t('toasts.movedToTrash', { name: entry.name })); + /** Local shell integration — only bun can reach the OS shell / clipboard. */ + private shell(action: 'open' | 'reveal' | 'copyPath', path: string): void { + void this.rpc.request.shellItemAction({ action, path }); + } + + /** Move the selection (else the target row) to the Trash, one item at a time. */ + async trash(panel: PanelState): Promise { + const targets = this.targets(panel); + if (!targets.length) return; + let moved = 0; + for (const entry of targets) { + const res = await this.rpc.request.moveToTrash({ path: entry.path }); + if (!res.ok) { + this.toast.error(res.error); + break; + } + moved++; + } + if (moved === 1) this.toast.info(this.i18n.t('toasts.movedToTrash', { name: targets[0].name })); + else if (moved > 1) this.toast.info(this.i18n.t('toasts.movedManyToTrash', { count: moved })); await panel.refresh(); } diff --git a/ui/src/app/core/focus-cycle.ts b/ui/src/app/core/focus-cycle.ts new file mode 100644 index 0000000..96d58ec --- /dev/null +++ b/ui/src/app/core/focus-cycle.ts @@ -0,0 +1,36 @@ +/** What Tab can land on. Tab order is DOM order: the app sets no positive tabindex. */ +const FOCUSABLE = 'button, input, select, textarea, [tabindex]'; + +/** + * Tab / Shift+Tab through the focusable controls under `root`, in DOM order, + * wrapping. Done by hand because WKWebView (macOS) skips buttons, checkboxes + * and radios on Tab unless the user turned on System Settings → Keyboard → + * Keyboard navigation, which left dialogs, Settings and the connection manager + * mouse-only there. It is also the focus trap the modal dialogs lacked. A radio + * group is one stop (its checked radio), as natively; arrows move inside it. + * Returns true when it took the key. + */ +export function cycleFocus(root: Element, event: KeyboardEvent): boolean { + if (event.key !== 'Tab' || event.metaKey || event.ctrlKey || event.altKey) return false; + const stops = [...root.querySelectorAll(FOCUSABLE)].filter(isTabStop); + if (!stops.length) return false; + event.preventDefault(); + const at = stops.indexOf(document.activeElement as HTMLElement); + const step = event.shiftKey ? -1 : 1; + const next = at < 0 ? (step > 0 ? 0 : stops.length - 1) : (at + step + stops.length) % stops.length; + stops[next].focus(); + return true; +} + +function isTabStop(el: HTMLElement): boolean { + if (el.tabIndex < 0 || el.matches(':disabled')) return false; + if (!el.getClientRects().length) return false; // display:none, or inside a hidden parent + if (el instanceof HTMLInputElement && el.type === 'radio' && el.name) { + const group = [ + ...document.querySelectorAll(`input[type=radio][name="${CSS.escape(el.name)}"]`), + ]; + const checked = group.find((radio) => radio.checked); + return el === (checked ?? group[0]); + } + return true; +} diff --git a/ui/src/app/core/panels.service.ts b/ui/src/app/core/panels.service.ts index b90f7e2..839c924 100644 --- a/ui/src/app/core/panels.service.ts +++ b/ui/src/app/core/panels.service.ts @@ -352,6 +352,19 @@ export class PanelsService { this.focus.bump(); } + /** Each file list's key handler. A key that lands on — a mouse click + * dropped focus (WebKit doesn't focus clicked buttons) — still reaches the + * active list through forwardListKey instead of dying until the next click. */ + private readonly listKeys = new Map void>(); + + registerListKeys(side: Side, handler: (event: KeyboardEvent) => void): void { + this.listKeys.set(side, handler); + } + + forwardListKey(event: KeyboardEvent): void { + this.listKeys.get(this.activeSide())?.(event); + } + // ---- workspace (tab) management ---- newWorkspace(): void { diff --git a/ui/src/app/core/platform.ts b/ui/src/app/core/platform.ts index 203ce33..80d4620 100644 --- a/ui/src/app/core/platform.ts +++ b/ui/src/app/core/platform.ts @@ -21,10 +21,6 @@ export const IS_WINDOWS = PLATFORM === 'win32'; export const IS_LINUX = PLATFORM === 'linux'; -/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */ -export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+'; -export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+'; - export const APP_MODE: 'main' | 'editor' | 'manager' | 'settings' = typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main'; @@ -35,6 +31,18 @@ export const EDITOR_CONTEXT = * hiddenInset layout, see chromeOptions), which reserves `pl-24` for them. */ export const IS_MAC_CHROME = IS_MAC; +/** + * WebView2 (Windows) reloads the whole page on F5 / Ctrl+R / Ctrl+F5 / + * Ctrl+Shift+R unless the page claims the key — dropping every session and any + * unsaved editor text. Every window root calls this for keys none of its + * bindings took. Known ceiling: unverified whether Electrobun already turns + * WebView2's browser keys off; harmless if it does. + */ +export function blockReloadKeys(event: KeyboardEvent): void { + if (!IS_WINDOWS || event.defaultPrevented) return; + if (event.key === 'F5' || (event.ctrlKey && event.code === 'KeyR')) event.preventDefault(); +} + /** * Kill the webview's default right-click menu (Reload, Inspect Element…) * everywhere the app doesn't show its own native menu. Text fields keep the diff --git a/ui/src/app/core/settings.service.ts b/ui/src/app/core/settings.service.ts index d7b55e6..28103eb 100644 --- a/ui/src/app/core/settings.service.ts +++ b/ui/src/app/core/settings.service.ts @@ -17,6 +17,9 @@ export class SettingsService { readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted); readonly editor = signal(DEFAULT_SETTINGS.editor); readonly editorApp = signal(DEFAULT_SETTINGS.editorApp); + /** Settings → Keyboard overrides; ShortcutsService applies them to the keymap. */ + readonly keybindings = signal(DEFAULT_SETTINGS.keybindings); + readonly openFilesOnEnter = signal(DEFAULT_SETTINGS.openFilesOnEnter); // Auto-refresh is always on for now (see panels.service). To make it // user-controllable: uncomment the field in types.ts, then here add // readonly autoRefresh = signal(DEFAULT_SETTINGS.autoRefresh); @@ -25,6 +28,10 @@ export class SettingsService { private readonly rpc = inject(RpcService); private readonly i18n = inject(I18nService); + /** Saves are whole-object: one sent before the first getSettings lands would + * write these defaults over the user's settings (a ⌘+ pressed as a window + * opens would wipe every keyboard remap), so nothing persists until then. */ + private loaded = false; /** Whether the app is actually rendering dark right now (drives the Tone row). */ readonly effectiveDark = computed( @@ -45,6 +52,7 @@ export class SettingsService { } private applyAll(settings: AppSettings): void { + this.loaded = true; this.locale.set(settings.locale); this.theme.set(settings.theme); this.darkTone.set(settings.darkTone); @@ -56,6 +64,8 @@ export class SettingsService { this.autoClearCompleted.set(settings.autoClearCompleted); this.editor.set(settings.editor); this.editorApp.set(settings.editorApp); + this.keybindings.set(settings.keybindings); + this.openFilesOnEnter.set(settings.openFilesOnEnter); this.applyTheme(); this.applyDarkTone(); this.applyUiScale(); @@ -123,6 +133,16 @@ export class SettingsService { void this.persist(); } + setKeybindings(keybindings: AppSettings['keybindings']): void { + this.keybindings.set(keybindings); + void this.persist(); + } + + setOpenFilesOnEnter(open: boolean): void { + this.openFilesOnEnter.set(open); + void this.persist(); + } + private applyTheme(): void { const theme = this.theme(); if (theme === 'system') { @@ -172,10 +192,13 @@ export class SettingsService { autoClearCompleted: this.autoClearCompleted(), editor: this.editor(), editorApp: this.editorApp(), + keybindings: this.keybindings(), + openFilesOnEnter: this.openFilesOnEnter(), }; } private async persist(): Promise { + if (!this.loaded) return; await this.rpc.request.saveSettings({ settings: this.settings() }); } } diff --git a/ui/src/app/core/shortcuts.service.ts b/ui/src/app/core/shortcuts.service.ts index 513fa15..9f623b8 100644 --- a/ui/src/app/core/shortcuts.service.ts +++ b/ui/src/app/core/shortcuts.service.ts @@ -1,114 +1,161 @@ -import { Injectable } from '@angular/core'; +import { Injectable, computed, inject } from '@angular/core'; +import { + type Combo, + EN_KEY_NAMES, + effectiveKeymap, + firesInTextField, + formatCombo, + isRepeatable, + type KeyEventLike, + type KeyNames, + matchesEvent, + parseCombo, + type ResolvedBinding, + type Scope, + sameCombo, + serializeCombo, + type ShortcutId, +} from '@shared/keymap'; +import { I18nService } from './i18n.service'; +import { IS_MAC } from './platform'; +import { SettingsService } from './settings.service'; + +export type { ShortcutId } from '@shared/keymap'; + +/** A keydown as resolve() reads it: the matching fields plus where it came from. */ +type KeyInput = KeyEventLike & { target?: EventTarget | null; isComposing?: boolean; keyCode?: number }; /** - * Every window-level shortcut the app answers to, by name. Components switch - * on these ids instead of key-matching inline, so the whole keymap is ONE - * table — the precondition for the vim-style bindings planned next, which - * will need a mode/leader state machine wrapped around exactly this point. - */ -export type ShortcutId = - // main window (workspace/tab management) - | 'new-tab' - | 'close-tab' - | 'cycle-tab' - | 'cycle-tab-back' - | 'go-to-tab' // 1-9; the digit rides in ShortcutMatch.digit - | 'open-settings' - // editor window - | 'save' - | 'find' - | 'close-window' - | 'toggle-wrap' - // any window - | 'scale-up' - | 'scale-down' - | 'scale-reset'; - -export interface ShortcutMatch { - id: ShortcutId; - /** Only for go-to-tab: the digit pressed, 1–9. */ - digit?: number; -} - -/** Which keymap a window consults. 'manager' and 'settings' answer only to the - * any-window shortcuts (scale). Escape/Tab stay component-local: their meaning - * is contextual state (find bar open? close prompt up? which pane?), not a - * global binding. */ -export type ShortcutScope = 'main' | 'editor' | 'manager' | 'settings'; - -/** - * The keyboard layer: modifier helpers + one declarative keymap. + * The keyboard layer over the shared keymap (src/shared/keymap.ts): resolves + * keydowns against the EFFECTIVE keymap — defaults plus the Settings → Keyboard + * remaps, live in every window — and formats the hints menus and tooltips show. * - * House rules encoded here rather than re-derived per component (they were - * previously copy-pasted inline in six places, drifting apart): - * - `mod` is metaKey OR ctrlKey — ⌘ on macOS, Ctrl elsewhere. The keyboard - * does not change with the window chrome (platform.ts says the same about - * MOD_LABEL). - * - Layout-dependent keys match on `event.code`, not `event.key`: digits - * (AZERTY's digit row shifts), +/- (key varies wildly across layouts), and - * letter chords with Alt (macOS Option produces Ω for ⌥Z). Plain letter - * chords match on `key` so they follow the user's layout. - * - isTypingTarget guards shortcuts that would be hostile mid-typing. It is - * deliberately NOT applied inside resolve(): the existing bindings rely on - * the path bar's stopPropagation and the editor WANTS Mod+S while typing. - * Callers opt in per shortcut (the scale keys do). + * - Matching is exact: ⌃F is not ⌘F, Ctrl+Tab is not Tab. `Mod` is ⌘ on + * macOS and Ctrl elsewhere. + * - On macOS the PAGE sees ⌘-keys first: WKWebView hands key equivalents to the + * page before the app menu (WebKit's WebViewImpl::performKeyEquivalent), so a + * key a window claims here (claim() → preventDefault) never reaches the menu + * bar, whose accelerators bun builds from the same table. Keys nobody claims + * fall through to it. + * - From a text field only firesInTextField() combos count (⌘S yes, Delete no). + * - Escape, the arrows inside menus and dialog Enter stay component-local: + * they are fixed, so nobody can remap themselves out of a dialog. */ @Injectable({ providedIn: 'root' }) export class ShortcutsService { - /** The platform command modifier: ⌘ on macOS, Ctrl elsewhere. Mouse events - * qualify too — Mod+click toggle-select in the panels uses the same rule. */ - isMod(event: KeyboardEvent | MouseEvent): boolean { - return event.metaKey || event.ctrlKey; - } + private readonly settings = inject(SettingsService); + private readonly i18n = inject(I18nService); - /** True when the event originates from something the user types into. */ - isTypingTarget(event: KeyboardEvent): boolean { - const target = event.target as HTMLElement | null; - return !!target?.closest('input, textarea, select, [contenteditable="true"]'); + readonly keymap = computed(() => effectiveKeymap(this.settings.keybindings(), IS_MAC)); + private readonly byId = computed(() => new Map(this.keymap().map((b) => [b.id, b]))); + /** Localized key names for the non-mac spelling ("Strg+Entf"); reading t() + * makes every hint follow a language switch. */ + private readonly names = computed(() => { + const names = { ...EN_KEY_NAMES }; + for (const key of Object.keys(names) as (keyof KeyNames)[]) { + names[key] = this.i18n.t(`keys.names.${key}`); + } + return names; + }); + + /** The command a keydown triggers in `scope`, or null. Window scopes include + * `any`; `panel` doesn't (the window root runs those). */ + resolve(event: KeyInput, scope: Scope): ShortcutId | null { + // an IME's Enter commits a candidate, it is never a shortcut + if (event.isComposing || event.keyCode === 229) return null; + const typing = this.isTypingTarget(event); + for (const binding of this.keymap()) { + if (!binding.scopes.includes(scope) && (scope === 'panel' || !binding.scopes.includes('any'))) { + continue; + } + for (const combo of binding.combos) { + if (typing && !firesInTextField(combo, IS_MAC)) continue; + if (matchesEvent(combo, event, IS_MAC)) return binding.id; + } + } + return null; } /** - * Match an event against the scope's keymap. Returns null for anything the - * table does not claim — callers must then leave the event alone. + * resolve() + take the key: a match is preventDefault-ed, so the macOS menu + * never runs it a second time. A held key repeats only movement-like + * commands; any other repeat is swallowed (holding ⌘W closes one tab). */ - resolve(event: KeyboardEvent, scope: ShortcutScope): ShortcutMatch | null { - const mod = this.isMod(event); - const key = event.key.toLowerCase(); + claim(event: KeyboardEvent, scope: Scope): ShortcutId | null { + const id = this.resolve(event, scope); + if (!id) return null; + event.preventDefault(); + return event.repeat && !isRepeatable(id) ? null : id; + } - // ---- any window: interface scale ---- - // event.code, not key: '+' needs Shift on many layouts and '-' moves - // around; the physical Equal/Minus keys (and the numpad pair) are stable. - if (mod && !event.altKey) { - if (event.code === 'Equal' || event.code === 'NumpadAdd') return { id: 'scale-up' }; - if (event.code === 'Minus' || event.code === 'NumpadSubtract') return { id: 'scale-down' }; - if (event.code === 'Digit0' || event.code === 'Numpad0') return { id: 'scale-reset' }; + /** Whether a keydown is one of `id`'s combos (an open menu matching its items). */ + matches(id: ShortcutId, event: KeyEventLike): boolean { + return !!this.byId().get(id)?.combos.some((c) => matchesEvent(c, event, IS_MAC)); + } + + binding(id: ShortcutId): ResolvedBinding | undefined { + return this.byId().get(id); + } + + format(combo: Combo): string { + return formatCombo(combo, IS_MAC, this.names()); + } + + /** The hint menus show: the command's first key, or '' when unbound. */ + label(id: ShortcutId): string { + const first = this.byId().get(id)?.combos[0]; + return first ? this.format(first) : ''; + } + + labels(id: ShortcutId): string[] { + return (this.byId().get(id)?.combos ?? []).map((c) => this.format(c)); + } + + /** A tooltip with the command's keys: "Delete (F8 / Del)"; bare when unbound. */ + tip(text: string, id: ShortcutId): string { + const keys = this.labels(id); + 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 { + const parsed = parseCombo(combo, IS_MAC); + return parsed ? `${text} (${this.format(parsed)})` : text; + } + + /** + * Store new combos for commands (Settings → Keyboard). Only rows that differ + * from their defaults are kept, so a reset — or a remap back to the + * default — leaves nothing behind. + */ + rebind(changes: Partial>): void { + const next = { ...this.settings.keybindings() }; + for (const [id, combos] of Object.entries(changes) as [ShortcutId, Combo[]][]) { + const defaults = this.byId().get(id)?.defaults ?? []; + const isDefault = + combos.length === defaults.length && combos.every((c, i) => sameCombo(c, defaults[i])); + if (isDefault) delete next[id]; + else next[id] = combos.map(serializeCombo); } + this.settings.setKeybindings(next); + } - if (scope === 'manager' || scope === 'settings') return null; // any-window shortcuts only + resetAll(): void { + this.settings.setKeybindings({}); + } - if (scope === 'main') { - // on macOS the app menu's ⌘, intercepts first; this is the Windows/Linux path - if (mod && key === ',') return { id: 'open-settings' }; - if (mod && key === 't') return { id: 'new-tab' }; - if (mod && key === 'w') return { id: 'close-tab' }; - // ctrlKey only, never meta: ⌘Tab is the macOS app switcher - if (event.ctrlKey && event.key === 'Tab') { - return { id: event.shiftKey ? 'cycle-tab-back' : 'cycle-tab' }; - } - if (mod && !event.shiftKey && !event.altKey) { - const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1]; - if (digit) return { id: 'go-to-tab', digit: Number(digit) }; - } - return null; - } + /** The platform command modifier: ⌘ on macOS, Ctrl elsewhere — exactly one. + * Mouse events too: Mod+click toggle-selects in the panels. */ + isMod(event: KeyboardEvent | MouseEvent): boolean { + return IS_MAC ? event.metaKey : event.ctrlKey; + } - // scope === 'editor' - if (mod && key === 's') return { id: 'save' }; - if (mod && key === 'f') return { id: 'find' }; - // on macOS the app menu intercepts ⌘W before this ever fires; this arm is - // the Windows/Linux path - if (mod && key === 'w') return { id: 'close-window' }; - if (event.altKey && event.code === 'KeyZ') return { id: 'toggle-wrap' }; - return null; + /** True when the event comes from something the user types into. */ + isTypingTarget(event: { target?: EventTarget | null }): boolean { + const target = event.target as HTMLElement | null; + return !!target?.closest?.( + 'textarea, select, [contenteditable="true"], input:not([type=checkbox]):not([type=radio]):not([type=button])', + ); } } diff --git a/ui/src/app/core/updater.service.ts b/ui/src/app/core/updater.service.ts index f813260..4279dab 100644 --- a/ui/src/app/core/updater.service.ts +++ b/ui/src/app/core/updater.service.ts @@ -45,6 +45,14 @@ export class UpdaterService { void this.rpc.request.applyUpdate({}); // quits + relaunches } + /** The pill's one action, also the "Install update" key: download when + * available, apply (relaunch) once ready; nothing otherwise. */ + advance(): void { + const state = this.state(); + if (state === 'available') void this.download(); + else if (state === 'ready') this.apply(); + } + /** Manual re-check (menu item). */ async check(): Promise { if (!this.rpc.available) return; diff --git a/ui/src/app/skins/shadlike/app.html b/ui/src/app/skins/shadlike/app.html index c36db3b..56bfe17 100644 --- a/ui/src/app/skins/shadlike/app.html +++ b/ui/src/app/skins/shadlike/app.html @@ -31,6 +31,7 @@

{{ i18n.t('app.managerOpen') }}

} @@ -86,30 +107,37 @@ const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size }) export class ContextMenu { readonly menu = inject(ContextMenuService); + readonly shortcuts = inject(ShortcutsService); private readonly focus = inject(FocusService); private readonly menuEl = viewChild>('menuEl'); readonly activeIndex = signal(-1); /** Click point, flipped/clamped so the menu never overflows the viewport. - * All UI metrics convert through the live root font-size; window.inner* is - * viewport CSS px, so both sides of every comparison share units. */ + * A menu taller than the viewport (a long source picker) scrolls inside a + * clamped box. All UI metrics convert through the live root font-size; + * window.inner* is viewport CSS px, so both sides of every comparison share + * units. */ readonly position = computed(() => { const req = this.menu.request(); - if (!req) return { x: 0, y: 0 }; - const itemH = remPx(ITEM_H_REM); - const separatorH = remPx(SEPARATOR_H_REM); + if (!req) return { x: 0, y: 0, maxHeight: 0 }; + const heights = { + separator: remPx(SEPARATOR_H_REM), + header: remPx(HEADER_H_REM), + item: remPx(ITEM_H_REM), + }; + const content = + req.items.reduce((sum, i) => sum + heights[i.type ?? 'item'], 0) + remPx(0.5); // py-1 ×2 + const maxHeight = window.innerHeight - 2 * EDGE_GAP; + const height = Math.min(content, maxHeight); const menuW = remPx(MENU_W_REM); - const height = - req.items.reduce((sum, i) => sum + (i.type === 'separator' ? separatorH : itemH), 0) + - remPx(0.5); // the menu's py-1 (0.25rem x2) const x = req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : req.x; const y = req.y + height > window.innerHeight - EDGE_GAP ? Math.max(EDGE_GAP, req.y - height) : req.y; - return { x, y }; + return { x, y, maxHeight }; }); constructor() { @@ -117,22 +145,25 @@ export class ContextMenu { // the viewChild signal, NOT on request(): under zoneless CD an effect can // run before the @if has rendered the element. effect(() => this.menuEl()?.nativeElement.focus()); + // a fresh menu starts with nothing highlighted — or, opened from the + // keyboard, with its first item + effect(() => this.activeIndex.set(this.menu.request()?.initialIndex ?? -1)); } close(): void { if (!this.menu.request()) return; this.menu.close(); - this.activeIndex.set(-1); // a fresh menu starts with nothing highlighted this.focus.bump(); } pick(item: MenuItem): void { // the keyboard path never consults the disabled attribute — guard here - if (item.disabled) return; - if (!item.action) return; + if (item.disabled || !item.action) return; const action = item.action; + const onPick = this.menu.request()?.onPick; this.close(); - this.menu.onPick(action); + if (onPick) onPick(action); + else this.menu.emit(action); } /** Right-clicking the backdrop closes rather than stacking a second menu. */ @@ -143,33 +174,48 @@ export class ContextMenu { onKeydown(event: KeyboardEvent): void { const items = this.menu.request()?.items ?? []; + const selectable = items.map(isSelectable); switch (event.key) { case 'Escape': - event.preventDefault(); - event.stopPropagation(); this.close(); break; case 'ArrowDown': - event.preventDefault(); - this.step(items, 1); + this.highlight(stepIndex(selectable, this.activeIndex(), 1)); break; case 'ArrowUp': - event.preventDefault(); - this.step(items, -1); + this.highlight(stepIndex(selectable, this.activeIndex(), -1)); + break; + case 'Home': + this.highlight(stepIndex(selectable, -1, 1)); + break; + case 'End': + this.highlight(stepIndex(selectable, -1, -1)); break; case 'Enter': case ' ': { - event.preventDefault(); const item = items[this.activeIndex()]; if (item) this.pick(item); break; } + case 'Tab': + // focus stays on the menu: the items are not Tab stops + break; + default: { + // an item's own shortcut picks it, as in a native menu + const hit = items.find( + (i) => isSelectable(i) && i.shortcut && this.shortcuts.matches(i.shortcut, event), + ); + if (!hit) return; // not ours: let it bubble + this.pick(hit); + } } + event.preventDefault(); + event.stopPropagation(); } - /** Move the highlight by one selectable item (see stepIndex). */ - private step(items: MenuItem[], delta: 1 | -1): void { - const selectable = items.map((i) => i.type !== 'separator' && !i.disabled); - this.activeIndex.set(stepIndex(selectable, this.activeIndex(), delta)); + /** Move the highlight and keep it in view (the menu may scroll). */ + private highlight(index: number): void { + this.activeIndex.set(index); + document.getElementById(`ctx-item-${index}`)?.scrollIntoView({ block: 'nearest' }); } } diff --git a/ui/src/app/skins/shadlike/dialogs/chmod-dialog.ts b/ui/src/app/skins/shadlike/dialogs/chmod-dialog.ts index d661338..93630f3 100644 --- a/ui/src/app/skins/shadlike/dialogs/chmod-dialog.ts +++ b/ui/src/app/skins/shadlike/dialogs/chmod-dialog.ts @@ -1,6 +1,16 @@ -import { Component, computed, inject, input, signal } from '@angular/core'; +import { + Component, + ElementRef, + afterNextRender, + computed, + inject, + input, + signal, + viewChild, +} from '@angular/core'; import { I18nService } from '@core/i18n.service'; import { DialogsService, type ChmodRequest } from '@core/dialogs.service'; +import { cycleFocus } from '@core/focus-cycle'; import { FocusService } from '@core/focus.service'; const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const; @@ -11,9 +21,12 @@ const PERMS = ['r', 'w', 'x'] as const; template: `