Remap any shortcut in Settings → Keyboard; the macOS menu bar follows

A Keyboard section in the Settings window edits the shared keymap: rows
by group with a search field, a chip per key (x removes), + to record,
reset per row and Reset all. Recording takes the keydown before every
other key in the window (Cmd+W records instead of closing), refuses
reserved combos without preventDefault (Cmd+Q still quits), requires a
chord or F-key for editor commands, and offers Replace when another
command in an overlapping scope owns the key. Rows flag conflicts and
plain character keys that would stop starting a type-to-filter query.
Fixed keys are listed read-only, in the platform's spelling.

Remaps are ordinary settings (only rows that differ from the defaults),
so every window, the menu hints and the tooltips follow live — and the
macOS menu bar: its accelerators are built from the keymap (only the
combos Electrobun's parser expresses faithfully) and rebuilt on a remap.
A main-window menu action fired from the editor or Settings window
brings the main window forward first.

Also: the "Enter / double-click on a file" setting under File list (do
nothing / open local files in their default app), arrow keys in the
Settings sidebar with focus on open and a hand-walked Tab, translations
for all seven languages (the spec now checks every language labels every
command), and a README shortcut table generated from the keymap.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-10-05 14:57:27 +02:00
co-authored by Claude
parent d361297690
commit 8f8f230711
18 changed files with 1256 additions and 50 deletions
+99 -9
View File
@@ -35,13 +35,15 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu
a standard titlebar.
- **Menu-bar (tray) icon**: template icon that adapts to light/dark; click it
to bring the app to the front.
- **Edit files** (F4 / toolbar / File menu): a built-in editor works on local
- **Edit files** (F4 / ⌘E / toolbar / File menu): a built-in editor works on local
and remote files directly; or pick an external app (Settings → Editor →
Custom → Choose…). External edits of remote files download to a temp folder,
are watched, and every save auto-uploads (visible in the transfer list) until
the session disconnects. Leftover temp files from a crash are offered for
cleanup at the next start.
- Browse, sort (name/size/modified, folders first), keyboard navigation.
- Browse, sort (name/size/modified, folders first), full keyboard control
with remappable shortcuts (see below). Enter / double-click on a local file
can open it in its default app (Settings → File list; off by default).
- Create folder / file, rename, delete (recursive), change permissions (chmod).
- Upload, download, and remote→remote send — files **and** whole folders —
with a transfer queue, per-job progress, and cancel.
@@ -178,14 +180,102 @@ Saved connections live in a plain JSON file in the OS app-data directory
## Keyboard shortcuts
`Tab` switch panel · `↑`/`↓`/`PgUp`/`PgDn`/`Home`/`End` move cursor ·
`Space` select · `Enter` open folder · `Backspace` parent ·
`Ctrl/Cmd+A` select all · `F5` send to other panel · `F2` rename ·
`F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab ·
`Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab · `Cmd/Ctrl+,` settings ·
`Cmd/Ctrl+=`/`-`/`0` interface bigger / smaller / 100%.
The whole app works from the keyboard, and **every command can be remapped** in
**Settings → Keyboard**: press **+** on a row and type the keys, see conflicts,
reset one row or all. Remaps apply live in every window, the right-click menus'
hints and the toolbar tooltips follow, and so does the macOS menu bar. The
defaults:
(On Macs, function keys may need `fn` — every action also has a toolbar button.)
| Command | macOS | Windows / Linux |
| --- | --- | --- |
| **File list** | | |
| Move up | `↑` | `↑` |
| Move down | `↓` | `↓` |
| Page up | `PgUp` | `PgUp` |
| Page down | `PgDn` | `PgDn` |
| First item | `Home` | `Home` |
| Last item | `End` | `End` |
| Select / deselect | `Space` | `Space` `Ins` |
| Select all | `⌘A` | `Ctrl+A` |
| Open | `↩` `⌘↓` | `Enter` |
| Parent folder | `⌫` `⌘↑` | `Backspace` `Alt+↑` |
| Switch panel | `⇥` `⇧⇥` | `Tab` `Shift+Tab` |
| Show the item's menu | `⇧F10` `⌃↩` | `Shift+F10` `Menu` |
| **Files** | | |
| Send to the other panel | `F5` | `F5` |
| Edit | `⌘E` `F4` | `F4` `Ctrl+E` |
| Rename | `F2` | `F2` |
| New folder | `⇧⌘N` `F7` | `F7` `Ctrl+Shift+N` |
| New file | `⌘N` `⇧F4` | `Shift+F4` `Ctrl+N` |
| Permissions | `⌘I` | `Ctrl+I` |
| Delete | `F8` `⌦` | `F8` `Del` |
| Move to Trash | `⌘⌫` | `Ctrl+Del` |
| Copy path | `⌥⌘C` | `Ctrl+Shift+C` |
| Reveal in file manager | `⌥⌘R` | `Alt+Shift+R` |
| Open in default app | `⌘O` | `Ctrl+O` |
| Refresh | `⌘R` | `Ctrl+R` |
| Go to path | `⌘L` | `Ctrl+L` |
| Choose what the panel shows | `⌘K` | `Ctrl+K` |
| **Sorting** | | |
| Sort by name | `⌥1` | `Alt+1` |
| Sort by size | `⌥2` | `Alt+2` |
| Sort by modified | `⌥3` | `Alt+3` |
| Sort by kind | `⌥4` | `Alt+4` |
| Sort by rights | `⌥5` | `Alt+5` |
| Sort by owner | `⌥6` | `Alt+6` |
| Sort by group | `⌥7` | `Alt+7` |
| **Tabs** | | |
| New tab | `⌘T` | `Ctrl+T` |
| Close tab | `⌘W` | `Ctrl+W` |
| Close other tabs | `⌥⌘W` | `Ctrl+Shift+W` |
| Close tabs to the right | — | — |
| Next tab | `⌃⇥` `⇧⌘]` | `Ctrl+Tab` `Ctrl+PgDn` |
| Previous tab | `⌃⇧⇥` `⇧⌘[` | `Ctrl+Shift+Tab` `Ctrl+PgUp` |
| Go to tab 1 | `⌘1` | `Ctrl+1` |
| Go to tab 2 | `⌘2` | `Ctrl+2` |
| Go to tab 3 | `⌘3` | `Ctrl+3` |
| Go to tab 4 | `⌘4` | `Ctrl+4` |
| Go to tab 5 | `⌘5` | `Ctrl+5` |
| Go to tab 6 | `⌘6` | `Ctrl+6` |
| Go to tab 7 | `⌘7` | `Ctrl+7` |
| Go to tab 8 | `⌘8` | `Ctrl+8` |
| Go to last tab | `⌘9` | `Ctrl+9` |
| Move tab left | `⌃⇧PgUp` | `Ctrl+Shift+PgUp` |
| Move tab right | `⌃⇧PgDn` | `Ctrl+Shift+PgDn` |
| Disconnect tab | `⌘D` | `Ctrl+D` |
| **Window** | | |
| Settings | `⌘,` | `Ctrl+,` |
| Connection manager | `⇧⌘K` | `Ctrl+Shift+K` |
| Show / hide transfers | `⌘J` | `Ctrl+J` |
| Install update | — | — |
| Bigger interface | `⌘=` `⇧⌘=` `⌘Num +` | `Ctrl+=` `Ctrl+Shift+=` `Ctrl+Num +` |
| Smaller interface | `⌘-` | `Ctrl+-` |
| Interface at 100% | `⌘0` | `Ctrl+0` |
| Close window | `⌘W` | `Ctrl+W` |
| **Editor** | | |
| Save | `⌘S` | `Ctrl+S` |
| Find | `⌘F` | `Ctrl+F` |
| Find next | `⌘G` | `F3` |
| Find previous | `⇧⌘G` | `Shift+F3` |
| Wrap lines | `⌥Z` | `Alt+Z` |
| **Connection manager** | | |
| New connection | `⌘N` | `Ctrl+N` |
| New stack | `⇧⌘N` | `Ctrl+Shift+N` |
| Save connection | `⌘S` | `Ctrl+S` |
| Connect / open stack | `⌘↩` | `Ctrl+Enter` |
| Delete connection or stack | `⌘⌫` | `Del` |
| Rename stack | `F2` | `F2` |
| Import from WinSCP | `⌘O` | `Ctrl+O` |
**Fixed keys** (not remappable): Esc closes menus, dialogs and the find bar —
in the file list it clears the filter, then cancels a connect, then clears the
selection. Enter / Esc confirm / cancel dialogs and Tab walks their controls
(on macOS too, without the system's Keyboard navigation setting). Typing in the
file list filters it (Backspace trims); Shift + any movement key selects the
rows the cursor passes. ↑ ↓ Home End Enter work in every menu.
(On Macs, function keys may need `fn` — every F-key command also has a ⌘ key
or a toolbar button.)
## Same-file protection
+35 -7
View File
@@ -131,10 +131,27 @@ main) and, on macOS, the app menu's "Settings…". Its RPC serves only
`getSettings`/`saveSettings`/`pickEditorApp`/`showAbout`/`settingsClose`. Its
page claims its own close-window key (`settingsClose`); a ⌘W it doesn't claim
falls through to the app menu's Close Tab, and bun checks the Settings
window's focus FIRST and closes it (see gotchas). Sidebar sections: Appearance,
Transfers, Editor, File list (visible columns — the column-header right-click
menu toggles the same setting), About (opens the native About box). The
native title is `options.title`, retitled on a locale switch.
window's focus FIRST and closes it (see gotchas). Sidebar sections (↑ / ↓ move
between them, focus starts on the current one, Tab is walked by `cycleFocus`):
Appearance, Transfers, Editor, File list (visible columns — the column-header
right-click menu toggles the same setting — and "Enter / double-click on a
file": nothing | open local files in their default app), Keyboard, About
(opens the native About box). The native title is `options.title`, retitled on
a locale switch.
**Keyboard** is the remap editor over the shared keymap: rows by group with a
search field (label or key text), a chip per combo (× removes), **+** records
(the next non-modifier keydown, taken before every other key in the window so
⌘W records instead of closing; Esc cancels; a click elsewhere or a blur ends
it), ↺ resets a row, Reset all (inline confirm). Reserved combos are refused
without `preventDefault` (⌘Q still quits); editor-scope combos must be chords
or F-keys (the editor's text area always has focus); a combo owned by an
overlapping-scope command offers **Replace** (moves it). Rows flag conflicts
and plain character keys on file-list rows (they no longer start a filter).
Fixed keys are listed read-only below. Every edit is `ShortcutsService.rebind`
→ `settings.keybindings` (only rows that differ from the defaults) → the
ordinary save + broadcast, so menus, tooltips and the macOS menu bar follow
live.
## Bun modules
@@ -185,9 +202,16 @@ native title is `options.title`, retitled on a locale switch.
atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
spread-merge on load, so NEW SETTINGS KEYS need no backend change.
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
Connect submenu mirrors the store; "Settings…" carries ⌘,). ⌘W is intercepted
by the menu, so bun routes `close-tab` to a focused Settings or editor window
first. Labels go through
Connect submenu mirrors the store), language switch and keyboard remap.
**Accelerators come from the shared keymap** (`effectiveKeymap` +
`toAccelerator`, the first combo the native parser can express faithfully:
⌘/⌃ chords only — a bare key means ⌘+key to it — no ⌫ / ⌦ / Shift+punctuation);
⌘Q stays fixed. The keybindings are read through a getter AFTER the
connections load, so a remap saved meanwhile isn't lost. The page claims the
keys it handles first, so the menu only runs what falls through; a
`close-tab` that still arrives while a Settings or editor window is focused
closes THAT window, and other main-targeted actions bring the main window
forward first. Labels go through
backend `i18n.t` and the menu is rebuilt on a locale change too.
- `i18n.ts` — backend translator (native menus + thrown error messages, which
no Angular service can reach). `setLocale(setting)` caches the resolved
@@ -402,6 +426,10 @@ ends with `blockReloadKeys`. Keys that land on `<body>` (a click dropped focus)
are forwarded to the active list (`PanelsService.forwardListKey`), and `App`'s
`(mousedown)` keeps focus on the list after chrome-button clicks.
**Remaps** live in `AppSettings.keybindings` (sanitized on load), edited in
Settings → Keyboard (see the Settings window). `ShortcutsService.rebind`
stores only rows that differ from their defaults.
**macOS key equivalents are page-first.** WKWebView hands ⌘-keys to the page
before the app menu (WebKit `WebViewImpl::performKeyEquivalent`); only keys the
page doesn't `preventDefault` reach the menu bar. So the page owns every app
+8
View File
@@ -311,6 +311,14 @@ touching the adjacent code.
path bar) hides keys from it — don't add one.
- **Hold-to-repeat** fired ⌘W once per repeat — every tab closed. `claim()`
repeats only movement-like commands (`isRepeatable`).
- **Electrobun's native menu accelerator turns a combo with no modifier token
into ⌘+key** ("f7" shows and fires ⌘F7), its `backspace` / `delete` tokens
are characters AppKit matches unreliably, and AppKit matches Shift+punctuation
by the shifted character ("}" for ⇧]). `toAccelerator` returns null for all of
those; the menu item then shows no key and the page still handles it.
- **Recording a key must take the keydown before every window handler** — and
must NOT take reserved ones: preventDefault on ⌘Q while recording would make
the app unquittable from the keyboard.
- **Focus after a state change needs `afterNextRender({ injector })`.** Under
zoneless CD `queueMicrotask` runs before the render that creates an `@if`
element (the editor's find box and textarea), so `.focus()` hit nothing.
+1 -1
View File
@@ -23,7 +23,7 @@ Every platform seam carries a `PORTABILITY:` comment at the branch site.
| Tab focus | `core/focus-cycle.ts cycleFocus` | WKWebView skips buttons/checkboxes without Keyboard navigation → walked by hand | native order (same walk) | native order (same walk) |
| Reload keys | `platform.ts blockReloadKeys` | — | WebView2 reloads on F5 / Ctrl+R → swallowed when unclaimed | — |
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored (needs AppIndicator, see below) |
| App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — Settings opens from the tab-bar gear (Ctrl+,), About from its About section | none (unsupported upstream) |
| App menu | `src/bun/menu.ts` | full menu incl. About; accelerators follow the keymap (remaps included), ⌘Q fixed | none (early return) — Settings opens from the tab-bar gear (Ctrl+,), About from its About section | none (unsupported upstream) |
| connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 |
| App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config |
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
+15 -7
View File
@@ -108,7 +108,7 @@ const manager = new ManagerWindow({
connectionsChanged: () => rpc.send.connectionsChanged({}),
menuAction: (info) => rpc.send.menuAction(info),
},
rebuildMenu: () => void applyApplicationMenu(store),
rebuildMenu: () => void applyApplicationMenu(store, () => currentSettings.keybindings),
onSettingsSaved: (s) => settingsSaved(s, "manager"),
});
const settingsWin = new SettingsWindow({
@@ -134,12 +134,14 @@ function settingsSaved(
): void {
engine.setConcurrency(effectiveConcurrency(s));
const localeChanged = s.locale !== currentSettings.locale;
const keysChanged = JSON.stringify(s.keybindings) !== JSON.stringify(currentSettings.keybindings);
currentSettings = s; // future windows' preloads bake the fresh theme
if (localeChanged) {
setBackendLocale(s.locale);
void applyApplicationMenu(store); // relabel the native menu in the new language
settingsWin.retitle();
}
// relabel the native menu in the new language / give it the remapped keys
if (localeChanged || keysChanged) void applyApplicationMenu(store, () => currentSettings.keybindings);
if (sender !== "main") rpc.send.settingsChanged({ settings: s });
if (sender !== "manager") manager.broadcastSettings(s);
if (sender !== "settings") settingsWin.broadcastSettings(s);
@@ -258,16 +260,19 @@ events.sessionClosed = (info) => {
rpc.send.sessionClosed(info);
editors.notifySessionClosed(info);
};
events.connectionsChanged = () => void applyApplicationMenu(store);
events.connectionsChanged = () => void applyApplicationMenu(store, () => currentSettings.keybindings);
engine.setConcurrency(effectiveConcurrency(currentSettings));
void applyApplicationMenu(store);
void applyApplicationMenu(store, () => currentSettings.keybindings);
ApplicationMenu.on("application-menu-clicked", (event) => {
const action = (event as { data?: { action?: string } }).data?.action;
if (!action) return;
// ⌘W while the Settings window or an editor window is focused closes THAT
// window (an editor with its dirty prompt), not a main-window tab. Other
// menu actions deliberately still target the main window.
// Their pages claim close-window (⌘W) themselves — macOS hands key
// equivalents to the page first — so a close-tab that still arrives while
// the Settings window or an editor is focused is a ⌘W the page let through
// (or the menu item clicked): close THAT window (an editor with its dirty
// prompt), never a main-window tab behind it. Other menu actions
// deliberately still target the main window.
if (action === "close-tab") {
if (settingsWin.isFocused()) {
settingsWin.close();
@@ -299,6 +304,9 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
}
// the manager is modal to the main window — don't drive main behind it
if (manager.isOpen()) return;
// everything below acts on the main window: from a focused editor or the
// Settings window (⌘T, ⌘N…), bring it forward so the effect is visible
if (settingsWin.isFocused() || editors.focusedKey()) win.activate();
rpc.send.menuAction({ action });
});
+33 -11
View File
@@ -1,5 +1,6 @@
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/main";
import { BRANDING } from "../shared/branding";
import { effectiveKeymap, type ShortcutId, toAccelerator } from "../shared/keymap";
import type { SavedConnection } from "../shared/types";
import { t } from "./i18n";
import type { ConnectionStore } from "./store/connections";
@@ -7,13 +8,30 @@ import type { ConnectionStore } from "./store/connections";
/**
* macOS application menu (electrobun menus are macOS-only). Standard roles plus
* app actions; custom actions are forwarded to the webview as menuAction
* messages. Rebuilt whenever saved connections change so the Connect submenu
* stays current.
* messages. Rebuilt whenever saved connections, the language or the keyboard
* remaps change.
*
* Accelerators come from the shared keymap, so the menu bar shows — and
* fires — the user's keys. The page sees ⌘-keys first and claims the ones it
* handles; the menu only runs what falls through (see shortcuts.service.ts).
* ⌘Q stays fixed: quitting must always work.
*/
export async function applyApplicationMenu(store: ConnectionStore): Promise<void> {
export async function applyApplicationMenu(
store: ConnectionStore,
keybindings: () => Record<string, string[]>,
): Promise<void> {
if (process.platform !== "darwin") return;
const { stacks, connections } = await store.load();
// read after the await: a remap saved meanwhile must not be lost
const keymap = effectiveKeymap(keybindings(), true);
const accelerator = (id: ShortcutId): string | undefined => {
for (const combo of keymap.find((b) => b.id === id)?.combos ?? []) {
const native = toAccelerator(combo);
if (native) return native;
}
return undefined;
};
const item = (c: SavedConnection): ApplicationMenuItemConfig => ({
label: c.name,
action: `connect:${c.id}`,
@@ -28,7 +46,11 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
...looseItems,
...stackMenus,
...(looseItems.length || stackMenus.length ? [{ type: "separator" } as const] : []),
{ label: t("menu.connectionManager"), action: "manage-connections" },
{
label: t("menu.connectionManager"),
action: "manage-connections",
accelerator: accelerator("connection-manager"),
},
];
ApplicationMenu.setApplicationMenu([
@@ -36,7 +58,7 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
submenu: [
{ label: t("menu.about", { app: BRANDING.appName }), action: "about" },
{ type: "separator" },
{ label: t("menu.settings"), action: "settings", accelerator: "," },
{ label: t("menu.settings"), action: "settings", accelerator: accelerator("open-settings") },
{ type: "separator" },
{ label: t("menu.checkForUpdates"), action: "check-for-updates" },
{ type: "separator" },
@@ -53,13 +75,13 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
{
label: t("menu.file"),
submenu: [
{ label: t("menu.newTab"), action: "new-tab", accelerator: "t" },
{ label: t("menu.closeTab"), action: "close-tab", accelerator: "w" },
{ label: t("menu.newTab"), action: "new-tab", accelerator: accelerator("new-tab") },
{ label: t("menu.closeTab"), action: "close-tab", accelerator: accelerator("close-tab") },
{ type: "separator" },
{ label: t("menu.newFolder"), action: "new-folder" },
{ label: t("menu.newFile"), action: "new-file" },
{ label: t("menu.newFolder"), action: "new-folder", accelerator: accelerator("new-folder") },
{ label: t("menu.newFile"), action: "new-file", accelerator: accelerator("new-file") },
{ type: "separator" },
{ label: t("menu.editFile"), action: "edit-file", accelerator: "e" },
{ label: t("menu.editFile"), action: "edit-file", accelerator: accelerator("edit") },
{ type: "separator" },
{ label: t("menu.connect"), submenu: connectSubmenu },
],
@@ -81,7 +103,7 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
{
label: t("menu.view"),
submenu: [
{ label: t("menu.refresh"), action: "refresh-active", accelerator: "r" },
{ label: t("menu.refresh"), action: "refresh-active", accelerator: accelerator("refresh") },
{ type: "separator" },
{ role: "toggleFullScreen" },
],
+104
View File
@@ -36,6 +36,7 @@ export const de = {
transfers: "Übertragungen",
editor: "Editor",
fileList: "Dateiliste",
keyboard: "Tastatur",
about: "Über",
},
columns: "Sichtbare Spalten",
@@ -51,6 +52,7 @@ export const de = {
systemDefault: "Systemstandard",
resetDefault: "Auf Standard zurücksetzen",
finishedTransfers: { label: "Abgeschlossene Übertragungen", autoClear: "Auto-löschen", keep: "Behalten" },
openFiles: { label: "Eingabe / Doppelklick auf eine Datei", nothing: "Nichts tun", open: "Öffnen", hint: "Lokale Dateien öffnen sich in ihrer Standard-App; Ordner öffnen sich immer." },
},
kinds: {
folder: "Ordner",
@@ -293,6 +295,108 @@ export const de = {
trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar",
},
keys: {
"cursor-up": "Nach oben",
"cursor-down": "Nach unten",
"page-up": "Seite nach oben",
"page-down": "Seite nach unten",
"cursor-first": "Erster Eintrag",
"cursor-last": "Letzter Eintrag",
"toggle-select": "Auswählen / abwählen",
"select-all": "Alles auswählen",
open: "Öffnen",
parent: "Übergeordneter Ordner",
"switch-panel": "Panel wechseln",
"row-menu": "Menü des Eintrags zeigen",
send: "An anderes Panel senden",
edit: "Bearbeiten",
rename: "Umbenennen",
"new-folder": "Neuer Ordner",
"new-file": "Neue Datei",
chmod: "Berechtigungen",
delete: "Löschen",
trash: "In den Papierkorb",
"copy-path": "Pfad kopieren",
reveal: "Im Dateimanager zeigen",
"open-default": "Mit Standard-App öffnen",
refresh: "Aktualisieren",
"focus-path": "Zum Pfad",
"source-picker": "Panelinhalt wählen",
"sort-name": "Nach Name sortieren",
"sort-size": "Nach Größe sortieren",
"sort-mtime": "Nach Änderungsdatum sortieren",
"sort-kind": "Nach Art sortieren",
"sort-rights": "Nach Rechten sortieren",
"sort-owner": "Nach Eigentümer sortieren",
"sort-group": "Nach Gruppe sortieren",
"new-tab": "Neuer Tab",
"close-tab": "Tab schließen",
"close-other-tabs": "Andere Tabs schließen",
"close-tabs-right": "Tabs rechts schließen",
"next-tab": "Nächster Tab",
"prev-tab": "Vorheriger Tab",
"go-to-tab": "Zu Tab {n}",
"go-to-last-tab": "Zum letzten Tab",
"move-tab-left": "Tab nach links",
"move-tab-right": "Tab nach rechts",
"disconnect-tab": "Tab trennen",
"open-settings": "Einstellungen",
"connection-manager": "Verbindungsverwaltung",
"toggle-transfers": "Übertragungen ein-/ausblenden",
"install-update": "Update installieren",
"scale-up": "Oberfläche größer",
"scale-down": "Oberfläche kleiner",
"scale-reset": "Oberfläche auf 100 %",
"close-window": "Fenster schließen",
save: "Speichern",
find: "Suchen",
"find-next": "Weitersuchen",
"find-prev": "Rückwärts suchen",
"toggle-wrap": "Zeilen umbrechen",
"conn-new": "Neue Verbindung",
"conn-new-stack": "Neue Gruppe",
"conn-save": "Verbindung speichern",
"conn-connect": "Verbinden / Gruppe öffnen",
"conn-delete": "Verbindung oder Gruppe löschen",
"conn-rename-stack": "Gruppe umbenennen",
"conn-import": "Aus WinSCP importieren",
groups: {
panels: "Dateiliste",
files: "Dateien",
sort: "Sortierung",
tabs: "Tabs",
window: "Fenster",
editor: "Editor",
connections: "Verbindungsverwaltung",
},
ui: {
filter: "Tastenkürzel suchen",
intro: "Auf + klicken und die Tasten drücken, um ein Kürzel hinzuzufügen. Das erste zeigen Menüs und Tooltips an.",
add: "Kürzel hinzufügen",
remove: "{key} entfernen",
reset: "Auf Standard zurücksetzen",
resetAll: "Alle zurücksetzen",
resetAllConfirm: "Alle Kürzel zurücksetzen",
press: "Tasten drücken… ({esc} bricht ab)",
unbound: "Kein Kürzel",
reserved: "{key} ist vom System reserviert",
needsModifier: "Im Editor würde {key} nur tippen – eine Sondertaste hinzufügen oder eine F-Taste nehmen",
usedBy: "{key} wird schon von „{action}“ benutzt",
replace: "Ersetzen",
conflict: "Gleiche Taste wie {action}",
plainLetter: "{key} in der Dateiliste tippen filtert sie nicht mehr",
noResults: "Kein Kürzel passt",
fixed: "Feste Tasten",
},
fixed: {
escape: "{esc} schließt Menüs, Dialoge und die Suchleiste",
escList: "{esc} in der Dateiliste leert den Filter, bricht dann eine Verbindung ab und hebt dann die Auswahl auf",
dialogs: "{enter} und {esc} bestätigen oder verwerfen einen Dialog; {tab} und {shiftTab} wechseln zwischen seinen Elementen",
menus: "↑ ↓ {home} {end} und {enter} funktionieren in jedem Menü",
filter: "Tippen in der Dateiliste filtert sie; {backspace} löscht das letzte Zeichen des Filters",
shift: "Umschalt mit einer Bewegungstaste wählt die überfahrenen Zeilen aus",
find: "{enter} und {shiftEnter} springen durch die Treffer der Suche",
sidebar: "↑ ↓ wechseln hier zwischen den Bereichen",
},
names: {
ctrl: "Strg",
alt: "Alt",
+33
View File
@@ -40,6 +40,7 @@ export const en = {
transfers: "Transfers",
editor: "Editor",
fileList: "File list",
keyboard: "Keyboard",
about: "About",
},
columns: "Visible columns",
@@ -55,6 +56,7 @@ export const en = {
systemDefault: "System default",
resetDefault: "Reset to system default",
finishedTransfers: { label: "Finished transfers", autoClear: "Auto-clear", keep: "Keep" },
openFiles: { label: "Enter / double-click on a file", nothing: "Do nothing", open: "Open", hint: "Local files open in their default app; folders always open." },
},
kinds: {
folder: "Folder",
@@ -372,6 +374,37 @@ export const en = {
editor: "Editor",
connections: "Connection manager",
},
// Settings → Keyboard
ui: {
filter: "Search shortcuts",
intro: "Press + and type the keys to add a shortcut. The first one is the hint menus and tooltips show.",
add: "Add a shortcut",
remove: "Remove {key}",
reset: "Reset to default",
resetAll: "Reset all",
resetAllConfirm: "Reset every shortcut",
press: "Press keys… ({esc} cancels)",
unbound: "No shortcut",
reserved: "{key} is reserved by the system",
needsModifier: "In the editor {key} would only type — add a modifier or use an F-key",
usedBy: "{key} is already used by “{action}”",
replace: "Replace",
conflict: "Same key as {action}",
plainLetter: "Typing {key} in the file list no longer filters it",
noResults: "No shortcut matches",
fixed: "Fixed keys",
},
// fixed keys, listed read-only there (key params come from the platform)
fixed: {
escape: "{esc} closes menus, dialogs and the find bar",
escList: "{esc} in the file list clears the filter, then cancels a connect, then clears the selection",
dialogs: "{enter} and {esc} confirm or cancel a dialog; {tab} and {shiftTab} move between its controls",
menus: "↑ ↓ {home} {end} and {enter} work in every menu",
filter: "Typing in the file list filters it; {backspace} trims the filter",
shift: "Shift with a movement key selects the rows the cursor passes",
find: "{enter} and {shiftEnter} step through the matches in the find box",
sidebar: "↑ ↓ move between these sections",
},
// key names for "Ctrl+Shift+Del"-style hints (not used on macOS, which shows ⌘⇧⌫)
names: {
ctrl: "Ctrl",
+104
View File
@@ -36,6 +36,7 @@ export const es = {
transfers: "Transferencias",
editor: "Editor",
fileList: "Lista de archivos",
keyboard: "Teclado",
about: "Acerca de",
},
columns: "Columnas visibles",
@@ -51,6 +52,7 @@ export const es = {
systemDefault: "Predeterminado del sistema",
resetDefault: "Restablecer al predeterminado",
finishedTransfers: { label: "Transferencias completadas", autoClear: "Auto-limpiar", keep: "Mantener" },
openFiles: { label: "Intro / doble clic en un archivo", nothing: "No hacer nada", open: "Abrir", hint: "Los archivos locales se abren con su app predeterminada; las carpetas siempre se abren." },
},
kinds: {
folder: "Carpeta",
@@ -293,6 +295,108 @@ export const es = {
trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí",
},
keys: {
"cursor-up": "Subir",
"cursor-down": "Bajar",
"page-up": "Página arriba",
"page-down": "Página abajo",
"cursor-first": "Primer elemento",
"cursor-last": "Último elemento",
"toggle-select": "Seleccionar / deseleccionar",
"select-all": "Seleccionar todo",
open: "Abrir",
parent: "Carpeta superior",
"switch-panel": "Cambiar de panel",
"row-menu": "Mostrar el menú del elemento",
send: "Enviar al otro panel",
edit: "Editar",
rename: "Renombrar",
"new-folder": "Nueva carpeta",
"new-file": "Nuevo archivo",
chmod: "Permisos",
delete: "Eliminar",
trash: "Mover a la papelera",
"copy-path": "Copiar ruta",
reveal: "Mostrar en el gestor de archivos",
"open-default": "Abrir con la app predeterminada",
refresh: "Actualizar",
"focus-path": "Ir a la ruta",
"source-picker": "Elegir qué muestra el panel",
"sort-name": "Ordenar por nombre",
"sort-size": "Ordenar por tamaño",
"sort-mtime": "Ordenar por fecha de modificación",
"sort-kind": "Ordenar por tipo",
"sort-rights": "Ordenar por permisos",
"sort-owner": "Ordenar por propietario",
"sort-group": "Ordenar por grupo",
"new-tab": "Nueva pestaña",
"close-tab": "Cerrar pestaña",
"close-other-tabs": "Cerrar las demás pestañas",
"close-tabs-right": "Cerrar las pestañas de la derecha",
"next-tab": "Pestaña siguiente",
"prev-tab": "Pestaña anterior",
"go-to-tab": "Ir a la pestaña {n}",
"go-to-last-tab": "Ir a la última pestaña",
"move-tab-left": "Mover la pestaña a la izquierda",
"move-tab-right": "Mover la pestaña a la derecha",
"disconnect-tab": "Desconectar la pestaña",
"open-settings": "Ajustes",
"connection-manager": "Gestor de conexiones",
"toggle-transfers": "Mostrar / ocultar transferencias",
"install-update": "Instalar la actualización",
"scale-up": "Interfaz más grande",
"scale-down": "Interfaz más pequeña",
"scale-reset": "Interfaz al 100 %",
"close-window": "Cerrar la ventana",
save: "Guardar",
find: "Buscar",
"find-next": "Buscar siguiente",
"find-prev": "Buscar anterior",
"toggle-wrap": "Ajustar líneas",
"conn-new": "Nueva conexión",
"conn-new-stack": "Nuevo grupo",
"conn-save": "Guardar la conexión",
"conn-connect": "Conectar / abrir grupo",
"conn-delete": "Eliminar conexión o grupo",
"conn-rename-stack": "Renombrar grupo",
"conn-import": "Importar de WinSCP",
groups: {
panels: "Lista de archivos",
files: "Archivos",
sort: "Orden",
tabs: "Pestañas",
window: "Ventana",
editor: "Editor",
connections: "Gestor de conexiones",
},
ui: {
filter: "Buscar atajos",
intro: "Pulsa + y teclea las teclas para añadir un atajo. El primero es el que muestran los menús y las pistas.",
add: "Añadir un atajo",
remove: "Quitar {key}",
reset: "Restablecer el predeterminado",
resetAll: "Restablecer todo",
resetAllConfirm: "Restablecer todos los atajos",
press: "Pulsa las teclas… ({esc} cancela)",
unbound: "Sin atajo",
reserved: "{key} está reservado por el sistema",
needsModifier: "En el editor {key} solo escribiría: añade un modificador o usa una tecla F",
usedBy: "{key} ya lo usa «{action}»",
replace: "Reemplazar",
conflict: "Misma tecla que {action}",
plainLetter: "Teclear {key} en la lista de archivos ya no la filtra",
noResults: "Ningún atajo coincide",
fixed: "Teclas fijas",
},
fixed: {
escape: "{esc} cierra menús, diálogos y la barra de búsqueda",
escList: "{esc} en la lista de archivos borra el filtro, luego cancela una conexión y luego quita la selección",
dialogs: "{enter} y {esc} confirman o cancelan un diálogo; {tab} y {shiftTab} recorren sus controles",
menus: "↑ ↓ {home} {end} y {enter} funcionan en todos los menús",
filter: "Teclear en la lista de archivos la filtra; {backspace} borra el último carácter del filtro",
shift: "Mayús con una tecla de movimiento selecciona las filas por las que pasa el cursor",
find: "{enter} y {shiftEnter} recorren las coincidencias de la búsqueda",
sidebar: "↑ ↓ cambian de sección aquí",
},
names: {
ctrl: "Ctrl",
alt: "Alt",
+104
View File
@@ -38,6 +38,7 @@ export const eu = {
transfers: "Transferentziak",
editor: "Editorea",
fileList: "Fitxategi-zerrenda",
keyboard: "Teklatua",
about: "Honi buruz",
},
columns: "Zutabe ikusgaiak",
@@ -53,6 +54,7 @@ export const eu = {
systemDefault: "Sistemaren lehenetsia",
resetDefault: "Berrezarri lehenetsira",
finishedTransfers: { label: "Amaitutako transferentziak", autoClear: "Garbitu", keep: "Mantendu" },
openFiles: { label: "Sartu / klik bikoitza fitxategi batean", nothing: "Ezer ez", open: "Ireki", hint: "Fitxategi lokalak beren aplikazio lehenetsiarekin irekitzen dira; karpetak beti irekitzen dira." },
},
kinds: {
folder: "Karpeta",
@@ -295,6 +297,108 @@ export const eu = {
trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat",
},
keys: {
"cursor-up": "Gora",
"cursor-down": "Behera",
"page-up": "Orrialde bat gora",
"page-down": "Orrialde bat behera",
"cursor-first": "Lehen elementua",
"cursor-last": "Azken elementua",
"toggle-select": "Hautatu / desautatu",
"select-all": "Hautatu dena",
open: "Ireki",
parent: "Karpeta gurasoa",
"switch-panel": "Aldatu panela",
"row-menu": "Erakutsi elementuaren menua",
send: "Bidali beste panelera",
edit: "Editatu",
rename: "Berrizendatu",
"new-folder": "Karpeta berria",
"new-file": "Fitxategi berria",
chmod: "Baimenak",
delete: "Ezabatu",
trash: "Eraman zakarrontzira",
"copy-path": "Kopiatu bidea",
reveal: "Erakutsi fitxategi-kudeatzailean",
"open-default": "Ireki aplikazio lehenetsiarekin",
refresh: "Freskatu",
"focus-path": "Joan bidera",
"source-picker": "Aukeratu panelak zer erakusten duen",
"sort-name": "Ordenatu izenaren arabera",
"sort-size": "Ordenatu tamainaren arabera",
"sort-mtime": "Ordenatu aldaketa-dataren arabera",
"sort-kind": "Ordenatu motaren arabera",
"sort-rights": "Ordenatu baimenen arabera",
"sort-owner": "Ordenatu jabearen arabera",
"sort-group": "Ordenatu taldearen arabera",
"new-tab": "Fitxa berria",
"close-tab": "Itxi fitxa",
"close-other-tabs": "Itxi beste fitxak",
"close-tabs-right": "Itxi eskuineko fitxak",
"next-tab": "Hurrengo fitxa",
"prev-tab": "Aurreko fitxa",
"go-to-tab": "Joan {n}. fitxara",
"go-to-last-tab": "Joan azken fitxara",
"move-tab-left": "Eraman fitxa ezkerrera",
"move-tab-right": "Eraman fitxa eskuinera",
"disconnect-tab": "Deskonektatu fitxa",
"open-settings": "Ezarpenak",
"connection-manager": "Konexio-kudeatzailea",
"toggle-transfers": "Erakutsi / ezkutatu transferentziak",
"install-update": "Instalatu eguneraketa",
"scale-up": "Interfaze handiagoa",
"scale-down": "Interfaze txikiagoa",
"scale-reset": "Interfazea % 100ean",
"close-window": "Itxi leihoa",
save: "Gorde",
find: "Bilatu",
"find-next": "Bilatu hurrengoa",
"find-prev": "Bilatu aurrekoa",
"toggle-wrap": "Doitu lerroak",
"conn-new": "Konexio berria",
"conn-new-stack": "Talde berria",
"conn-save": "Gorde konexioa",
"conn-connect": "Konektatu / ireki taldea",
"conn-delete": "Ezabatu konexioa edo taldea",
"conn-rename-stack": "Berrizendatu taldea",
"conn-import": "Inportatu WinSCPtik",
groups: {
panels: "Fitxategi-zerrenda",
files: "Fitxategiak",
sort: "Ordena",
tabs: "Fitxak",
window: "Leihoa",
editor: "Editorea",
connections: "Konexio-kudeatzailea",
},
ui: {
filter: "Bilatu lasterbideak",
intro: "Sakatu + eta idatzi teklak lasterbide bat gehitzeko. Lehenengoa da menuek eta argibideek erakusten dutena.",
add: "Gehitu lasterbide bat",
remove: "Kendu {key}",
reset: "Berrezarri lehenetsia",
resetAll: "Berrezarri dena",
resetAllConfirm: "Berrezarri lasterbide guztiak",
press: "Sakatu teklak… ({esc} utzi)",
unbound: "Lasterbiderik ez",
reserved: "{key} sistemak erreserbatuta dauka",
needsModifier: "Editorean {key}(e)k idatzi besterik ez luke egingo: gehitu aldatzaile bat edo erabili F tekla bat",
usedBy: "{key} «{action}»(e)k erabiltzen du dagoeneko",
replace: "Ordeztu",
conflict: "{action}(r)en tekla bera",
plainLetter: "Fitxategi-zerrendan {key} idazteak ez du jada iragazten",
noResults: "Ez dago bat datorren lasterbiderik",
fixed: "Tekla finkoak",
},
fixed: {
escape: "{esc}(e)k menuak, elkarrizketa-koadroak eta bilaketa-barra ixten ditu",
escList: "Fitxategi-zerrendan {esc}(e)k iragazkia garbitzen du, gero konexioa bertan behera uzten du eta gero hautapena kentzen du",
dialogs: "{enter} eta {esc} elkarrizketa-koadroa berresten edo uzten dute; {tab} eta {shiftTab} haren kontroletan mugitzen dira",
menus: "↑ ↓ {home} {end} eta {enter} menu guztietan dabiltza",
filter: "Fitxategi-zerrendan idazteak iragazi egiten du; {backspace}(e)k iragazkiaren azken karakterea ezabatzen du",
shift: "Maius mugimendu-tekla batekin kurtsoreak igarotako errenkadak hautatzen ditu",
find: "{enter} eta {shiftEnter} bilaketako bat-etortzeetan mugitzen dira",
sidebar: "↑ ↓ atal batetik bestera mugitzen dira hemen",
},
names: {
ctrl: "Ctrl",
alt: "Alt",
+104
View File
@@ -36,6 +36,7 @@ export const fr = {
transfers: "Transferts",
editor: "Éditeur",
fileList: "Liste des fichiers",
keyboard: "Clavier",
about: "À propos",
},
columns: "Colonnes visibles",
@@ -51,6 +52,7 @@ export const fr = {
systemDefault: "Valeur par défaut du système",
resetDefault: "Réinitialiser par défaut",
finishedTransfers: { label: "Transferts terminés", autoClear: "Effacer auto.", keep: "Garder" },
openFiles: { label: "Entrée / double-clic sur un fichier", nothing: "Ne rien faire", open: "Ouvrir", hint: "Les fichiers locaux s'ouvrent avec leur application par défaut ; les dossiers s'ouvrent toujours." },
},
kinds: {
folder: "Dossier",
@@ -293,6 +295,108 @@ export const fr = {
trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici",
},
keys: {
"cursor-up": "Monter",
"cursor-down": "Descendre",
"page-up": "Page précédente",
"page-down": "Page suivante",
"cursor-first": "Premier élément",
"cursor-last": "Dernier élément",
"toggle-select": "Sélectionner / désélectionner",
"select-all": "Tout sélectionner",
open: "Ouvrir",
parent: "Dossier parent",
"switch-panel": "Changer de panneau",
"row-menu": "Afficher le menu de l'élément",
send: "Envoyer vers l'autre panneau",
edit: "Éditer",
rename: "Renommer",
"new-folder": "Nouveau dossier",
"new-file": "Nouveau fichier",
chmod: "Permissions",
delete: "Supprimer",
trash: "Mettre à la corbeille",
"copy-path": "Copier le chemin",
reveal: "Afficher dans le gestionnaire de fichiers",
"open-default": "Ouvrir avec l'application par défaut",
refresh: "Actualiser",
"focus-path": "Aller au chemin",
"source-picker": "Choisir ce qu'affiche le panneau",
"sort-name": "Trier par nom",
"sort-size": "Trier par taille",
"sort-mtime": "Trier par date de modification",
"sort-kind": "Trier par type",
"sort-rights": "Trier par droits",
"sort-owner": "Trier par propriétaire",
"sort-group": "Trier par groupe",
"new-tab": "Nouvel onglet",
"close-tab": "Fermer l'onglet",
"close-other-tabs": "Fermer les autres onglets",
"close-tabs-right": "Fermer les onglets à droite",
"next-tab": "Onglet suivant",
"prev-tab": "Onglet précédent",
"go-to-tab": "Aller à l'onglet {n}",
"go-to-last-tab": "Aller au dernier onglet",
"move-tab-left": "Déplacer l'onglet à gauche",
"move-tab-right": "Déplacer l'onglet à droite",
"disconnect-tab": "Déconnecter l'onglet",
"open-settings": "Réglages",
"connection-manager": "Gestionnaire de connexions",
"toggle-transfers": "Afficher / masquer les transferts",
"install-update": "Installer la mise à jour",
"scale-up": "Interface plus grande",
"scale-down": "Interface plus petite",
"scale-reset": "Interface à 100 %",
"close-window": "Fermer la fenêtre",
save: "Enregistrer",
find: "Rechercher",
"find-next": "Résultat suivant",
"find-prev": "Résultat précédent",
"toggle-wrap": "Retour à la ligne",
"conn-new": "Nouvelle connexion",
"conn-new-stack": "Nouveau groupe",
"conn-save": "Enregistrer la connexion",
"conn-connect": "Se connecter / ouvrir le groupe",
"conn-delete": "Supprimer la connexion ou le groupe",
"conn-rename-stack": "Renommer le groupe",
"conn-import": "Importer depuis WinSCP",
groups: {
panels: "Liste des fichiers",
files: "Fichiers",
sort: "Tri",
tabs: "Onglets",
window: "Fenêtre",
editor: "Éditeur",
connections: "Gestionnaire de connexions",
},
ui: {
filter: "Rechercher un raccourci",
intro: "Cliquez sur + et tapez les touches pour ajouter un raccourci. Le premier est celui qu'affichent les menus et les infobulles.",
add: "Ajouter un raccourci",
remove: "Retirer {key}",
reset: "Rétablir la valeur par défaut",
resetAll: "Tout rétablir",
resetAllConfirm: "Rétablir tous les raccourcis",
press: "Tapez les touches… ({esc} annule)",
unbound: "Aucun raccourci",
reserved: "{key} est réservé par le système",
needsModifier: "Dans l'éditeur, {key} ne ferait que taper : ajoutez un modificateur ou utilisez une touche F",
usedBy: "{key} est déjà utilisé par « {action} »",
replace: "Remplacer",
conflict: "Même touche que {action}",
plainLetter: "Taper {key} dans la liste des fichiers ne la filtre plus",
noResults: "Aucun raccourci ne correspond",
fixed: "Touches fixes",
},
fixed: {
escape: "{esc} ferme les menus, les dialogues et la barre de recherche",
escList: "{esc} dans la liste des fichiers efface le filtre, puis annule une connexion, puis vide la sélection",
dialogs: "{enter} et {esc} valident ou annulent un dialogue ; {tab} et {shiftTab} passent d'un contrôle à l'autre",
menus: "↑ ↓ {home} {end} et {enter} fonctionnent dans tous les menus",
filter: "Taper dans la liste des fichiers la filtre ; {backspace} efface le dernier caractère du filtre",
shift: "Maj avec une touche de déplacement sélectionne les lignes parcourues",
find: "{enter} et {shiftEnter} parcourent les résultats de la recherche",
sidebar: "↑ ↓ changent de section ici",
},
names: {
ctrl: "Ctrl",
alt: "Alt",
+104
View File
@@ -37,6 +37,7 @@ export const ja = {
transfers: "転送",
editor: "エディタ",
fileList: "ファイル一覧",
keyboard: "キーボード",
about: "情報",
},
columns: "表示する列",
@@ -52,6 +53,7 @@ export const ja = {
systemDefault: "システムの既定",
resetDefault: "既定に戻す",
finishedTransfers: { label: "完了した転送", autoClear: "自動クリア", keep: "保持" },
openFiles: { label: "Enter / ファイルをダブルクリック", nothing: "何もしない", open: "開く", hint: "ローカルファイルは既定のアプリで開きます。フォルダは常に開きます。" },
},
kinds: {
folder: "フォルダ",
@@ -293,6 +295,108 @@ export const ja = {
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません",
},
keys: {
"cursor-up": "上へ",
"cursor-down": "下へ",
"page-up": "前のページ",
"page-down": "次のページ",
"cursor-first": "先頭の項目",
"cursor-last": "最後の項目",
"toggle-select": "選択 / 選択解除",
"select-all": "すべて選択",
open: "開く",
parent: "親フォルダ",
"switch-panel": "パネルを切り替え",
"row-menu": "項目のメニューを表示",
send: "もう一方のパネルへ送信",
edit: "編集",
rename: "名前を変更",
"new-folder": "新規フォルダ",
"new-file": "新規ファイル",
chmod: "アクセス権",
delete: "削除",
trash: "ゴミ箱に移動",
"copy-path": "パスをコピー",
reveal: "ファイルマネージャーで表示",
"open-default": "既定のアプリで開く",
refresh: "更新",
"focus-path": "パスへ移動",
"source-picker": "パネルの表示内容を選択",
"sort-name": "名前で並べ替え",
"sort-size": "サイズで並べ替え",
"sort-mtime": "更新日時で並べ替え",
"sort-kind": "種類で並べ替え",
"sort-rights": "権限で並べ替え",
"sort-owner": "所有者で並べ替え",
"sort-group": "グループで並べ替え",
"new-tab": "新しいタブ",
"close-tab": "タブを閉じる",
"close-other-tabs": "他のタブを閉じる",
"close-tabs-right": "右側のタブを閉じる",
"next-tab": "次のタブ",
"prev-tab": "前のタブ",
"go-to-tab": "タブ {n} へ移動",
"go-to-last-tab": "最後のタブへ移動",
"move-tab-left": "タブを左へ移動",
"move-tab-right": "タブを右へ移動",
"disconnect-tab": "タブを切断",
"open-settings": "設定",
"connection-manager": "接続マネージャー",
"toggle-transfers": "転送の表示 / 非表示",
"install-update": "アップデートをインストール",
"scale-up": "インターフェースを拡大",
"scale-down": "インターフェースを縮小",
"scale-reset": "インターフェースを 100% に",
"close-window": "ウインドウを閉じる",
save: "保存",
find: "検索",
"find-next": "次を検索",
"find-prev": "前を検索",
"toggle-wrap": "行を折り返す",
"conn-new": "新規接続",
"conn-new-stack": "新規グループ",
"conn-save": "接続を保存",
"conn-connect": "接続 / グループを開く",
"conn-delete": "接続またはグループを削除",
"conn-rename-stack": "グループ名を変更",
"conn-import": "WinSCP からインポート",
groups: {
panels: "ファイル一覧",
files: "ファイル",
sort: "並べ替え",
tabs: "タブ",
window: "ウインドウ",
editor: "エディタ",
connections: "接続マネージャー",
},
ui: {
filter: "ショートカットを検索",
intro: "+ を押してキーを入力するとショートカットを追加できます。最初のものがメニューとツールチップに表示されます。",
add: "ショートカットを追加",
remove: "{key} を削除",
reset: "既定に戻す",
resetAll: "すべて戻す",
resetAllConfirm: "すべてのショートカットを戻す",
press: "キーを押してください…({esc} でキャンセル)",
unbound: "ショートカットなし",
reserved: "{key} はシステムで予約されています",
needsModifier: "エディタでは {key} は入力されるだけです。修飾キーを加えるか F キーを使ってください",
usedBy: "{key} はすでに「{action}」で使われています",
replace: "置き換える",
conflict: "{action} と同じキー",
plainLetter: "ファイル一覧で {key} を入力しても絞り込まれなくなります",
noResults: "一致するショートカットはありません",
fixed: "固定キー",
},
fixed: {
escape: "{esc} でメニュー、ダイアログ、検索バーを閉じます",
escList: "ファイル一覧の {esc} は、絞り込みの解除、接続のキャンセル、選択の解除の順に働きます",
dialogs: "{enter} と {esc} でダイアログを確定 / キャンセルし、{tab} と {shiftTab} でコントロール間を移動します",
menus: "↑ ↓ {home} {end} と {enter} はすべてのメニューで使えます",
filter: "ファイル一覧で入力すると絞り込まれます。{backspace} で最後の文字を消します",
shift: "Shift と移動キーで、カーソルが通過した行を選択します",
find: "{enter} と {shiftEnter} で検索結果を移動します",
sidebar: "↑ ↓ でここのセクションを切り替えます",
},
names: {
ctrl: "Ctrl",
alt: "Alt",
+104
View File
@@ -37,6 +37,7 @@ export const zh = {
transfers: "传输",
editor: "编辑器",
fileList: "文件列表",
keyboard: "键盘",
about: "关于",
},
columns: "显示的列",
@@ -52,6 +53,7 @@ export const zh = {
systemDefault: "系统默认",
resetDefault: "恢复为系统默认",
finishedTransfers: { label: "已完成传输", autoClear: "自动清除", keep: "保留" },
openFiles: { label: "回车 / 双击文件", nothing: "不执行操作", open: "打开", hint: "本地文件用默认应用打开;文件夹始终打开。" },
},
kinds: {
folder: "文件夹",
@@ -293,6 +295,108 @@ export const zh = {
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
},
keys: {
"cursor-up": "上移",
"cursor-down": "下移",
"page-up": "上一页",
"page-down": "下一页",
"cursor-first": "第一项",
"cursor-last": "最后一项",
"toggle-select": "选择 / 取消选择",
"select-all": "全选",
open: "打开",
parent: "上级目录",
"switch-panel": "切换面板",
"row-menu": "显示该项的菜单",
send: "发送到另一个面板",
edit: "编辑",
rename: "重命名",
"new-folder": "新建文件夹",
"new-file": "新建文件",
chmod: "权限",
delete: "删除",
trash: "移到废纸篓",
"copy-path": "复制路径",
reveal: "在文件管理器中显示",
"open-default": "用默认应用打开",
refresh: "刷新",
"focus-path": "转到路径",
"source-picker": "选择面板显示的内容",
"sort-name": "按名称排序",
"sort-size": "按大小排序",
"sort-mtime": "按修改时间排序",
"sort-kind": "按种类排序",
"sort-rights": "按权限排序",
"sort-owner": "按所有者排序",
"sort-group": "按组排序",
"new-tab": "新建标签页",
"close-tab": "关闭标签页",
"close-other-tabs": "关闭其他标签页",
"close-tabs-right": "关闭右侧标签页",
"next-tab": "下一个标签页",
"prev-tab": "上一个标签页",
"go-to-tab": "转到标签页 {n}",
"go-to-last-tab": "转到最后一个标签页",
"move-tab-left": "标签页左移",
"move-tab-right": "标签页右移",
"disconnect-tab": "断开标签页",
"open-settings": "设置",
"connection-manager": "连接管理器",
"toggle-transfers": "显示 / 隐藏传输",
"install-update": "安装更新",
"scale-up": "放大界面",
"scale-down": "缩小界面",
"scale-reset": "界面 100%",
"close-window": "关闭窗口",
save: "保存",
find: "查找",
"find-next": "查找下一个",
"find-prev": "查找上一个",
"toggle-wrap": "自动换行",
"conn-new": "新建连接",
"conn-new-stack": "新建分组",
"conn-save": "保存连接",
"conn-connect": "连接 / 打开分组",
"conn-delete": "删除连接或分组",
"conn-rename-stack": "重命名分组",
"conn-import": "从 WinSCP 导入",
groups: {
panels: "文件列表",
files: "文件",
sort: "排序",
tabs: "标签页",
window: "窗口",
editor: "编辑器",
connections: "连接管理器",
},
ui: {
filter: "搜索快捷键",
intro: "点击 + 并按下按键即可添加快捷键。第一个会显示在菜单和提示中。",
add: "添加快捷键",
remove: "移除 {key}",
reset: "恢复默认",
resetAll: "全部恢复",
resetAllConfirm: "恢复所有快捷键",
press: "请按键…({esc} 取消)",
unbound: "无快捷键",
reserved: "{key} 已被系统保留",
needsModifier: "在编辑器中 {key} 只会输入字符:请加上修饰键或使用 F 键",
usedBy: "{key} 已被“{action}”使用",
replace: "替换",
conflict: "与 {action} 的按键相同",
plainLetter: "在文件列表中输入 {key} 将不再进行筛选",
noResults: "没有匹配的快捷键",
fixed: "固定按键",
},
fixed: {
escape: "{esc} 关闭菜单、对话框和查找栏",
escList: "在文件列表中按 {esc} 依次清除筛选、取消连接、清除选择",
dialogs: "{enter} 和 {esc} 确认或取消对话框;{tab} 和 {shiftTab} 在其控件间移动",
menus: "↑ ↓ {home} {end} 和 {enter} 适用于所有菜单",
filter: "在文件列表中输入即可筛选;{backspace} 删除筛选的最后一个字符",
shift: "Shift 加移动键会选中光标经过的行",
find: "{enter} 和 {shiftEnter} 在查找结果间跳转",
sidebar: "↑ ↓ 在这些分区间切换",
},
names: {
ctrl: "Ctrl",
alt: "Alt",
+10 -4
View File
@@ -1,4 +1,5 @@
import { expect, test } from "bun:test";
import { DICTS } from "./i18n";
import { en } from "./i18n/en";
import {
BINDINGS,
@@ -111,10 +112,15 @@ for (const isMac of [true, false]) {
});
}
test("every command and group has an English label", () => {
const keys = en.keys as Record<string, unknown>;
for (const b of BINDINGS) expect(typeof keys["n" in b ? "go-to-tab" : b.id]).toBe("string");
for (const g of GROUPS) expect(typeof en.keys.groups[g]).toBe("string");
test("every command and group is labelled — in every shipped language", () => {
for (const [locale, dict] of Object.entries(DICTS)) {
const keys = dict.keys as Record<string, unknown> & { groups: Record<string, unknown> };
for (const b of BINDINGS) {
expect(`${locale}:${b.id}:${typeof keys["n" in b ? "go-to-tab" : b.id]}`).toBe(`${locale}:${b.id}:string`);
}
for (const g of GROUPS) expect(`${locale}:${g}:${typeof keys.groups[g]}`).toBe(`${locale}:${g}:string`);
}
expect(typeof en.keys.ui.press).toBe("string");
});
test("matching is exact about modifiers", () => {
+6
View File
@@ -403,6 +403,12 @@ export function firesInTextField(c: Combo, isMac: boolean): boolean {
return c.mod || c.alt;
}
/** Whether a plain press of the combo types a character — bound in the file
* list, that key then no longer starts a type-to-filter query. */
export function typesCharacter(c: Combo): boolean {
return !c.mod && !c.ctrl && !c.alt && (LETTER.test(c.key) || DIGIT.test(c.key) || Object.hasOwn(CHAR_OF, c.key));
}
/** Commands a held key may repeat; any other held shortcut fires once
* (holding ⌘W must not close every tab). */
const REPEATABLE = new Set<ShortcutId>([
+9 -4
View File
@@ -117,11 +117,16 @@ export class ShortcutsService {
return keys.length ? `${text} (${keys.join(' / ')})` : text;
}
/** The same for a FIXED key — one no remap moves, such as Enter in the find
* box — so it reads in the platform's spelling too (⇧↩ / Shift+Enter). */
tipFixed(text: string, combo: string): string {
/** A FIXED key — one no remap moves, such as Enter in the find box — in
* the platform's spelling (⇧↩ / Shift+Enter). */
fixedKey(combo: string): string {
const parsed = parseCombo(combo, IS_MAC);
return parsed ? `${text} (${this.format(parsed)})` : text;
return parsed ? this.format(parsed) : combo;
}
/** tip() for a fixed key. */
tipFixed(text: string, combo: string): string {
return `${text} (${this.fixedKey(combo)})`;
}
/**
@@ -15,10 +15,16 @@
<div class="flex min-h-0 flex-1">
<!-- section list -->
<nav class="flex w-44 shrink-0 flex-col gap-0.5 border-r border-border bg-chrome p-2">
<nav
#nav
(keydown)="onNavKeydown($event)"
class="flex w-44 shrink-0 flex-col gap-0.5 border-r border-border bg-chrome p-2"
>
@for (s of sections; track s) {
<button
(click)="section.set(s)"
[attr.data-section]="s"
[attr.aria-current]="section() === s ? 'page' : null"
class="px-2.5 py-1.5 text-left text-xs hover:bg-accent hover:text-foreground"
[class.bg-accent]="section() === s"
[class.font-medium]="section() === s"
@@ -266,6 +272,171 @@
</label>
}
</div>
<!-- Enter / double-click on a file: nothing (folders only) or open it -->
<div class="mt-2 flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.openFiles.label') }}</span>
<div class="flex border border-border p-0.5">
<button
(click)="settings.setOpenFilesOnEnter(false)"
class="px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="!settings.openFilesOnEnter()"
[class.text-foreground]="!settings.openFilesOnEnter()"
[class.text-muted-foreground]="settings.openFilesOnEnter()"
>
{{ i18n.t('options.openFiles.nothing') }}
</button>
<button
(click)="settings.setOpenFilesOnEnter(true)"
class="px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.openFilesOnEnter()"
[class.text-foreground]="settings.openFilesOnEnter()"
[class.text-muted-foreground]="!settings.openFilesOnEnter()"
>
{{ i18n.t('options.openFiles.open') }}
</button>
</div>
</div>
<p class="-mt-1 pl-3 text-[0.6875rem] text-muted-foreground">{{ i18n.t('options.openFiles.hint') }}</p>
}
@case ('keyboard') {
<!-- search + reset all -->
<div class="flex items-center gap-2">
<div class="relative min-w-0 flex-1">
<ng-icon
name="tdesignSearch"
size="0.8rem"
class="pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-muted-foreground"
/>
<input
[value]="keyFilter()"
(input)="keyFilter.set($any($event.target).value)"
[placeholder]="i18n.t('keys.ui.filter')"
spellcheck="false"
class="h-7 w-full border border-input bg-background pr-2 pl-7 text-xs outline-none focus:ring-2 focus:ring-ring"
/>
</div>
@if (confirmResetAll()) {
<button
(click)="resetAll()"
class="shrink-0 border border-destructive px-2 py-0.5 text-[0.6875rem] text-destructive hover:bg-destructive/10"
>
{{ i18n.t('keys.ui.resetAllConfirm') }}
</button>
<button
(click)="confirmResetAll.set(false)"
class="shrink-0 px-2 py-0.5 text-[0.6875rem] text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ i18n.t('common.cancel') }}
</button>
} @else {
<button
(click)="confirmResetAll.set(true)"
[disabled]="!anyCustom()"
class="shrink-0 border border-border px-2 py-0.5 text-[0.6875rem] hover:bg-accent disabled:opacity-40 disabled:hover:bg-transparent"
>
{{ i18n.t('keys.ui.resetAll') }}
</button>
}
</div>
<p class="text-[0.6875rem] text-muted-foreground">{{ i18n.t('keys.ui.intro') }}</p>
@for (group of keyGroups(); track group.id) {
<h3 class="mt-1 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
{{ i18n.t('keys.groups.' + group.id) }}
</h3>
<div class="-mt-2 flex flex-col">
@for (binding of group.rows; track binding.id) {
<div class="flex min-h-7 items-center gap-1.5 border-b border-border/50 py-0.5 text-xs">
<span class="min-w-0 flex-1 truncate" [title]="rowLabel(binding)">{{ rowLabel(binding) }}</span>
@if (conflictWith(binding); as others) {
<span
class="grid size-4 shrink-0 place-items-center bg-destructive/15 text-[0.625rem] font-bold text-destructive"
[title]="i18n.t('keys.ui.conflict', { action: others })"
>!</span
>
}
<div class="flex shrink-0 flex-wrap justify-end gap-1">
@for (combo of binding.combos; track $index) {
<span class="flex items-center border border-border bg-muted pl-1.5 text-[0.6875rem] tabular-nums">
{{ shortcuts.format(combo) }}
<button
(click)="removeCombo(binding, $index)"
[attr.aria-label]="i18n.t('keys.ui.remove', { key: shortcuts.format(combo) })"
[title]="i18n.t('keys.ui.remove', { key: shortcuts.format(combo) })"
class="grid size-4 place-items-center text-muted-foreground hover:text-foreground"
>
<ng-icon name="tdesignClose" size="0.6rem" />
</button>
</span>
}
@if (capturing() === binding.id) {
<span class="border border-primary px-1.5 text-[0.6875rem] text-primary">
{{ i18n.t('keys.ui.press', { esc: fixedKeys().esc }) }}
</span>
} @else if (!binding.combos.length) {
<span class="px-1 text-[0.6875rem] text-muted-foreground">{{ i18n.t('keys.ui.unbound') }}</span>
}
</div>
<button
data-record
(click)="startCapture(binding.id)"
[attr.aria-label]="i18n.t('keys.ui.add')"
[title]="i18n.t('keys.ui.add')"
class="grid size-5 shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
[class.text-primary]="capturing() === binding.id"
>
<ng-icon name="tdesignAdd" size="0.75rem" />
</button>
<button
(click)="resetRow(binding)"
[disabled]="!binding.custom"
[attr.aria-label]="i18n.t('keys.ui.reset')"
[title]="i18n.t('keys.ui.reset')"
class="grid size-5 shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:invisible"
>
<ng-icon name="tdesignRefresh" size="0.7rem" />
</button>
</div>
@if (note()?.id === binding.id) {
<div class="flex items-center gap-2 py-1 pl-3 text-[0.6875rem] text-destructive">
<span class="min-w-0 flex-1">{{ noteText(note()!) }}</span>
@if (note()!.kind === 'conflict') {
<button
(click)="replaceConflict()"
class="shrink-0 border border-border px-2 py-0.5 text-foreground hover:bg-accent"
>
{{ i18n.t('keys.ui.replace') }}
</button>
}
<button
(click)="note.set(null)"
class="shrink-0 px-2 py-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ i18n.t('common.cancel') }}
</button>
</div>
}
@if (filterKey(binding); as key) {
<p class="py-0.5 pl-3 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('keys.ui.plainLetter', { key }) }}
</p>
}
}
</div>
} @empty {
<p class="text-xs text-muted-foreground">{{ i18n.t('keys.ui.noResults') }}</p>
}
<h3 class="mt-1 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
{{ i18n.t('keys.ui.fixed') }}
</h3>
<ul class="-mt-1 flex flex-col gap-1 text-[0.6875rem] text-muted-foreground">
@for (line of fixedLines; track line) {
<li>{{ i18n.t('keys.fixed.' + line, fixedKeys()) }}</li>
}
</ul>
}
@case ('about') {
@@ -1,15 +1,29 @@
import { Component, computed, inject, signal } from '@angular/core';
import { Component, ElementRef, afterNextRender, computed, inject, signal, viewChild } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { BRANDING } from '@shared/branding';
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
import {
type Combo,
comboFromEvent,
conflicts,
firesInTextField,
GROUPS,
isReserved,
ownerOf,
type ResolvedBinding,
sameCombo,
type ShortcutId,
typesCharacter,
} from '@shared/keymap';
import { COLUMN_KEYS } from '@core/context-menu.service';
import { cycleFocus } from '@core/focus-cycle';
import { I18nService } from '@core/i18n.service';
import { blockNativeContextMenu, blockReloadKeys, IS_MAC_CHROME } from '@core/platform';
import { blockNativeContextMenu, blockReloadKeys, IS_MAC, IS_MAC_CHROME } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
const SECTIONS = ['appearance', 'transfers', 'editor', 'fileList', 'about'] as const;
const SECTIONS = ['appearance', 'transfers', 'editor', 'fileList', 'keyboard', 'about'] as const;
type Section = (typeof SECTIONS)[number];
const THEMES = ['system', 'light', 'dark'] as const;
@@ -21,10 +35,23 @@ const SPEED_UNITS = [
const TRANSFER_MODES = ['single', 'multi'] as const;
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
/** The keys no remap moves, listed read-only under the editable ones. */
const FIXED_LINES = ['escape', 'escList', 'dialogs', 'menus', 'filter', 'shift', 'find', 'sidebar'] as const;
/** A recording that could not simply be added: explained under its row. */
interface CaptureNote {
id: ShortcutId;
kind: 'reserved' | 'needsModifier' | 'conflict';
combo: Combo;
/** conflict: the command that already owns the combo. */
owner?: ShortcutId;
}
/**
* Root of the Settings window (APP_MODE 'settings'). Every control is a plain
* SettingsService setter: the save goes to bun, which rebroadcasts it, so the
* other windows follow live while this one stays open.
* other windows follow live while this one stays open — keyboard remaps
* included (menu hints, tooltips and the macOS menu bar follow at once).
*/
@Component({
selector: 'app-root',
@@ -33,13 +60,15 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
host: {
'(window:keydown)': 'onWindowKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)',
'(window:mousedown)': 'onWindowMousedown($event)',
'(window:blur)': 'capturing.set(null)',
},
})
export class SettingsWindow {
readonly settings = inject(SettingsService);
readonly i18n = inject(I18nService);
readonly shortcuts = inject(ShortcutsService);
private readonly rpc = inject(RpcService);
private readonly shortcuts = inject(ShortcutsService);
readonly isMacChrome = IS_MAC_CHROME;
readonly sections = SECTIONS;
@@ -52,11 +81,50 @@ export class SettingsWindow {
readonly transferModes = TRANSFER_MODES;
readonly concurrencyChoices = CONCURRENCY_CHOICES;
readonly columnKeys = COLUMN_KEYS;
readonly fixedLines = FIXED_LINES;
readonly appName = BRANDING.appName;
readonly buildLabel = BRANDING.buildLabel;
/** "100%" for the stepper readout; the value itself lives in settings. */
readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`);
private readonly nav = viewChild.required<ElementRef<HTMLElement>>('nav');
// ---- Settings → Keyboard ----
readonly keyFilter = signal('');
/** The command being recorded, if any. */
readonly capturing = signal<ShortcutId | null>(null);
readonly note = signal<CaptureNote | null>(null);
readonly confirmResetAll = signal(false);
readonly anyCustom = computed(() => this.shortcuts.keymap().some((b) => b.custom));
private readonly conflictMap = computed(() => conflicts(this.shortcuts.keymap()));
/** The editable rows by group, narrowed by the search field (label or keys). */
readonly keyGroups = computed(() => {
const query = this.keyFilter().trim().toLowerCase();
const keymap = this.shortcuts.keymap();
return GROUPS.map((group) => ({
id: group,
rows: keymap.filter((b) => b.group === group && (!query || this.rowText(b).includes(query))),
})).filter((group) => group.rows.length);
});
/** The fixed keys, in this platform's spelling, for the read-only lines. */
readonly fixedKeys = computed(() => ({
esc: this.shortcuts.fixedKey('Escape'),
enter: this.shortcuts.fixedKey('Enter'),
shiftEnter: this.shortcuts.fixedKey('Shift+Enter'),
tab: this.shortcuts.fixedKey('Tab'),
shiftTab: this.shortcuts.fixedKey('Shift+Tab'),
backspace: this.shortcuts.fixedKey('Backspace'),
home: this.shortcuts.fixedKey('Home'),
end: this.shortcuts.fixedKey('End'),
}));
constructor() {
// the keyboard starts on the current section
afterNextRender(() => this.sectionButton(this.section())?.focus());
}
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
editorAppName(): string {
const path = this.settings.editorApp();
@@ -74,16 +142,153 @@ export class SettingsWindow {
void this.rpc.request.showAbout({});
}
/** This window's keys: close-window, and the any-window interface scale. */
// ---- sections ----
/** ↑ / ↓ in the sidebar move between sections (and focus follows). */
onNavKeydown(event: KeyboardEvent): void {
const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
if (!step) return;
event.preventDefault();
const next = SECTIONS[(SECTIONS.indexOf(this.section()) + step + SECTIONS.length) % SECTIONS.length];
this.section.set(next);
this.sectionButton(next)?.focus();
}
private sectionButton(section: Section): HTMLElement | null {
return this.nav().nativeElement.querySelector<HTMLElement>(`[data-section="${section}"]`);
}
// ---- keyboard remaps ----
rowLabel(binding: ResolvedBinding): string {
return binding.n
? this.i18n.t('keys.go-to-tab', { n: binding.n })
: this.i18n.t(`keys.${binding.id}`);
}
private labelOf(id: ShortcutId): string {
const binding = this.shortcuts.binding(id);
return binding ? this.rowLabel(binding) : id;
}
private rowText(binding: ResolvedBinding): string {
return [this.rowLabel(binding), ...binding.combos.map((c) => this.shortcuts.format(c))]
.join(' ')
.toLowerCase();
}
/** "Rename, Edit" when this row shares a key with others that can collide. */
conflictWith(binding: ResolvedBinding): string {
return (this.conflictMap().get(binding.id) ?? []).map((id) => this.labelOf(id)).join(', ');
}
/** A plain character key on a file-list row: typing it no longer filters. */
filterKey(binding: ResolvedBinding): string {
if (!binding.scopes.some((s) => s === 'panel' || s === 'main')) return '';
const plain = binding.combos.find(typesCharacter);
return plain ? this.shortcuts.format(plain) : '';
}
noteText(note: CaptureNote): string {
const key = this.shortcuts.format(note.combo);
if (note.kind === 'reserved') return this.i18n.t('keys.ui.reserved', { key });
if (note.kind === 'needsModifier') return this.i18n.t('keys.ui.needsModifier', { key });
return this.i18n.t('keys.ui.usedBy', { key, action: note.owner ? this.labelOf(note.owner) : '' });
}
startCapture(id: ShortcutId): void {
this.note.set(null);
this.capturing.set(this.capturing() === id ? null : id);
}
/**
* One keydown while recording, taken before every other key in this window
* (⌘W records instead of closing). A bare modifier keeps waiting; Esc on its
* own cancels. Reserved combos are refused WITHOUT preventDefault, so ⌘Q
* still quits and ⌘C still copies.
*/
private record(event: KeyboardEvent, id: ShortcutId): void {
const combo = comboFromEvent(event, IS_MAC);
if (combo && isReserved(combo, IS_MAC)) {
this.capturing.set(null);
this.note.set({ id, kind: 'reserved', combo });
return;
}
event.preventDefault();
event.stopPropagation();
if (event.repeat || !combo) return;
const bare = !combo.mod && !combo.ctrl && !combo.alt && !combo.shift;
this.capturing.set(null);
if (bare && combo.key === 'Escape') return;
const binding = this.shortcuts.binding(id);
if (!binding || binding.combos.some((c) => sameCombo(c, combo))) return;
// the editor's text area always has focus: a plain key could never fire there
if (binding.scopes.includes('editor') && !firesInTextField(combo, IS_MAC)) {
this.note.set({ id, kind: 'needsModifier', combo });
return;
}
const owner = ownerOf(this.shortcuts.keymap(), combo, binding.scopes, id);
if (owner) {
this.note.set({ id, kind: 'conflict', combo, owner: owner.id });
return;
}
this.shortcuts.rebind({ [id]: [...binding.combos, combo] });
}
/** "Replace": the key moves from the command that had it to this one. */
replaceConflict(): void {
const note = this.note();
const binding = note && this.shortcuts.binding(note.id);
const owner = note?.owner && this.shortcuts.binding(note.owner);
if (!note || !binding || !owner) return;
this.shortcuts.rebind({
[note.id]: [...binding.combos, note.combo],
[owner.id]: owner.combos.filter((c) => !sameCombo(c, note.combo)),
});
this.note.set(null);
}
removeCombo(binding: ResolvedBinding, index: number): void {
this.shortcuts.rebind({ [binding.id]: binding.combos.filter((_, i) => i !== index) });
}
resetRow(binding: ResolvedBinding): void {
this.shortcuts.rebind({ [binding.id]: binding.defaults });
if (this.note()?.id === binding.id) this.note.set(null);
}
resetAll(): void {
this.shortcuts.resetAll();
this.confirmResetAll.set(false);
this.note.set(null);
}
// ---- window ----
/** Recording first; then this window's keys (close-window, the scale) and
* Tab, walked by hand — WKWebView skips buttons on Tab otherwise. */
onWindowKeydown(event: KeyboardEvent): void {
const recording = this.capturing();
if (recording) {
this.record(event, recording);
return;
}
const id = this.shortcuts.claim(event, 'settings');
if (id === 'close-window') void this.rpc.request.settingsClose({});
else if (id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
else if (id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
else if (id === 'scale-reset') this.settings.setUiScale(1);
else if (!id) cycleFocus(document.body, event);
blockReloadKeys(event);
}
/** A click anywhere but a record button ends a recording. */
onWindowMousedown(event: MouseEvent): void {
if (this.capturing() && !(event.target as Element).closest('[data-record]')) {
this.capturing.set(null);
}
}
blockContextMenu(event: Event): void {
blockNativeContextMenu(event);
}