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. a standard titlebar.
- **Menu-bar (tray) icon**: template icon that adapts to light/dark; click it - **Menu-bar (tray) icon**: template icon that adapts to light/dark; click it
to bring the app to the front. 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 → and remote files directly; or pick an external app (Settings → Editor →
Custom → Choose…). External edits of remote files download to a temp folder, 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 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 the session disconnects. Leftover temp files from a crash are offered for
cleanup at the next start. 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). - Create folder / file, rename, delete (recursive), change permissions (chmod).
- Upload, download, and remote→remote send — files **and** whole folders — - Upload, download, and remote→remote send — files **and** whole folders —
with a transfer queue, per-job progress, and cancel. 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 ## Keyboard shortcuts
`Tab` switch panel · `↑`/`↓`/`PgUp`/`PgDn`/`Home`/`End` move cursor · The whole app works from the keyboard, and **every command can be remapped** in
`Space` select · `Enter` open folder · `Backspace` parent · **Settings → Keyboard**: press **+** on a row and type the keys, see conflicts,
`Ctrl/Cmd+A` select all · `F5` send to other panel · `F2` rename · reset one row or all. Remaps apply live in every window, the right-click menus'
`F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab · hints and the toolbar tooltips follow, and so does the macOS menu bar. The
`Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab · `Cmd/Ctrl+,` settings · defaults:
`Cmd/Ctrl+=`/`-`/`0` interface bigger / smaller / 100%.
(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 ## 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 `getSettings`/`saveSettings`/`pickEditorApp`/`showAbout`/`settingsClose`. Its
page claims its own close-window key (`settingsClose`); a ⌘W it doesn't claim 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 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, window's focus FIRST and closes it (see gotchas). Sidebar sections (↑ / ↓ move
Transfers, Editor, File list (visible columns — the column-header right-click between them, focus starts on the current one, Tab is walked by `cycleFocus`):
menu toggles the same setting), About (opens the native About box). The Appearance, Transfers, Editor, File list (visible columns — the column-header
native title is `options.title`, retitled on a locale switch. 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 ## 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 atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
spread-merge on load, so NEW SETTINGS KEYS need no backend change. spread-merge on load, so NEW SETTINGS KEYS need no backend change.
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the - `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
Connect submenu mirrors the store; "Settings…" carries ⌘,). ⌘W is intercepted Connect submenu mirrors the store), language switch and keyboard remap.
by the menu, so bun routes `close-tab` to a focused Settings or editor window **Accelerators come from the shared keymap** (`effectiveKeymap` +
first. Labels go through `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. backend `i18n.t` and the menu is rebuilt on a locale change too.
- `i18n.ts` — backend translator (native menus + thrown error messages, which - `i18n.ts` — backend translator (native menus + thrown error messages, which
no Angular service can reach). `setLocale(setting)` caches the resolved 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 are forwarded to the active list (`PanelsService.forwardListKey`), and `App`'s
`(mousedown)` keeps focus on the list after chrome-button clicks. `(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 **macOS key equivalents are page-first.** WKWebView hands ⌘-keys to the page
before the app menu (WebKit `WebViewImpl::performKeyEquivalent`); only keys the before the app menu (WebKit `WebViewImpl::performKeyEquivalent`); only keys the
page doesn't `preventDefault` reach the menu bar. So the page owns every app 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. path bar) hides keys from it — don't add one.
- **Hold-to-repeat** fired ⌘W once per repeat — every tab closed. `claim()` - **Hold-to-repeat** fired ⌘W once per repeat — every tab closed. `claim()`
repeats only movement-like commands (`isRepeatable`). 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 - **Focus after a state change needs `afterNextRender({ injector })`.** Under
zoneless CD `queueMicrotask` runs before the render that creates an `@if` zoneless CD `queueMicrotask` runs before the render that creates an `@if`
element (the editor's find box and textarea), so `.focus()` hit nothing. 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) | | 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 | — | | 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) | | 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 | | 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 | | 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 | | 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({}), connectionsChanged: () => rpc.send.connectionsChanged({}),
menuAction: (info) => rpc.send.menuAction(info), menuAction: (info) => rpc.send.menuAction(info),
}, },
rebuildMenu: () => void applyApplicationMenu(store), rebuildMenu: () => void applyApplicationMenu(store, () => currentSettings.keybindings),
onSettingsSaved: (s) => settingsSaved(s, "manager"), onSettingsSaved: (s) => settingsSaved(s, "manager"),
}); });
const settingsWin = new SettingsWindow({ const settingsWin = new SettingsWindow({
@@ -134,12 +134,14 @@ function settingsSaved(
): void { ): void {
engine.setConcurrency(effectiveConcurrency(s)); engine.setConcurrency(effectiveConcurrency(s));
const localeChanged = s.locale !== currentSettings.locale; 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 currentSettings = s; // future windows' preloads bake the fresh theme
if (localeChanged) { if (localeChanged) {
setBackendLocale(s.locale); setBackendLocale(s.locale);
void applyApplicationMenu(store); // relabel the native menu in the new language
settingsWin.retitle(); 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 !== "main") rpc.send.settingsChanged({ settings: s });
if (sender !== "manager") manager.broadcastSettings(s); if (sender !== "manager") manager.broadcastSettings(s);
if (sender !== "settings") settingsWin.broadcastSettings(s); if (sender !== "settings") settingsWin.broadcastSettings(s);
@@ -258,16 +260,19 @@ events.sessionClosed = (info) => {
rpc.send.sessionClosed(info); rpc.send.sessionClosed(info);
editors.notifySessionClosed(info); editors.notifySessionClosed(info);
}; };
events.connectionsChanged = () => void applyApplicationMenu(store); events.connectionsChanged = () => void applyApplicationMenu(store, () => currentSettings.keybindings);
engine.setConcurrency(effectiveConcurrency(currentSettings)); engine.setConcurrency(effectiveConcurrency(currentSettings));
void applyApplicationMenu(store); void applyApplicationMenu(store, () => currentSettings.keybindings);
ApplicationMenu.on("application-menu-clicked", (event) => { ApplicationMenu.on("application-menu-clicked", (event) => {
const action = (event as { data?: { action?: string } }).data?.action; const action = (event as { data?: { action?: string } }).data?.action;
if (!action) return; if (!action) return;
// ⌘W while the Settings window or an editor window is focused closes THAT // Their pages claim close-window (⌘W) themselves — macOS hands key
// window (an editor with its dirty prompt), not a main-window tab. Other // equivalents to the page first — so a close-tab that still arrives while
// menu actions deliberately still target the main window. // 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 (action === "close-tab") {
if (settingsWin.isFocused()) { if (settingsWin.isFocused()) {
settingsWin.close(); 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 // the manager is modal to the main window — don't drive main behind it
if (manager.isOpen()) return; 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 }); rpc.send.menuAction({ action });
}); });
+33 -11
View File
@@ -1,5 +1,6 @@
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/main"; import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/main";
import { BRANDING } from "../shared/branding"; import { BRANDING } from "../shared/branding";
import { effectiveKeymap, type ShortcutId, toAccelerator } from "../shared/keymap";
import type { SavedConnection } from "../shared/types"; import type { SavedConnection } from "../shared/types";
import { t } from "./i18n"; import { t } from "./i18n";
import type { ConnectionStore } from "./store/connections"; 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 * macOS application menu (electrobun menus are macOS-only). Standard roles plus
* app actions; custom actions are forwarded to the webview as menuAction * app actions; custom actions are forwarded to the webview as menuAction
* messages. Rebuilt whenever saved connections change so the Connect submenu * messages. Rebuilt whenever saved connections, the language or the keyboard
* stays current. * 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; if (process.platform !== "darwin") return;
const { stacks, connections } = await store.load(); 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 => ({ const item = (c: SavedConnection): ApplicationMenuItemConfig => ({
label: c.name, label: c.name,
action: `connect:${c.id}`, action: `connect:${c.id}`,
@@ -28,7 +46,11 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
...looseItems, ...looseItems,
...stackMenus, ...stackMenus,
...(looseItems.length || stackMenus.length ? [{ type: "separator" } as const] : []), ...(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([ ApplicationMenu.setApplicationMenu([
@@ -36,7 +58,7 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
submenu: [ submenu: [
{ label: t("menu.about", { app: BRANDING.appName }), action: "about" }, { label: t("menu.about", { app: BRANDING.appName }), action: "about" },
{ type: "separator" }, { type: "separator" },
{ label: t("menu.settings"), action: "settings", accelerator: "," }, { label: t("menu.settings"), action: "settings", accelerator: accelerator("open-settings") },
{ type: "separator" }, { type: "separator" },
{ label: t("menu.checkForUpdates"), action: "check-for-updates" }, { label: t("menu.checkForUpdates"), action: "check-for-updates" },
{ type: "separator" }, { type: "separator" },
@@ -53,13 +75,13 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
{ {
label: t("menu.file"), label: t("menu.file"),
submenu: [ submenu: [
{ label: t("menu.newTab"), action: "new-tab", accelerator: "t" }, { label: t("menu.newTab"), action: "new-tab", accelerator: accelerator("new-tab") },
{ label: t("menu.closeTab"), action: "close-tab", accelerator: "w" }, { label: t("menu.closeTab"), action: "close-tab", accelerator: accelerator("close-tab") },
{ type: "separator" }, { type: "separator" },
{ label: t("menu.newFolder"), action: "new-folder" }, { label: t("menu.newFolder"), action: "new-folder", accelerator: accelerator("new-folder") },
{ label: t("menu.newFile"), action: "new-file" }, { label: t("menu.newFile"), action: "new-file", accelerator: accelerator("new-file") },
{ type: "separator" }, { type: "separator" },
{ label: t("menu.editFile"), action: "edit-file", accelerator: "e" }, { label: t("menu.editFile"), action: "edit-file", accelerator: accelerator("edit") },
{ type: "separator" }, { type: "separator" },
{ label: t("menu.connect"), submenu: connectSubmenu }, { label: t("menu.connect"), submenu: connectSubmenu },
], ],
@@ -81,7 +103,7 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
{ {
label: t("menu.view"), label: t("menu.view"),
submenu: [ submenu: [
{ label: t("menu.refresh"), action: "refresh-active", accelerator: "r" }, { label: t("menu.refresh"), action: "refresh-active", accelerator: accelerator("refresh") },
{ type: "separator" }, { type: "separator" },
{ role: "toggleFullScreen" }, { role: "toggleFullScreen" },
], ],
+104
View File
@@ -36,6 +36,7 @@ export const de = {
transfers: "Übertragungen", transfers: "Übertragungen",
editor: "Editor", editor: "Editor",
fileList: "Dateiliste", fileList: "Dateiliste",
keyboard: "Tastatur",
about: "Über", about: "Über",
}, },
columns: "Sichtbare Spalten", columns: "Sichtbare Spalten",
@@ -51,6 +52,7 @@ export const de = {
systemDefault: "Systemstandard", systemDefault: "Systemstandard",
resetDefault: "Auf Standard zurücksetzen", resetDefault: "Auf Standard zurücksetzen",
finishedTransfers: { label: "Abgeschlossene Übertragungen", autoClear: "Auto-löschen", keep: "Behalten" }, 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: { kinds: {
folder: "Ordner", folder: "Ordner",
@@ -293,6 +295,108 @@ export const de = {
trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar", trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar",
}, },
keys: { 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: { names: {
ctrl: "Strg", ctrl: "Strg",
alt: "Alt", alt: "Alt",
+33
View File
@@ -40,6 +40,7 @@ export const en = {
transfers: "Transfers", transfers: "Transfers",
editor: "Editor", editor: "Editor",
fileList: "File list", fileList: "File list",
keyboard: "Keyboard",
about: "About", about: "About",
}, },
columns: "Visible columns", columns: "Visible columns",
@@ -55,6 +56,7 @@ export const en = {
systemDefault: "System default", systemDefault: "System default",
resetDefault: "Reset to system default", resetDefault: "Reset to system default",
finishedTransfers: { label: "Finished transfers", autoClear: "Auto-clear", keep: "Keep" }, 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: { kinds: {
folder: "Folder", folder: "Folder",
@@ -372,6 +374,37 @@ export const en = {
editor: "Editor", editor: "Editor",
connections: "Connection manager", 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 ⌘⇧⌫) // key names for "Ctrl+Shift+Del"-style hints (not used on macOS, which shows ⌘⇧⌫)
names: { names: {
ctrl: "Ctrl", ctrl: "Ctrl",
+104
View File
@@ -36,6 +36,7 @@ export const es = {
transfers: "Transferencias", transfers: "Transferencias",
editor: "Editor", editor: "Editor",
fileList: "Lista de archivos", fileList: "Lista de archivos",
keyboard: "Teclado",
about: "Acerca de", about: "Acerca de",
}, },
columns: "Columnas visibles", columns: "Columnas visibles",
@@ -51,6 +52,7 @@ export const es = {
systemDefault: "Predeterminado del sistema", systemDefault: "Predeterminado del sistema",
resetDefault: "Restablecer al predeterminado", resetDefault: "Restablecer al predeterminado",
finishedTransfers: { label: "Transferencias completadas", autoClear: "Auto-limpiar", keep: "Mantener" }, 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: { kinds: {
folder: "Carpeta", 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í", trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí",
}, },
keys: { 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: { names: {
ctrl: "Ctrl", ctrl: "Ctrl",
alt: "Alt", alt: "Alt",
+104
View File
@@ -38,6 +38,7 @@ export const eu = {
transfers: "Transferentziak", transfers: "Transferentziak",
editor: "Editorea", editor: "Editorea",
fileList: "Fitxategi-zerrenda", fileList: "Fitxategi-zerrenda",
keyboard: "Teklatua",
about: "Honi buruz", about: "Honi buruz",
}, },
columns: "Zutabe ikusgaiak", columns: "Zutabe ikusgaiak",
@@ -53,6 +54,7 @@ export const eu = {
systemDefault: "Sistemaren lehenetsia", systemDefault: "Sistemaren lehenetsia",
resetDefault: "Berrezarri lehenetsira", resetDefault: "Berrezarri lehenetsira",
finishedTransfers: { label: "Amaitutako transferentziak", autoClear: "Garbitu", keep: "Mantendu" }, 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: { kinds: {
folder: "Karpeta", folder: "Karpeta",
@@ -295,6 +297,108 @@ export const eu = {
trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat", trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat",
}, },
keys: { 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: { names: {
ctrl: "Ctrl", ctrl: "Ctrl",
alt: "Alt", alt: "Alt",
+104
View File
@@ -36,6 +36,7 @@ export const fr = {
transfers: "Transferts", transfers: "Transferts",
editor: "Éditeur", editor: "Éditeur",
fileList: "Liste des fichiers", fileList: "Liste des fichiers",
keyboard: "Clavier",
about: "À propos", about: "À propos",
}, },
columns: "Colonnes visibles", columns: "Colonnes visibles",
@@ -51,6 +52,7 @@ export const fr = {
systemDefault: "Valeur par défaut du système", systemDefault: "Valeur par défaut du système",
resetDefault: "Réinitialiser par défaut", resetDefault: "Réinitialiser par défaut",
finishedTransfers: { label: "Transferts terminés", autoClear: "Effacer auto.", keep: "Garder" }, 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: { kinds: {
folder: "Dossier", 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", trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici",
}, },
keys: { 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: { names: {
ctrl: "Ctrl", ctrl: "Ctrl",
alt: "Alt", alt: "Alt",
+104
View File
@@ -37,6 +37,7 @@ export const ja = {
transfers: "転送", transfers: "転送",
editor: "エディタ", editor: "エディタ",
fileList: "ファイル一覧", fileList: "ファイル一覧",
keyboard: "キーボード",
about: "情報", about: "情報",
}, },
columns: "表示する列", columns: "表示する列",
@@ -52,6 +53,7 @@ export const ja = {
systemDefault: "システムの既定", systemDefault: "システムの既定",
resetDefault: "既定に戻す", resetDefault: "既定に戻す",
finishedTransfers: { label: "完了した転送", autoClear: "自動クリア", keep: "保持" }, finishedTransfers: { label: "完了した転送", autoClear: "自動クリア", keep: "保持" },
openFiles: { label: "Enter / ファイルをダブルクリック", nothing: "何もしない", open: "開く", hint: "ローカルファイルは既定のアプリで開きます。フォルダは常に開きます。" },
}, },
kinds: { kinds: {
folder: "フォルダ", folder: "フォルダ",
@@ -293,6 +295,108 @@ export const ja = {
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません", trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません",
}, },
keys: { 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: { names: {
ctrl: "Ctrl", ctrl: "Ctrl",
alt: "Alt", alt: "Alt",
+104
View File
@@ -37,6 +37,7 @@ export const zh = {
transfers: "传输", transfers: "传输",
editor: "编辑器", editor: "编辑器",
fileList: "文件列表", fileList: "文件列表",
keyboard: "键盘",
about: "关于", about: "关于",
}, },
columns: "显示的列", columns: "显示的列",
@@ -52,6 +53,7 @@ export const zh = {
systemDefault: "系统默认", systemDefault: "系统默认",
resetDefault: "恢复为系统默认", resetDefault: "恢复为系统默认",
finishedTransfers: { label: "已完成传输", autoClear: "自动清除", keep: "保留" }, finishedTransfers: { label: "已完成传输", autoClear: "自动清除", keep: "保留" },
openFiles: { label: "回车 / 双击文件", nothing: "不执行操作", open: "打开", hint: "本地文件用默认应用打开;文件夹始终打开。" },
}, },
kinds: { kinds: {
folder: "文件夹", folder: "文件夹",
@@ -293,6 +295,108 @@ export const zh = {
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能", trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
}, },
keys: { 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: { names: {
ctrl: "Ctrl", ctrl: "Ctrl",
alt: "Alt", alt: "Alt",
+10 -4
View File
@@ -1,4 +1,5 @@
import { expect, test } from "bun:test"; import { expect, test } from "bun:test";
import { DICTS } from "./i18n";
import { en } from "./i18n/en"; import { en } from "./i18n/en";
import { import {
BINDINGS, BINDINGS,
@@ -111,10 +112,15 @@ for (const isMac of [true, false]) {
}); });
} }
test("every command and group has an English label", () => { test("every command and group is labelled — in every shipped language", () => {
const keys = en.keys as Record<string, unknown>; for (const [locale, dict] of Object.entries(DICTS)) {
for (const b of BINDINGS) expect(typeof keys["n" in b ? "go-to-tab" : b.id]).toBe("string"); const keys = dict.keys as Record<string, unknown> & { groups: Record<string, unknown> };
for (const g of GROUPS) expect(typeof en.keys.groups[g]).toBe("string"); 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", () => { 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; 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 /** Commands a held key may repeat; any other held shortcut fires once
* (holding ⌘W must not close every tab). */ * (holding ⌘W must not close every tab). */
const REPEATABLE = new Set<ShortcutId>([ const REPEATABLE = new Set<ShortcutId>([
+9 -4
View File
@@ -117,11 +117,16 @@ export class ShortcutsService {
return keys.length ? `${text} (${keys.join(' / ')})` : text; return keys.length ? `${text} (${keys.join(' / ')})` : text;
} }
/** The same for a FIXED key — one no remap moves, such as Enter in the find /** A FIXED key — one no remap moves, such as Enter in the find box — in
* box — so it reads in the platform's spelling too (⇧↩ / Shift+Enter). */ * the platform's spelling (⇧↩ / Shift+Enter). */
tipFixed(text: string, combo: string): string { fixedKey(combo: string): string {
const parsed = parseCombo(combo, IS_MAC); 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"> <div class="flex min-h-0 flex-1">
<!-- section list --> <!-- 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) { @for (s of sections; track s) {
<button <button
(click)="section.set(s)" (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="px-2.5 py-1.5 text-left text-xs hover:bg-accent hover:text-foreground"
[class.bg-accent]="section() === s" [class.bg-accent]="section() === s"
[class.font-medium]="section() === s" [class.font-medium]="section() === s"
@@ -266,6 +272,171 @@
</label> </label>
} }
</div> </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') { @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 { NgIcon } from '@ng-icons/core';
import { BRANDING } from '@shared/branding'; import { BRANDING } from '@shared/branding';
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n'; 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 { COLUMN_KEYS } from '@core/context-menu.service';
import { cycleFocus } from '@core/focus-cycle';
import { I18nService } from '@core/i18n.service'; 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 { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.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]; type Section = (typeof SECTIONS)[number];
const THEMES = ['system', 'light', 'dark'] as const; const THEMES = ['system', 'light', 'dark'] as const;
@@ -21,10 +35,23 @@ const SPEED_UNITS = [
const TRANSFER_MODES = ['single', 'multi'] as const; const TRANSFER_MODES = ['single', 'multi'] as const;
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] 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 * 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 * 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({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -33,13 +60,15 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
host: { host: {
'(window:keydown)': 'onWindowKeydown($event)', '(window:keydown)': 'onWindowKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)', '(window:contextmenu)': 'blockContextMenu($event)',
'(window:mousedown)': 'onWindowMousedown($event)',
'(window:blur)': 'capturing.set(null)',
}, },
}) })
export class SettingsWindow { export class SettingsWindow {
readonly settings = inject(SettingsService); readonly settings = inject(SettingsService);
readonly i18n = inject(I18nService); readonly i18n = inject(I18nService);
readonly shortcuts = inject(ShortcutsService);
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
private readonly shortcuts = inject(ShortcutsService);
readonly isMacChrome = IS_MAC_CHROME; readonly isMacChrome = IS_MAC_CHROME;
readonly sections = SECTIONS; readonly sections = SECTIONS;
@@ -52,11 +81,50 @@ export class SettingsWindow {
readonly transferModes = TRANSFER_MODES; readonly transferModes = TRANSFER_MODES;
readonly concurrencyChoices = CONCURRENCY_CHOICES; readonly concurrencyChoices = CONCURRENCY_CHOICES;
readonly columnKeys = COLUMN_KEYS; readonly columnKeys = COLUMN_KEYS;
readonly fixedLines = FIXED_LINES;
readonly appName = BRANDING.appName; readonly appName = BRANDING.appName;
readonly buildLabel = BRANDING.buildLabel; readonly buildLabel = BRANDING.buildLabel;
/** "100%" for the stepper readout; the value itself lives in settings. */ /** "100%" for the stepper readout; the value itself lives in settings. */
readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`); 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. */ /** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
editorAppName(): string { editorAppName(): string {
const path = this.settings.editorApp(); const path = this.settings.editorApp();
@@ -74,16 +142,153 @@ export class SettingsWindow {
void this.rpc.request.showAbout({}); 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 { onWindowKeydown(event: KeyboardEvent): void {
const recording = this.capturing();
if (recording) {
this.record(event, recording);
return;
}
const id = this.shortcuts.claim(event, 'settings'); const id = this.shortcuts.claim(event, 'settings');
if (id === 'close-window') void this.rpc.request.settingsClose({}); 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-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-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
else if (id === 'scale-reset') this.settings.setUiScale(1); else if (id === 'scale-reset') this.settings.setUiScale(1);
else if (!id) cycleFocus(document.body, event);
blockReloadKeys(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 { blockContextMenu(event: Event): void {
blockNativeContextMenu(event); blockNativeContextMenu(event);
} }