Compare commits

..
7 Commits
Author SHA1 Message Date
jgamboaandClaude 5be41db032 Redraw every icon on the pixel grid it renders at
On a 1x screen TDesign's glyphs smeared at 14 and 16px: their 24-unit
strokes land between pixels, so every ~1px line covered two at half ink.
All 37 icon/size pairs are now TDesign's shapes redrawn on their own grid
(viewBox 0 0 N N, so one unit is one CSS pixel):

- Straight lines are runs of whole pixels that end on pixel edges, with
  no caps or joins. WebKit drops square caps on a path of several
  subpaths, and Skia strokes 1px lines as hairlines that leave a joined
  corner pixel three-quarters inked.
- Slants and curves are 1.25px so they read as heavy as the crisp runs.
  Their ends, vertices and circle extremes sit on pixel centres.
- server24 keeps TDesign's 24px geometry; only its dots move onto whole
  pixels.

Only closeCircle24 (TDesign's own 24px grid) and the spinner still come
from @ng-icons/tdesign-icons. This also fixes add14 from 5d23809, whose
square-capped ends rendered half-inked in WebKit. icons.spec.ts now also
checks that every grid name's glyph uses that grid's viewBox.

Checked at 1x and 2x in WebKit (the app's macOS engine) and in Chrome
(Skia, as in WebView2 and WebKitGTK); straight runs fill whole pixels in
both. In WebKit at 1x, a copy of the tab bar, toolbar, file rows, menu
and shortcut-row markup rendered every straight-line glyph with zero
blur, including icons laid out on fractional pixels.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-09 13:49:42 +02:00
jgamboaandClaude 5d23809d48 Name every icon by the pixel grid it is drawn for
Groundwork for redrawing the icons on their pixel grids. A name is now
<icon><px> (server14, server16, server24) and must match the size token
where it renders: 14 at --icon-size-sm, 16 at -md, 24 at -xl.
tdesignLoading is the one exception, since it spins. Every name not yet
redrawn aliases its TDesign glyph, so nothing changes on screen except
the manager-open overlay icon, which moves from lg to xl ("a status
mark on a card").

- pixel-icons.ts holds the redrawn glyphs, so far add14 (the + from
  3f7145d, renamed from pixelAdd).
- TS code that picks icons takes Icon14 / Icon16 (menu items, file
  glyphs, the copy arrow, toasts, transfer states), so build:ui
  rejects a wrong name.
- icons.spec.ts checks every <ng-icon> in the skins: it has a size
  token, and each literal name, both sides of a [name] ternary
  included, is registered and drawn for that token's grid.
- TDesign's file1 is now fileText, since file1 plus a suffix would
  read file114.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-09 13:24:38 +02:00
jgamboaandClaude 3f7145d69a Draw the + on the pixel grid so it stays sharp on 1x screens
On a 1x display TDesign's + smeared at every small size: its 24-grid
strokes are centred on the box's middle, which at 12 or 14px is a pixel
boundary, so each ~1px bar covered two pixels at half ink. A thinner
stroke alone keeps the bar on the boundary. This is not Electrobun:
headless Chrome renders the same.

pixelAdd (icons.ts) redraws the + on the 14px grid with 1px bars at 7.5,
so they fill whole pixels at 1x and 2x, half a pixel off-centre. It
replaces tdesignAdd in the tab bar, the shortcut rows, the connection
manager and the tab menu. Checked at 1x in the real tab-bar and
shortcut-row markup: single-pixel bars at full ink.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-09 12:09:20 +02:00
jgamboaandClaude fa2cec7136 Size every icon on TDesign's scale: four rem tokens, whole pixels
TDesign draws its glyphs on a 24px grid with a 2px stroke and sizes a
component's icon 2px above its text, in whole pixels. The templates had
drifted to 12 one-off sizes, most of them fractional (0.9rem = 14.4px), so
strokes blurred and neighbours never matched: the tab bar's 14.4px cog
beside a 12.8px +, the shortcut rows' 12px + beside an 11.2px reset and
9.6px chip x.

Every <ng-icon> now takes one of --icon-size-sm (14px: beside 11-12px
text, small icon buttons), -md (16px: file rows, window titles, path-bar
buttons), -lg (20px) or -xl (24px: lone spinners and status marks).
--action-icon goes; architecture.md and gotchas.md carry the rule.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-09 10:19:58 +02:00
jgamboaandClaude e28314054f Drive the connection manager from the keyboard
The manager had only Esc and the scale keys: its list took one Tab stop
per row with no arrows, a stack's rename / delete / open-in-tabs buttons
existed only on hover, and no action had a key.

- Commands (the manager keymap scope, remappable like the rest): new
  connection with focus in Name, new stack, save, connect — or open the
  focused stack in tabs — delete the focused row, rename the focused
  stack, import from WinSCP, close the window.
- The list: up/down/Home/End move focus between rows (focus only:
  loading a connection would discard a half-edited form), Enter opens the
  focused connection, left/right collapse and expand a stack, and a
  stack's actions show while the keyboard is in its row.
- Enter in a form field connects; Esc leaves a field before it closes the
  window; Tab is walked by hand (WKWebView skips buttons and radios); focus
  comes back after a dialog and never stays lost on <body>.
- Tooltips show the keys; the Settings → Keyboard fixed-key list gains the
  manager's.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-05 14:59:37 +02:00
jgamboaandClaude 8f8f230711 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>
2026-10-05 14:57:27 +02:00
jgamboaandClaude d361297690 Put every shortcut on one shared keymap and make the app keyboard-reachable
The keymap was scattered over ShortcutsService if-chains, the file panel's
key switch, macOS-only menu accelerators and hand-typed hints in 16
tooltips x 7 locales, and large parts of the app had no keyboard path at
all. src/shared/keymap.ts is now the one table: scopes, per-platform
defaults, exact matching, formatting with localized key names, conflicts,
reserved combos and native-menu accelerators, proven by keymap.spec.ts.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-05 14:48:11 +02:00
54 changed files with 4341 additions and 929 deletions
+100 -10
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.
@@ -56,7 +58,7 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu
| Desktop | Electrobun 2.0 via Hutch (native webview, typed RPC bridge) | | Desktop | Electrobun 2.0 via Hutch (native webview, typed RPC bridge) |
| Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` | | Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` |
| Styling | Tailwind CSS v4, Oxide-derived tokens (no component library) | | Styling | Tailwind CSS v4, Oxide-derived tokens (no component library) |
| Icons | @ng-icons/tdesign-icons | | Icons | TDesign shapes redrawn on 14/16px pixel grids (`@ng-icons`) |
| SSH/SFTP | `ssh2` (pure-JS under Bun) | | SSH/SFTP | `ssh2` (pure-JS under Bun) |
The repo is a Bun workspace: the Electrobun app at the root, the Angular app in The repo is a Bun workspace: the Electrobun app at the root, the Angular app in
@@ -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
+205 -37
View File
@@ -78,9 +78,12 @@ Re-opening focuses the existing window. The webview fetches its own content
**Close safety** (designed when electrobun window `close` was NOT cancellable — **Close safety** (designed when electrobun window `close` was NOT cancellable —
1.18.1 had no `allow` response like `before-quit`; 2.x adds a cancellable 1.18.1 had no `allow` response like `before-quit`; 2.x adds a cancellable
`will-close` for the titlebar close, not used yet — see gotchas): `will-close` for the titlebar close, not used yet — see gotchas):
1. ⌘W (routed via the app menu → `editorCloseRequest`) / Close button → 1. ⌘W (the page's own `close-window` key; a ⌘W it doesn't claim falls through
styled in-window Save/Don't Save/Cancel; the confirm path calls to the app menu, which bun routes back as `editorCloseRequest`) / Close
`editorClose` which sets `suppressPrompt` before `win.close()`. button → styled in-window Save/Don't Save/Cancel, which takes focus (Save;
⌘S = Save, Esc = Cancel, Tab cycles) so keys can't edit the text behind it;
the confirm path calls `editorClose` which sets `suppressPrompt` before
`win.close()`.
2. Traffic-light close → the window is already gone, but the editor mirrored 2. Traffic-light close → the window is already gone, but the editor mirrored
its draft to bun (`editorState`, 400 ms debounce + flush on blur/dirty-flip) its draft to bun (`editorState`, 400 ms debounce + flush on blur/dirty-flip)
→ native Save/Discard box from the held draft. A failed save (dead session) → native Save/Discard box from the held draft. A failed save (dead session)
@@ -96,7 +99,11 @@ to the main window (panels showing that directory refresh). Editor upgrades:
scroll-synced line gutter (hidden when wrap is on — wrapped line numbers would scroll-synced line gutter (hidden when wrap is on — wrapped line numbers would
need per-edit height measurement), Alt/⌥Z wrap toggle, custom find bar need per-edit height measurement), Alt/⌥Z wrap toggle, custom find bar
(matches computed in TS, `setSelectionRange` + scrollTop math — deliberately (matches computed in TS, `setSelectionRange` + scrollTop math — deliberately
not `findInPage`, which is a shim on WebView2). not `findInPage`, which is a shim on WebView2). Find next / previous (⌘G /
⇧⌘G, F3 / ⇧F3) step from the caret with the bar open or closed — the query
survives closing it. Focus after a state change goes through
`afterNextRender({ injector })`: under zoneless CD a microtask runs BEFORE the
render that creates the `@if` element.
### Connection-manager window (`src/bun/manager-window.ts`) ### Connection-manager window (`src/bun/manager-window.ts`)
@@ -114,19 +121,51 @@ main's `ConnectionsService` reloads; a reload-retry in `App.findConnection`
covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it
would float over editor windows). would float over editor windows).
Keyboard (the `manager` keymap scope): ⌘N new connection (focus lands in
Name), ⇧⌘N new stack, ⌘S save, ⌘↩ connect — or, on a focused stack row, open
it in tabs — ⌘⌫ / Del delete the focused row (with a confirm), F2 rename the
focused stack, ⌘O import, ⌘W close. The list's rows are real buttons
(`data-row`; `data-stack` / `data-conn` say what they are): ↑ ↓ Home End move
FOCUS only — loading a connection would replace a half-edited form without
asking — Enter opens the focused connection in the form, ← / → collapse and
expand a stack (← on a connection climbs to its stack), and a stack's
hover-only actions also show while the keyboard is in its row. Enter in a form
field = Connect; Esc leaves a field first, then closes the window. Focus
returns where it was after a dialog (`focusin` memory + the FocusService
tick) and falls back to the edited connection's row whenever it drops to
`<body>`.
### Settings window (`src/bun/settings-window.ts` + `ui/src/app/skins/shadlike/settings-window/`) ### Settings window (`src/bun/settings-window.ts` + `ui/src/app/skins/shadlike/settings-window/`)
Single instance, centered over main, **not modal**: every control is a plain Single instance, centered over main, **not modal**: every control is a plain
`SettingsService` setter, so each edit is an ordinary save that bun `SettingsService` setter, so each edit is an ordinary save that bun
rebroadcasts — the other windows follow live while it stays open. Opened by rebroadcasts — the other windows follow live while it stays open. Opened by
the tab-bar gear and Ctrl+, (`openSettings` from main) and, on macOS, the app the tab-bar gear and the open-settings key (⌘, / Ctrl+,, `openSettings` from
menu's "Settings…" (⌘,, which the menu intercepts first). Its RPC serves only main) and, on macOS, the app menu's "Settings…". Its RPC serves only
`getSettings`/`saveSettings`/`pickEditorApp`/`showAbout`. ⌘W reaches bun as `getSettings`/`saveSettings`/`pickEditorApp`/`showAbout`/`settingsClose`. Its
the main window's `close-tab` menu action, so bun checks the Settings window's page claims its own close-window key (`settingsClose`); a ⌘W it doesn't claim
focus FIRST and closes it (see gotchas). Sidebar sections: Appearance, falls through to the app menu's Close Tab, and bun checks the Settings
Transfers, Editor, File list (visible columns — the column-header right-click window's focus FIRST and closes it (see gotchas). Sidebar sections (↑ / ↓ move
menu toggles the same setting), About (opens the native About box). The between them, focus starts on the current one, Tab is walked by `cycleFocus`):
native title is `options.title`, retitled on a locale switch. 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 ## Bun modules
@@ -177,9 +216,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
@@ -251,11 +297,22 @@ component; tokens → `ui/src/styles.css`.
save too — the scale shortcuts — and the Settings window is nothing but save too — the scale shortcuts — and the Settings window is nothing but
saves); bun's `settingsSaved()` in index.ts runs the saves); bun's `settingsSaved()` in index.ts runs the
side effects once and rebroadcasts to every window **except the sender**, so side effects once and rebroadcasts to every window **except the sender**, so
there is no echo to fight a held Cmd+. `applyAll` never persists. New setting there is no echo to fight a held Cmd+. `applyAll` never persists. Saves are
= five touches here + types.ts (backend merges automatically). whole-object, so **nothing persists until the first `getSettings` landed**
(`loaded`): a ⌘+ pressed while a window opens would otherwise write the
defaults over every remap. New setting = five touches here + types.ts
(backend merges automatically; sanitize hand-editable shapes in
`SettingsStore.load`).
- `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the - `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the
main window's panels listen. Exists so dialogs don't inject PanelsService main window's panels listen — but a panel only takes focus while NO dialog or
(whose constructor spawns a workspace — fatal in secondary windows). menu is up (a run of overwrite questions reuses one dialog instance; focusing
the list behind it handed Enter/Delete to the panel). Exists so dialogs don't
inject PanelsService (whose constructor spawns a workspace — fatal in
secondary windows).
- `shortcuts.service.ts` — the keyboard layer over `src/shared/keymap.ts`; see
"Keyboard" below.
- `focus-cycle.ts` — `cycleFocus(root, event)`: Tab / ⇧Tab by hand (see
"Keyboard").
- `connections.service.ts` — store mirror + ssh-config hosts; reloads on the - `connections.service.ts` — store mirror + ssh-config hosts; reloads on the
`connectionsChanged` push. `connectionsChanged` push.
- `i18n.service.ts` — runtime translator. `locale` signal + `t('ns.key', params)` - `i18n.service.ts` — runtime translator. `locale` signal + `t('ns.key', params)`
@@ -267,20 +324,42 @@ component; tokens → `ui/src/styles.css`.
/`speed()` so the file list reformats on switch. `SettingsService.applyLocale` /`speed()` so the file list reformats on switch. `SettingsService.applyLocale`
resolves the `locale` setting (`system` → `navigator.language`) into it, on resolves the `locale` setting (`system` → `navigator.language`) into it, on
boot and on the `settingsChanged` broadcast (so editor/manager windows follow). boot and on the `settingsChanged` broadcast (so editor/manager windows follow).
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always - `file-actions.service.ts` — **one file-command dispatcher**,
against an explicit panel; `menuTarget` remembers the right-clicked row. `run(id, { side, panel, entry })`, for every entry point: list keys (the
cursor row), row-menu picks (the right-clicked row, captured in the menu's
`onPick` closure), the macOS menu bar's File/View items and the panel
toolbar. Ids are keymap `FileCommand`s (`open`, `open-default`, `send`,
`edit`, `rename`, `new-folder`, `new-file`, `chmod`, `delete`, `trash`,
`copy-path`, `reveal`, `refresh`, `sort-*`). Selection if any, else the
target row; `..` stands for the folder. `open` enters folders and opens a
LOCAL file in its default app only when `openFilesOnEnter` is set (Settings
→ File list); `open-default` (the menu's Open on a local file) always does.
`trash` walks the selection one `moveToTrash` at a time. Main-window only
(it injects TransfersService).
- `context-menu.service.ts` + `skins/shadlike/context-menu.ts` — the panel and tab right-click - `context-menu.service.ts` + `skins/shadlike/context-menu.ts` — the panel and tab right-click
menus, in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was menus, in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was
retired on ALL platforms rather than kept as a second code path (see retired on ALL platforms rather than kept as a second code path (see
platform-notes.md). The service builds the item list (row / empty-space menu, platform-notes.md). The service builds the item list (row / empty-space menu,
and the header column picker) and holds the open request as a signal; the the header column picker, and the panel **source picker** — Local, drives,
component renders it at the pointer with edge-flip clamping, arrow/Enter/Escape connections under per-stack `header` items, `~/.ssh/config` hosts — which
keys, and close-on-blur/resize/scroll. Picked items are emitted verbatim as replaced a bespoke popover so it gets the same keys and look) and holds the
`ctx-*` / `col:*` / `tab:*` strings through `RpcService.emitMenuAction`, so they open request as a signal; the component renders it at a point
land in the SAME `App.handleMenuAction` dispatcher the macOS app menubar uses — (`MenuPoint`: a MouseEvent, or a row rect for a keyboard-opened menu) with
one handler, two sources. The three shell actions the webview cannot perform edge-flip clamping, a scrolling clamped box, and close-on-blur/resize/scroll.
(reveal, open-with-default, copy path) go back to bun via the `shellItemAction` Items carry `shortcut` (a keymap id: its first key is the soft hint at 60%
RPC, which is all that remains of the old native menu. of the row colour, and pressing it while the menu is open picks the item)
and `icon` (drawn in the check column; only the everyday verbs get one —
Send, Edit, Rename, Delete, New Folder, New/Close Tab, the toolbar's own
glyphs). Keys: ↑↓ Home End move the one highlight (mouse hover shares it),
Enter/Space pick, Esc closes, items are not Tab stops; every handled key
stops propagation so the window handler never runs it twice. A keyboard-opened
menu starts with its first item highlighted (`initialIndex`). A request's
`onPick` takes the pick (row menu → `FileActionsService.run`, source picker →
`FilePanel`); otherwise `col:*` / `tab:*` strings go through
`RpcService.emitMenuAction` to the SAME `App.handleMenuAction` dispatcher the
macOS app menubar uses. The three shell actions the webview cannot perform
(reveal, open-with-default, copy path) go back to bun via the
`shellItemAction` RPC, which is all that remains of the old native menu.
`tabItems()` builds the workspace-tab menu (`tab:new`, and `tabItems()` builds the workspace-tab menu (`tab:new`, and
`tab:close|close-others|close-right|disconnect:<id>`), served by `tab:close|close-others|close-right|disconnect:<id>`), served by
`PanelsService.closeOtherWorkspaces` / `closeWorkspacesToRight` / `PanelsService.closeOtherWorkspaces` / `closeWorkspacesToRight` /
@@ -293,14 +372,24 @@ component; tokens → `ui/src/styles.css`.
walker check the flag themselves. walker check the flag themselves.
- `file-icons.ts` — extension → category map driving glyphs, colors - `file-icons.ts` — extension → category map driving glyphs, colors
(fixed `--icon-*` tokens from Oxide's hue scales) and the Kind column. (fixed `--icon-*` tokens from Oxide's hue scales) and the Kind column.
- `icons.ts` — the one icon registry (TDesign, `@ng-icons/tdesign-icons`), passed to - `icons.ts` — the one icon registry, passed to `provideIcons` in `app.config.ts`.
`provideIcons` in `app.config.ts`. Icon names are plain strings in templates and A name is `<icon><px>`: the pixel grid its glyph is drawn for, which must match
the build never checks them: an unregistered name renders nothing (console error the size token where it renders (`server14` at `--icon-size-sm`, `server16` at
only), so every name used anywhere must be listed here. `-md`, `server24` at `-xl`). The one exception is `tdesignLoading`, which spins.
Every glyph is TDesign's shape redrawn on its pixel grid in `pixel-icons.ts`,
as whole-pixel runs plus 1.25px slants (rules in gotchas). Only `closeCircle24`
(TDesign's own 24px grid) and the spinner still come from
`@ng-icons/tdesign-icons`. Templates can't type-check
icon names, and an unregistered one renders nothing (a console warning only), so
`icons.spec.ts` checks every `<ng-icon>` in the skins. TS code that picks an icon
takes the `Icon14` / `Icon16` types.
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`, - `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
`EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`, `EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`) +
`MOD_LABEL`/`ALT_LABEL`) + `blockNativeContextMenu` (kills WebKit's `blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside text
Reload/Inspect menu outside text fields — the app draws its own menu instead). fields — the app draws its own menu instead) + `blockReloadKeys` (swallows
unclaimed F5 / Ctrl+R / Ctrl+F5 / Ctrl+Shift+R on Windows, where WebView2
would reload the page and drop every session; each window root calls it
last).
- `updater.service.ts` — auto-updater state for the tab-bar pill. Signals - `updater.service.ts` — auto-updater state for the tab-bar pill. Signals
`state` (`idle→available→downloading→ready`|`error`), `version`, `progress`. `state` (`idle→available→downloading→ready`|`error`), `version`, `progress`.
Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on
@@ -310,6 +399,79 @@ component; tokens → `ui/src/styles.css`.
`src/bun/index.ts`; feed is `release.baseUrl` (`https://miniscp.com/dl`, the `src/bun/index.ts`; feed is `release.baseUrl` (`https://miniscp.com/dl`, the
`/dl` host in `site/`). See build-and-release.md "Auto-updater". `/dl` host in `site/`). See build-and-release.md "Auto-updater".
## Keyboard
**One keymap** — `src/shared/keymap.ts`, shared so the webviews and bun (the
macOS menu bar) read the same table. `BINDINGS` lists every command: `id`,
`group` (Settings sections), `scopes`, and default combos per platform
(`mac` / `other`, best first: the first is the hint menus and tooltips show).
Combos are platform-neutral strings (`Mod+Shift+N`: Mod = ⌘ on macOS, Ctrl
elsewhere; `Ctrl` a literal ⌃ only macOS tells apart). Letters match the
layout's character (AZERTY's A is A) and fall back to the physical key under
Alt (⌥Z types Ω) and on non-Latin layouts; digits match physically (AZERTY
types "&" on 1); punctuation by character first (AZERTY's comma is on the M
key), physical second (⇧] makes "}"). **Matching is exact**: ⌃F is not ⌘F,
Ctrl+Tab is not Tab, and neither the Windows key nor AltGr is ever part of a
binding. `effectiveKeymap(overrides)` applies `AppSettings.keybindings`
(rows that differ from the defaults; `[]` unbinds) and drops anything
malformed. `conflicts()` / `ownerOf()` work over overlapping scopes; the
spec (`keymap.spec.ts`) proves the defaults conflict-free, round-trippable and
unshadowed on both platforms.
**Scopes.** `panel` = the focused file list (navigation, file commands,
sorting); `main` = anywhere in the main window (tabs, window commands); `editor`
/ `manager` / `settings` per window; `any` = every window (interface scale).
`panel` sits inside `main`, so they conflict-check together.
**`ShortcutsService`** resolves keydowns against the effective keymap
(reactive: a remap applies live in every window via the settings broadcast) and
formats hints — `label(id)` / `labels(id)` / `tip(text, id)` /
`tipFixed(text, 'Shift+Enter')`, with **localized key names** off macOS
(`keys.names.*`: German says Strg+Entf). `claim()` = resolve + `preventDefault`
(so the macOS menu never runs it twice) + auto-repeat only for movement-like
commands (holding ⌘W closes one tab). IME composition is never a shortcut.
From a text field only `firesInTextField()` combos count: ⌘/Ctrl and Alt chords
and F-keys, but not plain keys, not text-editing chords (⌘⌫, ⌥←, ⌘A) and not
Ctrl+Alt off macOS (AltGr).
**Dispatch** (one place per window): an open context menu owns its keys; an
open dialog owns Enter / Esc / Tab while the main window swallows window
commands and the panel commands the macOS menu bar carries (an unclaimed ⌘T
would otherwise reach the menu and open a tab behind the dialog); the focused
list (`FilePanel.onKeydown`) runs, in order, the fixed filter-editing keys
while a query is typed, the `panel` keymap (Shift + a movement key extends the
selection over the rows passed — a fixed rule that follows remaps), lets
`main` matches bubble, then type-to-filter, then Esc's fixed chain (clear the
filter → cancel a connect → clear the selection); `App` runs `main`. Every root
ends with `blockReloadKeys`. Keys that land on `<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
shortcut, and a window that doesn't claim a key lets it fall through to the
menu (⌘W in a window whose close-window was remapped away still hits Close Tab,
which bun routes to the focused Settings / editor window first).
**Focus without Keyboard navigation.** WKWebView's Tab skips buttons,
checkboxes and radios unless the user turned on System Settings → Keyboard →
Keyboard navigation, so `cycleFocus(root, event)` walks Tab / ⇧Tab by hand
(DOM order, wrap, a radio group is one stop): every dialog (it is also their
focus trap), the transfer list (⌘J opens it focused), the editor's save prompt.
Dialogs carry `role="dialog" aria-modal`, take initial focus (the chmod dialog
on its octal field, Enter applies) and stop Esc from propagating — a
window-level Esc (the connection manager closes on it) must not see it too. A
base-layer `:focus-visible` ring in `--ring` marks the focused control.
**Opening a row's menu from the keyboard**: ⇧F10 / the Menu key / ⌃↩ (macOS)
open the cursor row's menu under the row, first item highlighted; the
selection is kept, its commands act on it like the F-keys do.
## Theming ## Theming
`ui/src/styles.css`: OKLCH tokens adapted from **Oxide's design system** `ui/src/styles.css`: OKLCH tokens adapted from **Oxide's design system**
@@ -352,7 +514,13 @@ none`, a drawn check / square dot), selects (a `currentColor` caret; use
`::-webkit-scrollbar`s (which make macOS scrollbars always visible). `::-webkit-scrollbar`s (which make macOS scrollbars always visible).
`@theme inline` maps tokens to Tailwind utilities (`bg-primary`, `@theme inline` maps tokens to Tailwind utilities (`bg-primary`,
`text-icon-folder`, `bg-toolbar-2`, `bg-cursor-tint`). File-type icon colors `text-icon-folder`, `bg-toolbar-2`, `bg-cursor-tint`). File-type icon colors
come from Oxide's hue scales (800 steps dark, 600 light). **Interface scale** (`settings.uiScale`, come from Oxide's hue scales (800 steps dark, 600 light). **Icon sizes** follow
TDesign's rule (an icon is 2px larger than its text, in whole pixels) through
four rem tokens, and every `<ng-icon>` takes one: `--icon-size-sm` 14px (beside
11–12px text, small icon buttons), `-md` 16px (13–14px text: file rows, window
titles; the path-bar buttons), `-lg` 20px and `-xl` 24px (lone spinners and
status marks). The glyphs are drawn on the sm, md and xl grids (`icons.ts`).
**Interface scale** (`settings.uiScale`,
0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets 0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets
`html { font-size: 16px * scale }`, and since EVERY size in the app is rem `html { font-size: 16px * scale }`, and since EVERY size in the app is rem
(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes), the (panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes), the
+91 -5
View File
@@ -76,6 +76,31 @@ touching the adjacent code.
moment the pseudo-element is styled, WKWebView draws classic always-visible moment the pseudo-element is styled, WKWebView draws classic always-visible
scrollbars that take layout width. Deliberate here (square design); don't scrollbars that take layout width. Deliberate here (square design); don't
reach for it to "tint" scrollbars without accepting that. reach for it to "tint" scrollbars without accepting that.
- **An icon size is one of the four `--icon-size-*` tokens, never a one-off
rem.** TDesign glyphs are 24-unit SVGs with 2-unit strokes, so a fractional
size (0.9rem = 14.4px) lands every stroke between device pixels and blurs
it. Per-template picks had drifted to 12 sizes, and neighbours stopped
matching: a 14.4px cog beside a 12.8px +, and a 12px + beside 9.6px × in
the shortcut chips.
- **On a 1x screen, TDesign's glyphs smear at 14 and 16px, so every icon is
redrawn on its pixel grid (`pixel-icons.ts`).** Their 24-unit strokes land
between pixels, and a ~1px line covers two pixels at half ink. This is
geometry, not Electrobun: headless Chrome renders the same. The drawing rules
each cost a debugging round:
- **Straight lines are runs of whole pixels** (`h`, `v`, `box`) that end on
pixel edges. Never rely on caps or joins:
- WebKit drops square caps on a `<path>` with several subpaths.
- Skia (WebView2, WebKitGTK) strokes 1px lines as hairlines at 1x, which
leave a joined corner pixel three-quarters inked.
- **Slants and curves (`slant`) are 1.25px**, so they read as heavy as the
crisp 1px runs. Put their ends, vertices and a circle's extremes on pixel
centres; a 45° line is cleanest where x−y or x+y is whole.
- **A glyph is exact only on its own grid**: a 14px glyph blurs at 16px, so
the name's suffix must match the size token (`icons.spec.ts`). It is also
exact only at UI scale 100/200/300%.
- **Check new glyphs at 1x in WebKit and Chrome, not resvg.** resvg skews a
shape's closing edge and leaves path corners partly inked, so its pixels
don't predict either browser.
## The interface scale (root font-size lever) ## The interface scale (root font-size lever)
@@ -136,11 +161,18 @@ touching the adjacent code.
update-initiated) — a plain `Utils.quit()` there quits without installing. update-initiated) — a plain `Utils.quit()` there quits without installing.
`Utils.quit()` runs the same approval, which is why the guard checks `Utils.quit()` runs the same approval, which is why the guard checks
`quitConfirmed` first. `quitConfirmed` first.
- **⌘W in any secondary window arrives as the MAIN window's `close-tab` - **macOS ⌘-keys go to the PAGE first; only unclaimed ones reach the app
menu action** (macOS: the app menu intercepts it before the webview sees menu.** WKWebView's `performKeyEquivalent` hands key equivalents to the page
the key). bun must check the focused secondary window first — the Settings while the webview is first responder (WebKit `WebViewImpl.mm`: "lets
window, then editors — and close THAT; a new window kind that skips this webpages have a crack at intercepting key-modified keypresses"); a key the
check makes ⌘W close a main-window tab behind it. page `preventDefault`s never reaches the menu. Old comments here claimed the
opposite ("the menu intercepts ⌘W first") — what was really happening is that
windows which didn't claim ⌘W let it fall through to the menu's Close Tab.
Two consequences: a handled shortcut MUST `preventDefault` (`claim()` does),
or the menu runs it a second time; and a ⌘W a secondary window doesn't claim
still arrives as the MAIN window's `close-tab` action — bun checks the
focused Settings window, then editors, first and closes THAT, so a new
window kind must either claim close-window or join that check.
- **One rpc instance per window** — BrowserView binding steals the rpc's - **One rpc instance per window** — BrowserView binding steals the rpc's
transport; a shared instance silently sends to the wrong webview. transport; a shared instance silently sends to the wrong webview.
- **RPC before socket open was silently lost on 1.18.1.** 2.x Electroview queues - **RPC before socket open was silently lost on 1.18.1.** 2.x Electroview queues
@@ -270,6 +302,56 @@ touching the adjacent code.
`libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib` `libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib`
(upstream electrobun's choice, not ours). (upstream electrobun's choice, not ours).
## Keyboard (`src/shared/keymap.ts`, `ui/src/app/core/shortcuts.service.ts`)
- **WKWebView skips buttons, checkboxes and radios on Tab** unless the user
turned on System Settings → Keyboard → Keyboard navigation (or presses ⌥Tab).
Dialogs, Settings and the manager were mouse-only on a default Mac. Tab is
walked by hand: `cycleFocus(root, event)` (core/focus-cycle.ts) — use it in
any new dialog or window.
- **WebKit doesn't focus a clicked button — focus drops to `<body>`** (Windows
parks it on the button, where Space clicks it again). Every list key died
until the list was clicked. `App` `(mousedown)` keeps focus on chrome-button
clicks and forwards `<body>` keys to the active list; keep both.
- **Match modifiers exactly.** The old panel switch caught every Tab, so
Ctrl+Tab flipped the active panel AND switched tabs; `isMod = metaKey ||
ctrlKey` made ⌃F (Cocoa's forward-one-character) open the editor's find.
`matchesEvent` compares all four modifiers, platform-exact.
- **AltGr is Ctrl+Alt on Windows/Linux.** A Ctrl+Alt+letter binding steals
"@", "€", "ł"… — never a default there, never fired from a text field, and
an event with `getModifierState('AltGraph')` matches nothing.
- **Type-to-filter takes every printable key.** A plain-letter binding (vim
j/k) must resolve BEFORE it — and then that letter no longer starts a query
(Settings warns). While a query is being typed, printable keys (Space too)
always extend it.
- **In a master-detail list, selection must not follow focus** when the detail
is an unsaved form: the connection manager's `edit()` replaces the draft
without asking, so arrowing through the list would silently discard edits.
Arrows move focus; Enter loads.
- **A dialog's Esc must `stopPropagation`.** The connection manager closes on
a window-level Escape; the dialog cleared its own signal first, so the
window handler saw "no dialog open" and closed the whole window.
- **An unclaimed key behind a dialog reaches the macOS menu** — ⌘T opened a
tab behind the rename prompt. The main window swallows window commands and
the menu bar's panel commands while a dialog or menu is up.
- **WebView2 reloads the page on unclaimed F5 / Ctrl+R** (default browser
accelerators), dropping every session and the editor's unsaved text. Every
window root ends with `blockReloadKeys`; a blanket `stopPropagation` (the old
path bar) hides keys from it — don't add one.
- **Hold-to-repeat** fired ⌘W once per repeat — every tab closed. `claim()`
repeats only movement-like commands (`isRepeatable`).
- **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.
## Domain logic ## Domain logic
- **sessionId ≠ filesystem identity.** Two panels on the same server are two - **sessionId ≠ filesystem identity.** Two panels on the same server are two
@@ -287,6 +369,10 @@ touching the adjacent code.
- Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so - Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so
new keys auto-default, but the UI SettingsService reads field-by-field — new keys auto-default, but the UI SettingsService reads field-by-field —
a new setting silently stays at default until wired in all five spots. a new setting silently stays at default until wired in all five spots.
- **Saves are whole-object: a window must not save before its first
`getSettings` landed.** A ⌘+ pressed while an editor opened wrote
`DEFAULT_SETTINGS` (no keyboard remaps) over the user's file.
`SettingsService.persist()` is a no-op until `applyAll` ran once.
- **Every window's RPC must serve `saveSettings`.** The scale shortcuts - **Every window's RPC must serve `saveSettings`.** The scale shortcuts
shipped in editor and manager windows while only the main RPC served it: shipped in editor and manager windows while only the main RPC served it:
the request went unanswered, the change applied locally, and main's next the request went unanswered, the change applied locally, and main's next
+6 -2
View File
@@ -17,9 +17,13 @@ Every platform seam carries a `PORTABILITY:` comment at the branch site.
| Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty | | Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty |
| chmod/Permissions | context-menu.service.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full | | chmod/Permissions | context-menu.service.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full |
| Shell wording | `ui context-menu.service.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager | | Shell wording | `ui context-menu.service.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
| Shortcut labels | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ | | Shortcut labels | `src/shared/keymap.ts formatCombo` (+ `keys.names.*`) | ⌘⇧⌥⌃ glyphs: ⇧⌘N | Ctrl+Shift+N, localized key names (Strg+Entf) | as Windows |
| Command modifier | `keymap.ts` `Mod` | ⌘ only (⌃ stays Cocoa's text keys) | Ctrl | Ctrl |
| Key equivalents | `shortcuts.service.ts claim` | page first, unclaimed → app menu | page only (no app menu) | page only |
| Tab focus | `core/focus-cycle.ts cycleFocus` | WKWebView skips buttons/checkboxes without Keyboard navigation → walked by hand | native order (same walk) | native order (same walk) |
| Reload keys | `platform.ts blockReloadKeys` | — | WebView2 reloads on F5 / Ctrl+R → swallowed when unclaimed | — |
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored (needs AppIndicator, see below) | | 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" },
], ],
+7
View File
@@ -467,6 +467,8 @@ export interface SettingsRpcHooks {
/** Every edit here is a settings save — rebroadcast it to the other windows. */ /** Every edit here is a settings save — rebroadcast it to the other windows. */
onSettingsSaved: (next: AppSettings) => void; onSettingsSaved: (next: AppSettings) => void;
showAbout: () => void; showAbout: () => void;
/** The window's own close-window key. */
close: () => void;
} }
/** RPC for the single Settings window (per-window instance). */ /** RPC for the single Settings window (per-window instance). */
@@ -483,6 +485,11 @@ export function createSettingsRPC(deps: { settings: SettingsStore }, hooks: Sett
hooks.showAbout(); hooks.showAbout();
return {}; return {};
}), }),
settingsClose: () =>
wrap(async () => {
hooks.close();
return {};
}),
}, },
}, },
}); });
+4 -3
View File
@@ -23,9 +23,9 @@ export interface SettingsWindowDeps {
/** /**
* The single Settings window. Unlike the connection manager it is NOT modal: * The single Settings window. Unlike the connection manager it is NOT modal:
* every edit is an ordinary settings save, so the other windows follow live * every edit is an ordinary settings save, so the other windows follow live
* while it stays open. On macOS ⌘W reaches bun as the main window's close-tab * while it stays open. Its page claims its own close-window key (settingsClose).
* menu action, so index.ts asks isFocused() first and closes this window * A ⌘W it doesn't claim (remapped away) falls through to the app menu's Close
* instead of a main-window tab. * Tab, which index.ts routes here while this window is focused.
*/ */
export class SettingsWindow { export class SettingsWindow {
private win: BrowserWindow | null = null; private win: BrowserWindow | null = null;
@@ -49,6 +49,7 @@ export class SettingsWindow {
{ {
onSettingsSaved: (next) => this.deps.onSettingsSaved(next), onSettingsSaved: (next) => this.deps.onSettingsSaved(next),
showAbout: () => this.deps.showAbout(), showAbout: () => this.deps.showAbout(),
close: () => this.close(),
}, },
); );
+4
View File
@@ -1,5 +1,6 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { sanitizeKeybindings } from "../../shared/keymap";
import { clampUiScale, DEFAULT_SETTINGS, type AppSettings } from "../../shared/types"; import { clampUiScale, DEFAULT_SETTINGS, type AppSettings } from "../../shared/types";
import { appDataDir } from "./paths"; import { appDataDir } from "./paths";
@@ -24,6 +25,9 @@ export class SettingsStore {
// into every window's root font-size, so a hand-edited settings.json // into every window's root font-size, so a hand-edited settings.json
// (uiScale: 100, "2", NaN) must not be able to render the UI unusable // (uiScale: 100, "2", NaN) must not be able to render the UI unusable
settings.uiScale = clampUiScale(Number(settings.uiScale)); settings.uiScale = clampUiScale(Number(settings.uiScale));
// same reasoning: every window resolves keys through this map
settings.keybindings = sanitizeKeybindings(data.keybindings);
settings.openFilesOnEnter = data.openFilesOnEnter === true;
return settings; return settings;
} catch { } catch {
return { ...DEFAULT_SETTINGS }; return { ...DEFAULT_SETTINGS };
+139 -14
View File
@@ -21,8 +21,8 @@ export const de = {
tabBar: { tabBar: {
connections: "Verbindungen", connections: "Verbindungen",
manageConnections: "Verbindungen verwalten", manageConnections: "Verbindungen verwalten",
newTab: "Neuer Tab ({mod}T)", newTab: "Neuer Tab",
closeTab: "Tab schließen ({mod}W)", closeTab: "Tab schließen",
options: "Einstellungen", options: "Einstellungen",
update: "Update verfügbar", update: "Update verfügbar",
updating: "Aktualisiere", updating: "Aktualisiere",
@@ -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",
@@ -75,17 +77,16 @@ export const de = {
panel: { panel: {
choosePanel: "Wählen, was dieses Panel anzeigt", choosePanel: "Wählen, was dieses Panel anzeigt",
copy: "Senden", copy: "Senden",
copyToRight: "An rechtes Panel senden (F5)", copyToRight: "An rechtes Panel senden",
copyToLeft: "An linkes Panel senden (F5)", copyToLeft: "An linkes Panel senden",
edit: "Bearbeiten", edit: "Bearbeiten",
editFile: "Datei bearbeiten (F4)", editFile: "Datei bearbeiten",
rename: "Umbenennen (F2)", rename: "Umbenennen",
newFolder: "Neuer Ordner (F7)", newFolder: "Neuer Ordner",
newFile: "Neue Datei", newFile: "Neue Datei",
permissions: "Berechtigungen", permissions: "Berechtigungen",
delete: "Löschen", delete: "Löschen",
deleteHint: "Löschen (F8 / Entf)", parentDir: "Übergeordneter Ordner",
parentDir: "Übergeordneter Ordner (Rücktaste)",
refresh: "Aktualisieren", refresh: "Aktualisieren",
clearFilter: "Filter löschen (Esc)", clearFilter: "Filter löschen (Esc)",
sshConfig: "~/.ssh/config", sshConfig: "~/.ssh/config",
@@ -144,16 +145,16 @@ export const de = {
noFileTitle: "Keine Datei zum Bearbeiten", noFileTitle: "Keine Datei zum Bearbeiten",
noFileBody: "Dieses Fenster wurde ohne Editor-Kontext geöffnet. Öffne Dateien aus der App.", noFileBody: "Dieses Fenster wurde ohne Editor-Kontext geöffnet. Öffne Dateien aus der App.",
wrap: "Umbruch", wrap: "Umbruch",
wrapHint: "Lange Zeilen umbrechen — blendet Zeilennummern aus ({alt}Z)", wrapHint: "Lange Zeilen umbrechen — blendet Zeilennummern aus",
findHint: "Suchen ({mod}F)", findHint: "Suchen",
findPlaceholder: "In Datei suchen", findPlaceholder: "In Datei suchen",
prevMatch: "Vorheriger Treffer (⇧↵)", prevMatch: "Vorheriger Treffer",
nextMatch: "Nächster Treffer (↵)", nextMatch: "Nächster Treffer",
closeHint: "Schließen (Esc)", closeHint: "Schließen (Esc)",
disconnected: "Verbindung zu {name} getrennt — Speichern schlägt fehl, aber dein Text bleibt in diesem Fenster erhalten.", disconnected: "Verbindung zu {name} getrennt — Speichern schlägt fehl, aber dein Text bleibt in diesem Fenster erhalten.",
cantOpen: "{name} kann nicht geöffnet werden", cantOpen: "{name} kann nicht geöffnet werden",
closeWindow: "Fenster schließen", closeWindow: "Fenster schließen",
shortcuts: "{mod}S speichern · {mod}F suchen · {mod}W schließen", verbs: { save: "speichern", find: "suchen", close: "schließen" },
lines: { one: "{count} Zeile", other: "{count} Zeilen" }, lines: { one: "{count} Zeile", other: "{count} Zeilen" },
close: "Schließen", close: "Schließen",
save: "Speichern", save: "Speichern",
@@ -209,6 +210,7 @@ export const de = {
}, },
toasts: { toasts: {
movedToTrash: '„{name}" in den Papierkorb verschoben', movedToTrash: '„{name}" in den Papierkorb verschoben',
movedManyToTrash: { one: "{count} Objekt in den Papierkorb verschoben", other: "{count} Objekte in den Papierkorb verschoben" },
editingExternal: "Bearbeite {name} — Speichern lädt automatisch hoch", editingExternal: "Bearbeite {name} — Speichern lädt automatisch hoch",
nothingToCopy: "Nichts zu kopieren (symbolische Links werden übersprungen)", nothingToCopy: "Nichts zu kopieren (symbolische Links werden übersprungen)",
nothingToCopyAllSkipped: "Nichts zu kopieren — alle Konflikte wurden übersprungen", nothingToCopyAllSkipped: "Nichts zu kopieren — alle Konflikte wurden übersprungen",
@@ -292,6 +294,129 @@ export const de = {
tabDisconnect: "Trennen", tabDisconnect: "Trennen",
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: {
"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",
manager: "↑ ↓ ← → und {enter} bewegen sich durch die Verbindungsliste",
sidebar: "↑ ↓ wechseln hier zwischen den Bereichen",
},
names: {
ctrl: "Strg",
alt: "Alt",
shift: "Umschalt",
enter: "Eingabe",
backspace: "Rücktaste",
del: "Entf",
esc: "Esc",
space: "Leertaste",
tab: "Tab",
pgup: "Bild↑",
pgdn: "Bild↓",
home: "Pos1",
end: "Ende",
ins: "Einfg",
menu: "Menü",
num: "Num",
},
},
errors: { errors: {
sshAgentUnavailable: "ssh-agent nicht verfügbar (SSH_AUTH_SOCK ist nicht gesetzt)", sshAgentUnavailable: "ssh-agent nicht verfügbar (SSH_AUTH_SOCK ist nicht gesetzt)",
identityFileNotFound: "Identitätsdatei nicht gefunden: {path}", identityFileNotFound: "Identitätsdatei nicht gefunden: {path}",
+144 -14
View File
@@ -25,8 +25,8 @@ export const en = {
tabBar: { tabBar: {
connections: "Connections", connections: "Connections",
manageConnections: "Manage connections", manageConnections: "Manage connections",
newTab: "New tab ({mod}T)", newTab: "New tab",
closeTab: "Close tab ({mod}W)", closeTab: "Close tab",
options: "Settings", options: "Settings",
update: "Update available", update: "Update available",
updating: "Updating", updating: "Updating",
@@ -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",
@@ -79,17 +81,16 @@ export const en = {
panel: { panel: {
choosePanel: "Choose what this panel shows", choosePanel: "Choose what this panel shows",
copy: "Send", copy: "Send",
copyToRight: "Send to right panel (F5)", copyToRight: "Send to right panel",
copyToLeft: "Send to left panel (F5)", copyToLeft: "Send to left panel",
edit: "Edit", edit: "Edit",
editFile: "Edit file (F4)", editFile: "Edit file",
rename: "Rename (F2)", rename: "Rename",
newFolder: "New folder (F7)", newFolder: "New folder",
newFile: "New file", newFile: "New file",
permissions: "Permissions", permissions: "Permissions",
delete: "Delete", delete: "Delete",
deleteHint: "Delete (F8 / Del)", parentDir: "Parent directory",
parentDir: "Parent directory (Backspace)",
refresh: "Refresh", refresh: "Refresh",
clearFilter: "Clear filter (Esc)", clearFilter: "Clear filter (Esc)",
sshConfig: "~/.ssh/config", sshConfig: "~/.ssh/config",
@@ -148,16 +149,17 @@ export const en = {
noFileTitle: "No file to edit", noFileTitle: "No file to edit",
noFileBody: "This window was opened without an editor context. Launch files from the app.", noFileBody: "This window was opened without an editor context. Launch files from the app.",
wrap: "Wrap", wrap: "Wrap",
wrapHint: "Wrap long lines — hides line numbers ({alt}Z)", wrapHint: "Wrap long lines — hides line numbers",
findHint: "Find ({mod}F)", findHint: "Find",
findPlaceholder: "Find in file", findPlaceholder: "Find in file",
prevMatch: "Previous match (⇧↵)", prevMatch: "Previous match",
nextMatch: "Next match (↵)", nextMatch: "Next match",
closeHint: "Close (Esc)", closeHint: "Close (Esc)",
disconnected: "Disconnected from {name} — saving will fail, but your text is preserved in this window.", disconnected: "Disconnected from {name} — saving will fail, but your text is preserved in this window.",
cantOpen: "Can't open {name}", cantOpen: "Can't open {name}",
closeWindow: "Close Window", closeWindow: "Close Window",
shortcuts: "{mod}S save · {mod}F find · {mod}W close", // footer hint: "⌘S save · ⌘F find · ⌘W close", keys from the keymap
verbs: { save: "save", find: "find", close: "close" },
lines: { one: "{count} line", other: "{count} lines" }, lines: { one: "{count} line", other: "{count} lines" },
close: "Close", close: "Close",
save: "Save", save: "Save",
@@ -213,6 +215,7 @@ export const en = {
}, },
toasts: { toasts: {
movedToTrash: 'Moved "{name}" to Trash', movedToTrash: 'Moved "{name}" to Trash',
movedManyToTrash: { one: "Moved {count} item to Trash", other: "Moved {count} items to Trash" },
editingExternal: "Editing {name} — saves upload automatically", editingExternal: "Editing {name} — saves upload automatically",
nothingToCopy: "Nothing to copy (symlinks are skipped)", nothingToCopy: "Nothing to copy (symlinks are skipped)",
nothingToCopyAllSkipped: "Nothing to copy — all conflicting items were skipped", nothingToCopyAllSkipped: "Nothing to copy — all conflicting items were skipped",
@@ -296,6 +299,133 @@ export const en = {
tabDisconnect: "Disconnect", tabDisconnect: "Disconnect",
trashTooltip: "Finder's Restore is not available for items trashed from here", trashTooltip: "Finder's Restore is not available for items trashed from here",
}, },
// Keyboard commands — one label per src/shared/keymap.ts BINDINGS id
keys: {
"cursor-up": "Move up",
"cursor-down": "Move down",
"page-up": "Page up",
"page-down": "Page down",
"cursor-first": "First item",
"cursor-last": "Last item",
"toggle-select": "Select / deselect",
"select-all": "Select all",
open: "Open",
parent: "Parent folder",
"switch-panel": "Switch panel",
"row-menu": "Show the item's menu",
send: "Send to the other panel",
edit: "Edit",
rename: "Rename",
"new-folder": "New folder",
"new-file": "New file",
chmod: "Permissions",
delete: "Delete",
trash: "Move to Trash",
"copy-path": "Copy path",
reveal: "Reveal in file manager",
"open-default": "Open in default app",
refresh: "Refresh",
"focus-path": "Go to path",
"source-picker": "Choose what the panel shows",
"sort-name": "Sort by name",
"sort-size": "Sort by size",
"sort-mtime": "Sort by modified",
"sort-kind": "Sort by kind",
"sort-rights": "Sort by rights",
"sort-owner": "Sort by owner",
"sort-group": "Sort by group",
"new-tab": "New tab",
"close-tab": "Close tab",
"close-other-tabs": "Close other tabs",
"close-tabs-right": "Close tabs to the right",
"next-tab": "Next tab",
"prev-tab": "Previous tab",
"go-to-tab": "Go to tab {n}",
"go-to-last-tab": "Go to last tab",
"move-tab-left": "Move tab left",
"move-tab-right": "Move tab right",
"disconnect-tab": "Disconnect tab",
"open-settings": "Settings",
"connection-manager": "Connection manager",
"toggle-transfers": "Show / hide transfers",
"install-update": "Install update",
"scale-up": "Bigger interface",
"scale-down": "Smaller interface",
"scale-reset": "Interface at 100%",
"close-window": "Close window",
save: "Save",
find: "Find",
"find-next": "Find next",
"find-prev": "Find previous",
"toggle-wrap": "Wrap lines",
"conn-new": "New connection",
"conn-new-stack": "New stack",
"conn-save": "Save connection",
"conn-connect": "Connect / open stack",
"conn-delete": "Delete connection or stack",
"conn-rename-stack": "Rename stack",
"conn-import": "Import from WinSCP",
groups: {
panels: "File list",
files: "Files",
sort: "Sorting",
tabs: "Tabs",
window: "Window",
editor: "Editor",
connections: "Connection manager",
},
// 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",
manager: "↑ ↓ ← → and {enter} move through the connection list",
sidebar: "↑ ↓ move between these sections",
},
// key names for "Ctrl+Shift+Del"-style hints (not used on macOS, which shows ⌘⇧⌫)
names: {
ctrl: "Ctrl",
alt: "Alt",
shift: "Shift",
enter: "Enter",
backspace: "Backspace",
del: "Del",
esc: "Esc",
space: "Space",
tab: "Tab",
pgup: "PgUp",
pgdn: "PgDn",
home: "Home",
end: "End",
ins: "Ins",
menu: "Menu",
num: "Num",
},
},
errors: { errors: {
sshAgentUnavailable: "ssh-agent not available (SSH_AUTH_SOCK is not set)", sshAgentUnavailable: "ssh-agent not available (SSH_AUTH_SOCK is not set)",
identityFileNotFound: "identity file not found: {path}", identityFileNotFound: "identity file not found: {path}",
+139 -14
View File
@@ -21,8 +21,8 @@ export const es = {
tabBar: { tabBar: {
connections: "Conexiones", connections: "Conexiones",
manageConnections: "Gestionar conexiones", manageConnections: "Gestionar conexiones",
newTab: "Nueva pestaña ({mod}T)", newTab: "Nueva pestaña",
closeTab: "Cerrar pestaña ({mod}W)", closeTab: "Cerrar pestaña",
options: "Ajustes", options: "Ajustes",
update: "Actualización disponible", update: "Actualización disponible",
updating: "Actualizando", updating: "Actualizando",
@@ -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",
@@ -75,17 +77,16 @@ export const es = {
panel: { panel: {
choosePanel: "Elige qué muestra este panel", choosePanel: "Elige qué muestra este panel",
copy: "Enviar", copy: "Enviar",
copyToRight: "Enviar al panel derecho (F5)", copyToRight: "Enviar al panel derecho",
copyToLeft: "Enviar al panel izquierdo (F5)", copyToLeft: "Enviar al panel izquierdo",
edit: "Editar", edit: "Editar",
editFile: "Editar archivo (F4)", editFile: "Editar archivo",
rename: "Renombrar (F2)", rename: "Renombrar",
newFolder: "Nueva carpeta (F7)", newFolder: "Nueva carpeta",
newFile: "Nuevo archivo", newFile: "Nuevo archivo",
permissions: "Permisos", permissions: "Permisos",
delete: "Eliminar", delete: "Eliminar",
deleteHint: "Eliminar (F8 / Supr)", parentDir: "Carpeta superior",
parentDir: "Carpeta superior (Retroceso)",
refresh: "Actualizar", refresh: "Actualizar",
clearFilter: "Borrar filtro (Esc)", clearFilter: "Borrar filtro (Esc)",
sshConfig: "~/.ssh/config", sshConfig: "~/.ssh/config",
@@ -144,16 +145,16 @@ export const es = {
noFileTitle: "Ningún archivo que editar", noFileTitle: "Ningún archivo que editar",
noFileBody: "Esta ventana se abrió sin contexto de editor. Abre archivos desde la aplicación.", noFileBody: "Esta ventana se abrió sin contexto de editor. Abre archivos desde la aplicación.",
wrap: "Ajustar", wrap: "Ajustar",
wrapHint: "Ajustar líneas largas — oculta los números de línea ({alt}Z)", wrapHint: "Ajustar líneas largas — oculta los números de línea",
findHint: "Buscar ({mod}F)", findHint: "Buscar",
findPlaceholder: "Buscar en el archivo", findPlaceholder: "Buscar en el archivo",
prevMatch: "Coincidencia anterior (⇧↵)", prevMatch: "Coincidencia anterior",
nextMatch: "Coincidencia siguiente (↵)", nextMatch: "Coincidencia siguiente",
closeHint: "Cerrar (Esc)", closeHint: "Cerrar (Esc)",
disconnected: "Desconectado de {name} — no se podrá guardar, pero tu texto se conserva en esta ventana.", disconnected: "Desconectado de {name} — no se podrá guardar, pero tu texto se conserva en esta ventana.",
cantOpen: "No se puede abrir {name}", cantOpen: "No se puede abrir {name}",
closeWindow: "Cerrar ventana", closeWindow: "Cerrar ventana",
shortcuts: "{mod}S guardar · {mod}F buscar · {mod}W cerrar", verbs: { save: "guardar", find: "buscar", close: "cerrar" },
lines: { one: "{count} línea", other: "{count} líneas" }, lines: { one: "{count} línea", other: "{count} líneas" },
close: "Cerrar", close: "Cerrar",
save: "Guardar", save: "Guardar",
@@ -209,6 +210,7 @@ export const es = {
}, },
toasts: { toasts: {
movedToTrash: 'Se movió "{name}" a la papelera', movedToTrash: 'Se movió "{name}" a la papelera',
movedManyToTrash: { one: "Se movió {count} elemento a la papelera", other: "Se movieron {count} elementos a la papelera" },
editingExternal: "Editando {name} — los cambios se suben automáticamente", editingExternal: "Editando {name} — los cambios se suben automáticamente",
nothingToCopy: "Nada que copiar (los enlaces simbólicos se omiten)", nothingToCopy: "Nada que copiar (los enlaces simbólicos se omiten)",
nothingToCopyAllSkipped: "Nada que copiar — se omitieron todos los elementos en conflicto", nothingToCopyAllSkipped: "Nada que copiar — se omitieron todos los elementos en conflicto",
@@ -292,6 +294,129 @@ export const es = {
tabDisconnect: "Desconectar", tabDisconnect: "Desconectar",
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: {
"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",
manager: "↑ ↓ ← → y {enter} recorren la lista de conexiones",
sidebar: "↑ ↓ cambian de sección aquí",
},
names: {
ctrl: "Ctrl",
alt: "Alt",
shift: "Mayús",
enter: "Intro",
backspace: "Retroceso",
del: "Supr",
esc: "Esc",
space: "Espacio",
tab: "Tab",
pgup: "RePág",
pgdn: "AvPág",
home: "Inicio",
end: "Fin",
ins: "Insert",
menu: "Menú",
num: "Num",
},
},
errors: { errors: {
sshAgentUnavailable: "ssh-agent no disponible (SSH_AUTH_SOCK no está definido)", sshAgentUnavailable: "ssh-agent no disponible (SSH_AUTH_SOCK no está definido)",
identityFileNotFound: "archivo de identidad no encontrado: {path}", identityFileNotFound: "archivo de identidad no encontrado: {path}",
+139 -14
View File
@@ -23,8 +23,8 @@ export const eu = {
tabBar: { tabBar: {
connections: "Konexioak", connections: "Konexioak",
manageConnections: "Kudeatu konexioak", manageConnections: "Kudeatu konexioak",
newTab: "Fitxa berria ({mod}T)", newTab: "Fitxa berria",
closeTab: "Itxi fitxa ({mod}W)", closeTab: "Itxi fitxa",
options: "Ezarpenak", options: "Ezarpenak",
update: "Eguneratzea eskuragarri", update: "Eguneratzea eskuragarri",
updating: "Eguneratzen", updating: "Eguneratzen",
@@ -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",
@@ -77,17 +79,16 @@ export const eu = {
panel: { panel: {
choosePanel: "Aukeratu panel honek zer erakusten duen", choosePanel: "Aukeratu panel honek zer erakusten duen",
copy: "Bidali", copy: "Bidali",
copyToRight: "Bidali eskuineko panelera (F5)", copyToRight: "Bidali eskuineko panelera",
copyToLeft: "Bidali ezkerreko panelera (F5)", copyToLeft: "Bidali ezkerreko panelera",
edit: "Editatu", edit: "Editatu",
editFile: "Editatu fitxategia (F4)", editFile: "Editatu fitxategia",
rename: "Berrizendatu (F2)", rename: "Berrizendatu",
newFolder: "Karpeta berria (F7)", newFolder: "Karpeta berria",
newFile: "Fitxategi berria", newFile: "Fitxategi berria",
permissions: "Baimenak", permissions: "Baimenak",
delete: "Ezabatu", delete: "Ezabatu",
deleteHint: "Ezabatu (F8 / Del)", parentDir: "Karpeta gurasoa",
parentDir: "Karpeta gurasoa (Atzera-tekla)",
refresh: "Freskatu", refresh: "Freskatu",
clearFilter: "Garbitu iragazkia (Esc)", clearFilter: "Garbitu iragazkia (Esc)",
sshConfig: "~/.ssh/config", sshConfig: "~/.ssh/config",
@@ -146,16 +147,16 @@ export const eu = {
noFileTitle: "Ez dago editatzeko fitxategirik", noFileTitle: "Ez dago editatzeko fitxategirik",
noFileBody: "Leiho hau editore-testuingururik gabe ireki da. Ireki fitxategiak aplikaziotik.", noFileBody: "Leiho hau editore-testuingururik gabe ireki da. Ireki fitxategiak aplikaziotik.",
wrap: "Doitu", wrap: "Doitu",
wrapHint: "Doitu lerro luzeak — lerro-zenbakiak ezkutatzen ditu ({alt}Z)", wrapHint: "Doitu lerro luzeak — lerro-zenbakiak ezkutatzen ditu",
findHint: "Bilatu ({mod}F)", findHint: "Bilatu",
findPlaceholder: "Bilatu fitxategian", findPlaceholder: "Bilatu fitxategian",
prevMatch: "Aurreko bat-etortzea (⇧↵)", prevMatch: "Aurreko bat-etortzea",
nextMatch: "Hurrengo bat-etortzea (↵)", nextMatch: "Hurrengo bat-etortzea",
closeHint: "Itxi (Esc)", closeHint: "Itxi (Esc)",
disconnected: "{name}(e)tik deskonektatuta — gordetzeak huts egingo du, baina zure testua leiho honetan gordeta dago.", disconnected: "{name}(e)tik deskonektatuta — gordetzeak huts egingo du, baina zure testua leiho honetan gordeta dago.",
cantOpen: "Ezin da {name} ireki", cantOpen: "Ezin da {name} ireki",
closeWindow: "Itxi leihoa", closeWindow: "Itxi leihoa",
shortcuts: "{mod}S gorde · {mod}F bilatu · {mod}W itxi", verbs: { save: "gorde", find: "bilatu", close: "itxi" },
lines: { one: "lerro {count}", other: "{count} lerro" }, lines: { one: "lerro {count}", other: "{count} lerro" },
close: "Itxi", close: "Itxi",
save: "Gorde", save: "Gorde",
@@ -211,6 +212,7 @@ export const eu = {
}, },
toasts: { toasts: {
movedToTrash: '"{name}" zakarrontzira eraman da', movedToTrash: '"{name}" zakarrontzira eraman da',
movedManyToTrash: { one: "{count} elementu zakarrontzira eraman da", other: "{count} elementu zakarrontzira eraman dira" },
editingExternal: "{name} editatzen — gordetzean automatikoki igotzen da", editingExternal: "{name} editatzen — gordetzean automatikoki igotzen da",
nothingToCopy: "Ez dago kopiatzeko ezer (esteka sinbolikoak saltatu egiten dira)", nothingToCopy: "Ez dago kopiatzeko ezer (esteka sinbolikoak saltatu egiten dira)",
nothingToCopyAllSkipped: "Ez dago kopiatzeko ezer — gatazkan zeuden elementu guztiak saltatu dira", nothingToCopyAllSkipped: "Ez dago kopiatzeko ezer — gatazkan zeuden elementu guztiak saltatu dira",
@@ -294,6 +296,129 @@ export const eu = {
tabDisconnect: "Deskonektatu", tabDisconnect: "Deskonektatu",
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: {
"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",
manager: "↑ ↓ ← → eta {enter} konexio-zerrendan mugitzen dira",
sidebar: "↑ ↓ atal batetik bestera mugitzen dira hemen",
},
names: {
ctrl: "Ctrl",
alt: "Alt",
shift: "Maius",
enter: "Intro",
backspace: "Atzera",
del: "Supr",
esc: "Esc",
space: "Zuriunea",
tab: "Tab",
pgup: "RePág",
pgdn: "AvPág",
home: "Inicio",
end: "Fin",
ins: "Insert",
menu: "Menua",
num: "Num",
},
},
errors: { errors: {
sshAgentUnavailable: "ssh-agent ez dago erabilgarri (SSH_AUTH_SOCK ez dago ezarrita)", sshAgentUnavailable: "ssh-agent ez dago erabilgarri (SSH_AUTH_SOCK ez dago ezarrita)",
identityFileNotFound: "identitate-fitxategia ez da aurkitu: {path}", identityFileNotFound: "identitate-fitxategia ez da aurkitu: {path}",
+139 -14
View File
@@ -21,8 +21,8 @@ export const fr = {
tabBar: { tabBar: {
connections: "Connexions", connections: "Connexions",
manageConnections: "Gérer les connexions", manageConnections: "Gérer les connexions",
newTab: "Nouvel onglet ({mod}T)", newTab: "Nouvel onglet",
closeTab: "Fermer l'onglet ({mod}W)", closeTab: "Fermer l'onglet",
options: "Réglages", options: "Réglages",
update: "Mise à jour disponible", update: "Mise à jour disponible",
updating: "Mise à jour", updating: "Mise à jour",
@@ -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",
@@ -75,17 +77,16 @@ export const fr = {
panel: { panel: {
choosePanel: "Choisir ce qu'affiche ce panneau", choosePanel: "Choisir ce qu'affiche ce panneau",
copy: "Envoyer", copy: "Envoyer",
copyToRight: "Envoyer vers le panneau droit (F5)", copyToRight: "Envoyer vers le panneau droit",
copyToLeft: "Envoyer vers le panneau gauche (F5)", copyToLeft: "Envoyer vers le panneau gauche",
edit: "Éditer", edit: "Éditer",
editFile: "Éditer le fichier (F4)", editFile: "Éditer le fichier",
rename: "Renommer (F2)", rename: "Renommer",
newFolder: "Nouveau dossier (F7)", newFolder: "Nouveau dossier",
newFile: "Nouveau fichier", newFile: "Nouveau fichier",
permissions: "Autorisations", permissions: "Autorisations",
delete: "Supprimer", delete: "Supprimer",
deleteHint: "Supprimer (F8 / Suppr)", parentDir: "Dossier parent",
parentDir: "Dossier parent (Retour arrière)",
refresh: "Actualiser", refresh: "Actualiser",
clearFilter: "Effacer le filtre (Échap)", clearFilter: "Effacer le filtre (Échap)",
sshConfig: "~/.ssh/config", sshConfig: "~/.ssh/config",
@@ -144,16 +145,16 @@ export const fr = {
noFileTitle: "Aucun fichier à éditer", noFileTitle: "Aucun fichier à éditer",
noFileBody: "Cette fenêtre a été ouverte sans contexte d'éditeur. Ouvrez des fichiers depuis l'application.", noFileBody: "Cette fenêtre a été ouverte sans contexte d'éditeur. Ouvrez des fichiers depuis l'application.",
wrap: "Retour", wrap: "Retour",
wrapHint: "Retour à la ligne — masque les numéros de ligne ({alt}Z)", wrapHint: "Retour à la ligne — masque les numéros de ligne",
findHint: "Rechercher ({mod}F)", findHint: "Rechercher",
findPlaceholder: "Rechercher dans le fichier", findPlaceholder: "Rechercher dans le fichier",
prevMatch: "Résultat précédent (⇧↵)", prevMatch: "Résultat précédent",
nextMatch: "Résultat suivant (↵)", nextMatch: "Résultat suivant",
closeHint: "Fermer (Échap)", closeHint: "Fermer (Échap)",
disconnected: "Déconnecté de {name} — l'enregistrement échouera, mais votre texte est conservé dans cette fenêtre.", disconnected: "Déconnecté de {name} — l'enregistrement échouera, mais votre texte est conservé dans cette fenêtre.",
cantOpen: "Impossible d'ouvrir {name}", cantOpen: "Impossible d'ouvrir {name}",
closeWindow: "Fermer la fenêtre", closeWindow: "Fermer la fenêtre",
shortcuts: "{mod}S enregistrer · {mod}F rechercher · {mod}W fermer", verbs: { save: "enregistrer", find: "rechercher", close: "fermer" },
lines: { one: "{count} ligne", other: "{count} lignes" }, lines: { one: "{count} ligne", other: "{count} lignes" },
close: "Fermer", close: "Fermer",
save: "Enregistrer", save: "Enregistrer",
@@ -209,6 +210,7 @@ export const fr = {
}, },
toasts: { toasts: {
movedToTrash: '« {name} » mis à la corbeille', movedToTrash: '« {name} » mis à la corbeille',
movedManyToTrash: { one: "{count} élément mis à la corbeille", other: "{count} éléments mis à la corbeille" },
editingExternal: "Édition de {name} — les enregistrements sont envoyés automatiquement", editingExternal: "Édition de {name} — les enregistrements sont envoyés automatiquement",
nothingToCopy: "Rien à copier (les liens symboliques sont ignorés)", nothingToCopy: "Rien à copier (les liens symboliques sont ignorés)",
nothingToCopyAllSkipped: "Rien à copier — tous les éléments en conflit ont été ignorés", nothingToCopyAllSkipped: "Rien à copier — tous les éléments en conflit ont été ignorés",
@@ -292,6 +294,129 @@ export const fr = {
tabDisconnect: "Déconnecter", tabDisconnect: "Déconnecter",
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: {
"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",
manager: "↑ ↓ ← → et {enter} parcourent la liste des connexions",
sidebar: "↑ ↓ changent de section ici",
},
names: {
ctrl: "Ctrl",
alt: "Alt",
shift: "Maj",
enter: "Entrée",
backspace: "Retour arrière",
del: "Suppr",
esc: "Échap",
space: "Espace",
tab: "Tab",
pgup: "Pg préc",
pgdn: "Pg suiv",
home: "Début",
end: "Fin",
ins: "Inser",
menu: "Menu",
num: "Num",
},
},
errors: { errors: {
sshAgentUnavailable: "ssh-agent indisponible (SSH_AUTH_SOCK non défini)", sshAgentUnavailable: "ssh-agent indisponible (SSH_AUTH_SOCK non défini)",
identityFileNotFound: "fichier d'identité introuvable : {path}", identityFileNotFound: "fichier d'identité introuvable : {path}",
+139 -14
View File
@@ -22,8 +22,8 @@ export const ja = {
tabBar: { tabBar: {
connections: "接続", connections: "接続",
manageConnections: "接続を管理", manageConnections: "接続を管理",
newTab: "新しいタブ ({mod}T)", newTab: "新しいタブ",
closeTab: "タブを閉じる ({mod}W)", closeTab: "タブを閉じる",
options: "設定", options: "設定",
update: "アップデートあり", update: "アップデートあり",
updating: "更新中", updating: "更新中",
@@ -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: "フォルダ",
@@ -76,17 +78,16 @@ export const ja = {
panel: { panel: {
choosePanel: "このパネルに表示する内容を選択", choosePanel: "このパネルに表示する内容を選択",
copy: "送信", copy: "送信",
copyToRight: "右のパネルへ送信 (F5)", copyToRight: "右のパネルへ送信",
copyToLeft: "左のパネルへ送信 (F5)", copyToLeft: "左のパネルへ送信",
edit: "編集", edit: "編集",
editFile: "ファイルを編集 (F4)", editFile: "ファイルを編集",
rename: "名前を変更 (F2)", rename: "名前を変更",
newFolder: "新規フォルダ (F7)", newFolder: "新規フォルダ",
newFile: "新規ファイル", newFile: "新規ファイル",
permissions: "アクセス権", permissions: "アクセス権",
delete: "削除", delete: "削除",
deleteHint: "削除 (F8 / Del)", parentDir: "親フォルダ",
parentDir: "親フォルダ (Backspace)",
refresh: "更新", refresh: "更新",
clearFilter: "フィルターをクリア (Esc)", clearFilter: "フィルターをクリア (Esc)",
sshConfig: "~/.ssh/config", sshConfig: "~/.ssh/config",
@@ -145,16 +146,16 @@ export const ja = {
noFileTitle: "編集するファイルがありません", noFileTitle: "編集するファイルがありません",
noFileBody: "このウィンドウはエディタコンテキストなしで開かれました。アプリからファイルを開いてください。", noFileBody: "このウィンドウはエディタコンテキストなしで開かれました。アプリからファイルを開いてください。",
wrap: "折り返し", wrap: "折り返し",
wrapHint: "長い行を折り返す — 行番号を非表示 ({alt}Z)", wrapHint: "長い行を折り返す — 行番号を非表示",
findHint: "検索 ({mod}F)", findHint: "検索",
findPlaceholder: "ファイル内を検索", findPlaceholder: "ファイル内を検索",
prevMatch: "前の一致 (⇧↵)", prevMatch: "前の一致",
nextMatch: "次の一致 (↵)", nextMatch: "次の一致",
closeHint: "閉じる (Esc)", closeHint: "閉じる (Esc)",
disconnected: "{name} から切断されました — 保存は失敗しますが、テキストはこのウィンドウに保持されます。", disconnected: "{name} から切断されました — 保存は失敗しますが、テキストはこのウィンドウに保持されます。",
cantOpen: "{name} を開けません", cantOpen: "{name} を開けません",
closeWindow: "ウィンドウを閉じる", closeWindow: "ウィンドウを閉じる",
shortcuts: "{mod}S 保存 · {mod}F 検索 · {mod}W 閉じる", verbs: { save: "保存", find: "検索", close: "閉じる" },
lines: { other: "{count} 行" }, lines: { other: "{count} 行" },
close: "閉じる", close: "閉じる",
save: "保存", save: "保存",
@@ -210,6 +211,7 @@ export const ja = {
}, },
toasts: { toasts: {
movedToTrash: '"{name}" をゴミ箱に移動しました', movedToTrash: '"{name}" をゴミ箱に移動しました',
movedManyToTrash: { other: "{count} 個の項目をゴミ箱に移動しました" },
editingExternal: "{name} を編集中 — 保存すると自動でアップロードされます", editingExternal: "{name} を編集中 — 保存すると自動でアップロードされます",
nothingToCopy: "コピーする項目がありません(シンボリックリンクはスキップされます)", nothingToCopy: "コピーする項目がありません(シンボリックリンクはスキップされます)",
nothingToCopyAllSkipped: "コピーする項目がありません — 競合する項目はすべてスキップされました", nothingToCopyAllSkipped: "コピーする項目がありません — 競合する項目はすべてスキップされました",
@@ -292,6 +294,129 @@ export const ja = {
tabDisconnect: "切断", tabDisconnect: "切断",
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません", 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} で検索結果を移動します",
manager: "↑ ↓ ← → と {enter} で接続一覧を移動します",
sidebar: "↑ ↓ でここのセクションを切り替えます",
},
names: {
ctrl: "Ctrl",
alt: "Alt",
shift: "Shift",
enter: "Enter",
backspace: "Backspace",
del: "Del",
esc: "Esc",
space: "スペース",
tab: "Tab",
pgup: "PgUp",
pgdn: "PgDn",
home: "Home",
end: "End",
ins: "Ins",
menu: "メニュー",
num: "Num",
},
},
errors: { errors: {
sshAgentUnavailable: "ssh-agent が利用できません(SSH_AUTH_SOCK が未設定)", sshAgentUnavailable: "ssh-agent が利用できません(SSH_AUTH_SOCK が未設定)",
identityFileNotFound: "認証鍵ファイルが見つかりません: {path}", identityFileNotFound: "認証鍵ファイルが見つかりません: {path}",
+139 -14
View File
@@ -22,8 +22,8 @@ export const zh = {
tabBar: { tabBar: {
connections: "连接", connections: "连接",
manageConnections: "管理连接", manageConnections: "管理连接",
newTab: "新建标签页 ({mod}T)", newTab: "新建标签页",
closeTab: "关闭标签页 ({mod}W)", closeTab: "关闭标签页",
options: "设置", options: "设置",
update: "有可用更新", update: "有可用更新",
updating: "正在更新", updating: "正在更新",
@@ -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: "文件夹",
@@ -76,17 +78,16 @@ export const zh = {
panel: { panel: {
choosePanel: "选择此面板显示的内容", choosePanel: "选择此面板显示的内容",
copy: "发送", copy: "发送",
copyToRight: "发送到右侧面板 (F5)", copyToRight: "发送到右侧面板",
copyToLeft: "发送到左侧面板 (F5)", copyToLeft: "发送到左侧面板",
edit: "编辑", edit: "编辑",
editFile: "编辑文件 (F4)", editFile: "编辑文件",
rename: "重命名 (F2)", rename: "重命名",
newFolder: "新建文件夹 (F7)", newFolder: "新建文件夹",
newFile: "新建文件", newFile: "新建文件",
permissions: "权限", permissions: "权限",
delete: "删除", delete: "删除",
deleteHint: "删除 (F8 / Del)", parentDir: "上级目录",
parentDir: "上级目录 (退格)",
refresh: "刷新", refresh: "刷新",
clearFilter: "清除筛选 (Esc)", clearFilter: "清除筛选 (Esc)",
sshConfig: "~/.ssh/config", sshConfig: "~/.ssh/config",
@@ -145,16 +146,16 @@ export const zh = {
noFileTitle: "没有要编辑的文件", noFileTitle: "没有要编辑的文件",
noFileBody: "此窗口在没有编辑器上下文的情况下打开。请从应用中打开文件。", noFileBody: "此窗口在没有编辑器上下文的情况下打开。请从应用中打开文件。",
wrap: "换行", wrap: "换行",
wrapHint: "长行自动换行 — 隐藏行号 ({alt}Z)", wrapHint: "长行自动换行 — 隐藏行号",
findHint: "查找 ({mod}F)", findHint: "查找",
findPlaceholder: "在文件中查找", findPlaceholder: "在文件中查找",
prevMatch: "上一个匹配 (⇧↵)", prevMatch: "上一个匹配",
nextMatch: "下一个匹配 (↵)", nextMatch: "下一个匹配",
closeHint: "关闭 (Esc)", closeHint: "关闭 (Esc)",
disconnected: "已从 {name} 断开 — 保存将失败,但你的文本会保留在此窗口中。", disconnected: "已从 {name} 断开 — 保存将失败,但你的文本会保留在此窗口中。",
cantOpen: "无法打开 {name}", cantOpen: "无法打开 {name}",
closeWindow: "关闭窗口", closeWindow: "关闭窗口",
shortcuts: "{mod}S 保存 · {mod}F 查找 · {mod}W 关闭", verbs: { save: "保存", find: "查找", close: "关闭" },
lines: { other: "{count} 行" }, lines: { other: "{count} 行" },
close: "关闭", close: "关闭",
save: "保存", save: "保存",
@@ -210,6 +211,7 @@ export const zh = {
}, },
toasts: { toasts: {
movedToTrash: '已将 "{name}" 移到废纸篓', movedToTrash: '已将 "{name}" 移到废纸篓',
movedManyToTrash: { other: "已将 {count} 个项目移到废纸篓" },
editingExternal: "正在编辑 {name} — 保存后会自动上传", editingExternal: "正在编辑 {name} — 保存后会自动上传",
nothingToCopy: "没有可复制的内容(符号链接将被跳过)", nothingToCopy: "没有可复制的内容(符号链接将被跳过)",
nothingToCopyAllSkipped: "没有可复制的内容 — 所有冲突项都被跳过", nothingToCopyAllSkipped: "没有可复制的内容 — 所有冲突项都被跳过",
@@ -292,6 +294,129 @@ export const zh = {
tabDisconnect: "断开连接", tabDisconnect: "断开连接",
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能", 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} 在查找结果间跳转",
manager: "↑ ↓ ← → 和 {enter} 在连接列表中移动",
sidebar: "↑ ↓ 在这些分区间切换",
},
names: {
ctrl: "Ctrl",
alt: "Alt",
shift: "Shift",
enter: "Enter",
backspace: "退格",
del: "Del",
esc: "Esc",
space: "空格",
tab: "Tab",
pgup: "PgUp",
pgdn: "PgDn",
home: "Home",
end: "End",
ins: "Ins",
menu: "菜单",
num: "Num",
},
},
errors: { errors: {
sshAgentUnavailable: "ssh-agent 不可用(未设置 SSH_AUTH_SOCK)", sshAgentUnavailable: "ssh-agent 不可用(未设置 SSH_AUTH_SOCK)",
identityFileNotFound: "找不到身份文件:{path}", identityFileNotFound: "找不到身份文件:{path}",
+221
View File
@@ -0,0 +1,221 @@
import { expect, test } from "bun:test";
import { DICTS } from "./i18n";
import { en } from "./i18n/en";
import {
BINDINGS,
type Combo,
comboFromEvent,
conflicts,
EN_KEY_NAMES,
effectiveKeymap,
firesInTextField,
formatCombo,
GROUPS,
isReserved,
type KeyEventLike,
matchesEvent,
NATIVE_MENU_IDS,
parseCombo,
serializeCombo,
scopesOverlap,
toAccelerator,
} from "./keymap";
const US_CHARS: Record<string, string> = {
Comma: ",",
Period: ".",
Slash: "/",
Semicolon: ";",
Quote: "'",
BracketLeft: "[",
BracketRight: "]",
Backslash: "\\",
Backquote: "`",
Equal: "=",
Minus: "-",
NumpadAdd: "+",
};
/** The keydown a US keyboard produces for `c`. */
function eventFor(c: Combo, isMac: boolean): KeyEventLike {
let key = c.key;
let code = c.key;
if (/^[A-Z]$/.test(c.key)) {
key = c.shift ? c.key : c.key.toLowerCase();
code = `Key${c.key}`;
} else if (/^[0-9]$/.test(c.key)) {
code = `Digit${c.key}`;
} else if (c.key in US_CHARS) {
key = US_CHARS[c.key];
} else if (c.key === "Space") {
key = " ";
}
return {
key,
code,
metaKey: isMac && c.mod,
ctrlKey: isMac ? c.ctrl : c.mod,
altKey: c.alt,
shiftKey: c.shift,
};
}
const ev = (over: Partial<KeyEventLike>): KeyEventLike => ({
key: "",
code: "",
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: false,
...over,
});
const combo = (text: string, isMac: boolean) => {
const c = parseCombo(text, isMac);
if (!c) throw new Error(`unparsable ${text}`);
return c;
};
for (const isMac of [true, false]) {
const os = isMac ? "macOS" : "Windows/Linux";
const keymap = effectiveKeymap({}, isMac);
test(`${os}: the defaults have no conflicts`, () => {
expect([...conflicts(keymap).entries()]).toEqual([]);
});
test(`${os}: every default round-trips and resolves to its own command`, () => {
for (const binding of keymap) {
for (const c of binding.combos) {
expect(parseCombo(serializeCombo(c), isMac)).toEqual(c);
const event = eventFor(c, isMac);
// table order is resolution order: nothing earlier may shadow it
const hit = keymap.find(
(other) => scopesOverlap(other.scopes, binding.scopes) && other.combos.some((oc) => matchesEvent(oc, event, isMac)),
);
expect(`${serializeCombo(c)} → ${hit?.id}`).toBe(`${serializeCombo(c)} → ${binding.id}`);
expect(comboFromEvent(event, isMac)).toEqual(c);
}
}
});
test(`${os}: no default is a reserved combo`, () => {
for (const binding of keymap) for (const c of binding.combos) expect(isReserved(c, isMac)).toBe(false);
});
test(`${os}: every macOS menu command has an expressible accelerator`, () => {
if (!isMac) return;
for (const id of NATIVE_MENU_IDS) {
const binding = keymap.find((b) => b.id === id)!;
expect(binding.combos.some((c) => toAccelerator(c) !== null)).toBe(true);
}
});
}
test("every command and group 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", () => {
// ⌃F is Cocoa's forward-one-character, not ⌘F
expect(matchesEvent(combo("Mod+F", true), ev({ key: "f", code: "KeyF", ctrlKey: true }), true)).toBe(false);
expect(matchesEvent(combo("Mod+F", true), ev({ key: "f", code: "KeyF", metaKey: true }), true)).toBe(true);
// Ctrl+Tab is not Tab (the old panel switch caught both)
expect(matchesEvent(combo("Tab", false), ev({ key: "Tab", code: "Tab", ctrlKey: true }), false)).toBe(false);
// the Windows key is never part of a binding
expect(matchesEvent(combo("Mod+T", false), ev({ key: "t", code: "KeyT", ctrlKey: true, metaKey: true }), false)).toBe(false);
});
test("letters follow the layout, Alt chords and other scripts use the physical key", () => {
// AZERTY: the comma sits on the M key, the A on the Q key
expect(comboFromEvent(ev({ key: ",", code: "KeyM", metaKey: true }), true)).toEqual(combo("Mod+Comma", true));
expect(comboFromEvent(ev({ key: "a", code: "KeyQ", ctrlKey: true }), false)).toEqual(combo("Mod+A", false));
// macOS Option turns ⌥Z into Ω
expect(comboFromEvent(ev({ key: "Ω", code: "KeyZ", altKey: true }), true)).toEqual(combo("Alt+Z", true));
// Cyrillic ⌘T arrives as "е" on the T key
expect(matchesEvent(combo("Mod+T", true), ev({ key: "е", code: "KeyT", metaKey: true }), true)).toBe(true);
// US ⌘+ is ⇧⌘= — matched by the physical key
expect(matchesEvent(combo("Mod+Shift+Equal", true), ev({ key: "+", code: "Equal", metaKey: true, shiftKey: true }), true)).toBe(true);
// AZERTY ⌘1 types "&" on the 1 key
expect(comboFromEvent(ev({ key: "&", code: "Digit1", metaKey: true }), true)).toEqual(combo("Mod+1", true));
// a bare modifier press is not a combo
expect(comboFromEvent(ev({ key: "Shift", code: "ShiftLeft", shiftKey: true }), true)).toBeNull();
});
test("formatting per platform, with localized key names off macOS", () => {
expect(formatCombo(combo("Mod+Shift+N", true), true)).toBe("⇧⌘N");
expect(formatCombo(combo("Ctrl+Tab", true), true)).toBe("⌃⇥");
expect(formatCombo(combo("Mod+Alt+C", true), true)).toBe("⌥⌘C");
expect(formatCombo(combo("Mod+Comma", true), true)).toBe("⌘,");
expect(formatCombo(combo("Shift+F10", true), true)).toBe("⇧F10");
expect(formatCombo(combo("Mod+Backspace", true), true)).toBe("⌘⌫");
expect(formatCombo(combo("Mod+Shift+N", false), false)).toBe("Ctrl+Shift+N");
expect(formatCombo(combo("Alt+Shift+R", false), false)).toBe("Alt+Shift+R");
expect(formatCombo(combo("Delete", false), false)).toBe("Del");
const de = { ...EN_KEY_NAMES, ctrl: "Strg", del: "Entf" };
expect(formatCombo(combo("Mod+Delete", false), false, de)).toBe("Strg+Entf");
});
test("overrides replace a row; garbage is dropped", () => {
const keymap = effectiveKeymap(
{ rename: ["Mod+Shift+R", "nonsense", 5, "Mod+Shift+R"], save: "F9", bogus: ["F1"], delete: [] },
true,
);
const by = (id: string) => keymap.find((b) => b.id === id)!;
expect(by("rename").combos.map(serializeCombo)).toEqual(["Mod+Shift+R"]);
expect(by("rename").custom).toBe(true);
// not an array: ignored, the defaults stand
expect(by("save").custom).toBe(false);
expect(by("save").combos.map(serializeCombo)).toEqual(["Mod+S"]);
// [] unbinds
expect(by("delete").combos).toEqual([]);
expect(keymap.some((b) => (b.id as string) === "bogus")).toBe(false);
// not an object at all
expect(effectiveKeymap("junk", true).every((b) => !b.custom)).toBe(true);
});
test("off macOS a stored literal Ctrl folds into Mod", () => {
expect(parseCombo("Ctrl+Tab", false)).toEqual(parseCombo("Mod+Tab", false));
expect(parseCombo("Ctrl+Tab", true)).not.toEqual(parseCombo("Mod+Tab", true));
});
test("native menu accelerators", () => {
expect(toAccelerator(combo("Mod+Shift+N", true))).toBe("Command+Shift+n");
expect(toAccelerator(combo("Mod+Comma", true))).toBe("Command+,");
expect(toAccelerator(combo("Mod+Minus", true))).toBe("Command+minus");
expect(toAccelerator(combo("Ctrl+Shift+PageUp", true))).toBe("Control+Shift+pageup");
// a bare key would mean ⌘+key to the native parser
expect(toAccelerator(combo("F5", true))).toBeNull();
// ⌫ / ⌦, Shift+punctuation and modifier-less combos have no faithful form
expect(toAccelerator(combo("Mod+Delete", true))).toBeNull();
expect(toAccelerator(combo("Mod+Backspace", true))).toBeNull();
expect(toAccelerator(combo("Mod+Shift+BracketRight", true))).toBeNull();
expect(toAccelerator(combo("Shift+F4", true))).toBeNull();
});
test("text fields keep their own keys", () => {
// plain keys and text-editing chords type / edit
expect(firesInTextField(combo("Delete", true), true)).toBe(false);
expect(firesInTextField(combo("Mod+Backspace", true), true)).toBe(false);
expect(firesInTextField(combo("Mod+A", true), true)).toBe(false);
expect(firesInTextField(combo("Alt+ArrowLeft", true), true)).toBe(false);
// app chords and F-keys still fire (⌘S in the editor, ⌘↩ in the manager form)
expect(firesInTextField(combo("Mod+S", true), true)).toBe(true);
expect(firesInTextField(combo("Mod+Enter", false), false)).toBe(true);
expect(firesInTextField(combo("F3", false), false)).toBe(true);
// Ctrl+Alt off macOS is AltGr typing a character
expect(firesInTextField(combo("Mod+Alt+Q", false), false)).toBe(false);
});
test("AltGr is never a shortcut off macOS", () => {
const altGrQ = ev({ key: "@", code: "KeyQ", ctrlKey: true, altKey: true, getModifierState: (k) => k === "AltGraph" });
expect(matchesEvent(combo("Mod+Alt+Q", false), altGrQ, false)).toBe(false);
expect(comboFromEvent(altGrQ, false)).toBeNull();
});
+553
View File
@@ -0,0 +1,553 @@
// The app's one keymap: every keyboard command, where it fires and its default
// keys per platform, plus the pure helpers that parse, match, format and check
// key combos. Shared on purpose: the webviews resolve keys, show hints and edit
// remaps against this table, and bun builds the macOS menu-bar accelerators
// from it, so the two can never disagree. User remaps are stored as overrides
// in AppSettings.keybindings and applied by effectiveKeymap().
/** Where a binding fires. `panel` = the focused file list, `main` = anywhere
* in the main window, `any` = every window. `panel` sits inside `main`, so
* the two overlap for conflict purposes. */
export type Scope = "panel" | "main" | "editor" | "manager" | "settings" | "any";
/** Settings → Keyboard sections, in display order. */
export const GROUPS = ["panels", "files", "sort", "tabs", "window", "editor", "connections"] as const;
export type Group = (typeof GROUPS)[number];
interface BindingDef {
readonly id: string;
readonly group: Group;
readonly scopes: readonly Scope[];
/** Default combos, best first: the first is the one menus and tooltips show. */
readonly mac: readonly string[];
readonly other: readonly string[];
/** go-to-tab-N: the N its label interpolates. */
readonly n?: number;
}
const both = (...combos: string[]) => ({ mac: combos, other: combos });
/**
* Every command, in Settings display order within its group. Combo strings are
* platform-neutral (see parseCombo): `Mod` is ⌘ on macOS and Ctrl elsewhere,
* `Ctrl` a literal ⌃ that only macOS tells apart.
*/
export const BINDINGS = [
// ---- panels: the focused file list ----
{ id: "cursor-up", group: "panels", scopes: ["panel"], ...both("ArrowUp") },
{ id: "cursor-down", group: "panels", scopes: ["panel"], ...both("ArrowDown") },
{ id: "page-up", group: "panels", scopes: ["panel"], ...both("PageUp") },
{ id: "page-down", group: "panels", scopes: ["panel"], ...both("PageDown") },
{ id: "cursor-first", group: "panels", scopes: ["panel"], ...both("Home") },
{ id: "cursor-last", group: "panels", scopes: ["panel"], ...both("End") },
{ id: "toggle-select", group: "panels", scopes: ["panel"], mac: ["Space"], other: ["Space", "Insert"] },
{ id: "select-all", group: "panels", scopes: ["panel"], ...both("Mod+A") },
{ id: "open", group: "panels", scopes: ["panel"], mac: ["Enter", "Mod+ArrowDown"], other: ["Enter"] },
{ id: "parent", group: "panels", scopes: ["panel"], mac: ["Backspace", "Mod+ArrowUp"], other: ["Backspace", "Alt+ArrowUp"] },
{ id: "switch-panel", group: "panels", scopes: ["panel"], ...both("Tab", "Shift+Tab") },
{ id: "row-menu", group: "panels", scopes: ["panel"], mac: ["Shift+F10", "Ctrl+Enter"], other: ["Shift+F10", "ContextMenu"] },
// ---- files: act on the focused panel's selection, else its cursor row ----
{ id: "send", group: "files", scopes: ["panel"], ...both("F5") },
{ id: "edit", group: "files", scopes: ["panel"], mac: ["Mod+E", "F4"], other: ["F4", "Mod+E"] },
{ id: "rename", group: "files", scopes: ["panel"], ...both("F2") },
{ id: "new-folder", group: "files", scopes: ["panel"], mac: ["Mod+Shift+N", "F7"], other: ["F7", "Mod+Shift+N"] },
{ id: "new-file", group: "files", scopes: ["panel"], mac: ["Mod+N", "Shift+F4"], other: ["Shift+F4", "Mod+N"] },
{ id: "chmod", group: "files", scopes: ["panel"], ...both("Mod+I") },
{ id: "delete", group: "files", scopes: ["panel"], ...both("F8", "Delete") },
{ id: "trash", group: "files", scopes: ["panel"], mac: ["Mod+Backspace"], other: ["Mod+Delete"] },
{ id: "copy-path", group: "files", scopes: ["panel"], mac: ["Mod+Alt+C"], other: ["Mod+Shift+C"] },
{ id: "reveal", group: "files", scopes: ["panel"], mac: ["Mod+Alt+R"], other: ["Alt+Shift+R"] },
{ id: "open-default", group: "files", scopes: ["panel"], ...both("Mod+O") },
{ id: "refresh", group: "files", scopes: ["panel"], ...both("Mod+R") },
{ id: "focus-path", group: "files", scopes: ["panel"], ...both("Mod+L") },
{ id: "source-picker", group: "files", scopes: ["panel"], ...both("Mod+K") },
// ---- sort the focused panel (again = reverse) ----
{ id: "sort-name", group: "sort", scopes: ["panel"], ...both("Alt+1") },
{ id: "sort-size", group: "sort", scopes: ["panel"], ...both("Alt+2") },
{ id: "sort-mtime", group: "sort", scopes: ["panel"], ...both("Alt+3") },
{ id: "sort-kind", group: "sort", scopes: ["panel"], ...both("Alt+4") },
{ id: "sort-rights", group: "sort", scopes: ["panel"], ...both("Alt+5") },
{ id: "sort-owner", group: "sort", scopes: ["panel"], ...both("Alt+6") },
{ id: "sort-group", group: "sort", scopes: ["panel"], ...both("Alt+7") },
// ---- tabs (Ctrl+Tab is a literal ⌃ on macOS: ⌘Tab is the app switcher) ----
{ id: "new-tab", group: "tabs", scopes: ["main"], ...both("Mod+T") },
{ id: "close-tab", group: "tabs", scopes: ["main"], ...both("Mod+W") },
// Ctrl+Alt+letter is AltGr typing on many non-mac layouts — never a default there
{ id: "close-other-tabs", group: "tabs", scopes: ["main"], mac: ["Mod+Alt+W"], other: ["Mod+Shift+W"] },
{ id: "close-tabs-right", group: "tabs", scopes: ["main"], ...both() },
{ id: "next-tab", group: "tabs", scopes: ["main"], mac: ["Ctrl+Tab", "Mod+Shift+BracketRight"], other: ["Ctrl+Tab", "Mod+PageDown"] },
{ id: "prev-tab", group: "tabs", scopes: ["main"], mac: ["Ctrl+Shift+Tab", "Mod+Shift+BracketLeft"], other: ["Ctrl+Shift+Tab", "Mod+PageUp"] },
{ id: "go-to-tab-1", group: "tabs", scopes: ["main"], n: 1, ...both("Mod+1") },
{ id: "go-to-tab-2", group: "tabs", scopes: ["main"], n: 2, ...both("Mod+2") },
{ id: "go-to-tab-3", group: "tabs", scopes: ["main"], n: 3, ...both("Mod+3") },
{ id: "go-to-tab-4", group: "tabs", scopes: ["main"], n: 4, ...both("Mod+4") },
{ id: "go-to-tab-5", group: "tabs", scopes: ["main"], n: 5, ...both("Mod+5") },
{ id: "go-to-tab-6", group: "tabs", scopes: ["main"], n: 6, ...both("Mod+6") },
{ id: "go-to-tab-7", group: "tabs", scopes: ["main"], n: 7, ...both("Mod+7") },
{ id: "go-to-tab-8", group: "tabs", scopes: ["main"], n: 8, ...both("Mod+8") },
{ id: "go-to-last-tab", group: "tabs", scopes: ["main"], ...both("Mod+9") },
{ id: "move-tab-left", group: "tabs", scopes: ["main"], mac: ["Ctrl+Shift+PageUp"], other: ["Mod+Shift+PageUp"] },
{ id: "move-tab-right", group: "tabs", scopes: ["main"], mac: ["Ctrl+Shift+PageDown"], other: ["Mod+Shift+PageDown"] },
{ id: "disconnect-tab", group: "tabs", scopes: ["main"], ...both("Mod+D") },
// ---- window ----
{ id: "open-settings", group: "window", scopes: ["main"], ...both("Mod+Comma") },
{ id: "connection-manager", group: "window", scopes: ["main"], ...both("Mod+Shift+K") },
{ id: "toggle-transfers", group: "window", scopes: ["main"], ...both("Mod+J") },
{ id: "install-update", group: "window", scopes: ["main"], ...both() },
// ⌘+ is ⇧⌘= on US layouts — both spellings, plus the keypad
{ id: "scale-up", group: "window", scopes: ["any"], ...both("Mod+Equal", "Mod+Shift+Equal", "Mod+NumpadAdd") },
{ id: "scale-down", group: "window", scopes: ["any"], ...both("Mod+Minus") },
{ id: "scale-reset", group: "window", scopes: ["any"], ...both("Mod+0") },
{ id: "close-window", group: "window", scopes: ["editor", "manager", "settings"], ...both("Mod+W") },
// ---- editor window ----
{ id: "save", group: "editor", scopes: ["editor"], ...both("Mod+S") },
{ id: "find", group: "editor", scopes: ["editor"], ...both("Mod+F") },
{ id: "find-next", group: "editor", scopes: ["editor"], mac: ["Mod+G"], other: ["F3"] },
{ id: "find-prev", group: "editor", scopes: ["editor"], mac: ["Mod+Shift+G"], other: ["Shift+F3"] },
{ id: "toggle-wrap", group: "editor", scopes: ["editor"], ...both("Alt+Z") },
// ---- connection manager ----
{ id: "conn-new", group: "connections", scopes: ["manager"], ...both("Mod+N") },
{ id: "conn-new-stack", group: "connections", scopes: ["manager"], ...both("Mod+Shift+N") },
{ id: "conn-save", group: "connections", scopes: ["manager"], ...both("Mod+S") },
{ id: "conn-connect", group: "connections", scopes: ["manager"], ...both("Mod+Enter") },
{ id: "conn-delete", group: "connections", scopes: ["manager"], mac: ["Mod+Backspace"], other: ["Delete"] },
{ id: "conn-rename-stack", group: "connections", scopes: ["manager"], ...both("F2") },
{ id: "conn-import", group: "connections", scopes: ["manager"], ...both("Mod+O") },
] as const satisfies readonly BindingDef[];
export type ShortcutId = (typeof BINDINGS)[number]["id"];
/** Commands the macOS menu bar carries; their accelerators follow the keymap. */
export const NATIVE_MENU_IDS: readonly ShortcutId[] = [
"open-settings",
"new-tab",
"close-tab",
"new-folder",
"new-file",
"edit",
"connection-manager",
"refresh",
];
export interface Combo {
/** A–Z, 0–9, a punctuation event.code (Comma, Equal…) or a key name (Enter, F2…). */
key: string;
/** ⌘ on macOS, Ctrl elsewhere. */
mod: boolean;
/** A literal ⌃, macOS only — elsewhere Ctrl IS Mod. */
ctrl: boolean;
alt: boolean;
shift: boolean;
}
/** The KeyboardEvent fields matching needs (tests build these by hand). */
export interface KeyEventLike {
key: string;
code: string;
metaKey: boolean;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
getModifierState?(key: string): boolean;
}
export interface ResolvedBinding {
id: ShortcutId;
group: Group;
scopes: readonly Scope[];
n?: number;
/** What fires: the user's override when there is one, else the defaults. */
combos: Combo[];
defaults: Combo[];
/** The user replaced this row's defaults (possibly with nothing). */
custom: boolean;
}
const LETTER = /^[A-Z]$/;
const DIGIT = /^[0-9]$/;
const FKEY = /^F([1-9]|1[0-2])$/;
/** Punctuation tokens and the character each stands for. Matched by character
* first (AZERTY's comma is on the M key) and by physical key second (⇧] makes
* "}", the US ⌘+ is ⇧⌘=). */
const CHAR_OF: Record<string, string> = {
Comma: ",",
Period: ".",
Slash: "/",
Semicolon: ";",
Quote: "'",
BracketLeft: "[",
BracketRight: "]",
Backslash: "\\",
Backquote: "`",
Equal: "=",
Minus: "-",
};
const TOKEN_OF_CHAR: Record<string, string> = Object.fromEntries(
Object.entries(CHAR_OF).map(([token, char]) => [char, token]),
);
/** Physical-only keys: the keypad's + (its − already types "-", which is Minus). */
const KEYPAD = new Set(["NumpadAdd"]);
/** KeyboardEvent.key names, the same on every layout. */
const NAMED = new Set([
"Enter",
"Escape",
"Backspace",
"Delete",
"Tab",
"Space",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"Home",
"End",
"PageUp",
"PageDown",
"Insert",
"ContextMenu",
]);
function isToken(key: string): boolean {
return (
LETTER.test(key) || DIGIT.test(key) || FKEY.test(key) || Object.hasOwn(CHAR_OF, key) || KEYPAD.has(key) || NAMED.has(key)
);
}
/**
* An event's key token, or null for modifier-only presses and keys the keymap
* doesn't cover. Letters follow the layout (AZERTY's A is A); under Alt
* (macOS Option turns ⌥Z into Ω) and on non-Latin layouts they fall back to the
* physical key. Digits are physical too: AZERTY types "&" on the 1 key.
*/
export function keyToken(e: Pick<KeyEventLike, "key" | "code" | "altKey">): string | null {
const { key, code } = e;
const upper = key.length === 1 ? key.toUpperCase() : "";
if (!e.altKey && LETTER.test(upper)) return upper;
if (/^Key[A-Z]$/.test(code) && (e.altKey || /\p{L}/u.test(key))) return code.slice(3);
if (/^Digit[0-9]$/.test(code)) return code.slice(5);
if (/^Numpad[0-9]$/.test(code)) return DIGIT.test(key) ? key : NAMED.has(key) ? key : null;
if (Object.hasOwn(TOKEN_OF_CHAR, key)) return TOKEN_OF_CHAR[key];
if (Object.hasOwn(CHAR_OF, code) || KEYPAD.has(code)) return code;
if (key === " " || code === "Space") return "Space";
if (FKEY.test(key) || NAMED.has(key)) return key;
return null;
}
function eventMods(e: KeyEventLike, isMac: boolean) {
return isMac
? { mod: e.metaKey, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, other: false }
: // never part of a binding: the Windows / Super key, and AltGr — which
// arrives as Ctrl+Alt while it types "@", "€", "{"…
{
mod: e.ctrlKey,
ctrl: false,
alt: e.altKey,
shift: e.shiftKey,
other: e.metaKey || !!e.getModifierState?.("AltGraph"),
};
}
/** Exact match: the same key AND exactly the same modifiers. ⌃ is not ⌘ on
* macOS (⌃F is Cocoa's forward-one-character), and Ctrl+Tab is not Tab. */
export function matchesEvent(combo: Combo, e: KeyEventLike, isMac: boolean): boolean {
const m = eventMods(e, isMac);
if (m.other || m.mod !== combo.mod || m.ctrl !== combo.ctrl || m.alt !== combo.alt || m.shift !== combo.shift) {
return false;
}
return keyToken(e) === combo.key || ((Object.hasOwn(CHAR_OF, combo.key) || KEYPAD.has(combo.key)) && e.code === combo.key);
}
/** The combo a keydown spells (Settings → Keyboard recording), or null. */
export function comboFromEvent(e: KeyEventLike, isMac: boolean): Combo | null {
const key = keyToken(e);
const m = eventMods(e, isMac);
if (!key || m.other) return null;
return { key, mod: m.mod, ctrl: m.ctrl, alt: m.alt, shift: m.shift };
}
/** "Mod+Shift+N" → Combo; null for anything malformed. Off macOS a stored
* literal Ctrl folds into Mod — on those keyboards they are the same key. */
export function parseCombo(text: unknown, isMac: boolean): Combo | null {
if (typeof text !== "string" || !text) return null;
const parts = text.split("+");
let key = parts.pop() ?? "";
if (key.length === 1) key = key.toUpperCase();
if (!isToken(key)) return null;
const combo: Combo = { key, mod: false, ctrl: false, alt: false, shift: false };
for (const part of parts) {
const name = part.toLowerCase();
if (name === "mod") combo.mod = true;
else if (name === "ctrl") combo.ctrl = true;
else if (name === "alt") combo.alt = true;
else if (name === "shift") combo.shift = true;
else return null;
}
if (!isMac && combo.ctrl) {
combo.ctrl = false;
combo.mod = true;
}
return combo;
}
export function serializeCombo(c: Combo): string {
return [c.mod && "Mod", c.ctrl && "Ctrl", c.alt && "Alt", c.shift && "Shift", c.key].filter(Boolean).join("+");
}
export function sameCombo(a: Combo, b: Combo): boolean {
return serializeCombo(a) === serializeCombo(b);
}
function parseList(list: readonly unknown[], isMac: boolean): Combo[] {
const out: Combo[] = [];
for (const text of list) {
const combo = parseCombo(text, isMac);
if (combo && !out.some((c) => sameCombo(c, combo))) out.push(combo);
}
return out;
}
/**
* The table with the user's overrides applied — the single place a hand-edited
* settings.json is sanitized: unknown ids, non-arrays and malformed combos are
* dropped. An override replaces a row's whole list; [] unbinds it.
*/
export function effectiveKeymap(overrides: unknown, isMac: boolean): ResolvedBinding[] {
const table: Record<string, unknown> =
overrides && typeof overrides === "object" && !Array.isArray(overrides)
? (overrides as Record<string, unknown>)
: {};
return BINDINGS.map((b): ResolvedBinding => {
const defaults = parseList(isMac ? b.mac : b.other, isMac);
const raw = Object.hasOwn(table, b.id) ? table[b.id] : undefined;
const custom = Array.isArray(raw);
return {
id: b.id,
group: b.group,
scopes: b.scopes,
n: "n" in b ? b.n : undefined,
defaults,
custom,
combos: custom ? parseList(raw, isMac) : defaults,
};
});
}
/** The stored shape, coerced to arrays of combo strings, for
* SettingsStore.load: a hand-edited settings.json can't break boot. Unknown
* ids are kept (a newer build's commands survive a downgrade round trip);
* effectiveKeymap ignores them. */
export function sanitizeKeybindings(raw: unknown): Record<string, string[]> {
const out: Record<string, string[]> = {};
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
for (const [id, list] of Object.entries(raw)) {
if (Array.isArray(list)) out[id] = list.filter((t): t is string => parseCombo(t, true) !== null);
}
return out;
}
/** True when a binding in `a` and one in `b` can fire on the same keystroke. */
export function scopesOverlap(a: readonly Scope[], b: readonly Scope[]): boolean {
return a.some((x) =>
b.some(
(y) =>
x === y ||
x === "any" ||
y === "any" ||
(x === "panel" && y === "main") ||
(x === "main" && y === "panel"),
),
);
}
/** The binding that already owns `combo` where `scopes` fire, if any. */
export function ownerOf(
keymap: readonly ResolvedBinding[],
combo: Combo,
scopes: readonly Scope[],
except?: ShortcutId,
): ResolvedBinding | undefined {
return keymap.find(
(b) => b.id !== except && scopesOverlap(b.scopes, scopes) && b.combos.some((c) => sameCombo(c, combo)),
);
}
/** Every binding that shares a combo with another one it can collide with. */
export function conflicts(keymap: readonly ResolvedBinding[]): Map<ShortcutId, ShortcutId[]> {
const out = new Map<ShortcutId, ShortcutId[]>();
const link = (from: ShortcutId, to: ShortcutId) => out.set(from, [...(out.get(from) ?? []), to]);
for (let i = 0; i < keymap.length; i++) {
for (let j = i + 1; j < keymap.length; j++) {
const a = keymap[i];
const b = keymap[j];
if (!scopesOverlap(a.scopes, b.scopes)) continue;
if (a.combos.some((ca) => b.combos.some((cb) => sameCombo(ca, cb)))) {
link(a.id, b.id);
link(b.id, a.id);
}
}
}
return out;
}
/** Keys whose modified forms edit text: ⌘⌫ deletes to line start, ⌥← jumps a word… */
const TEXT_EDITING = new Set(["Backspace", "Delete", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End"]);
/** Whether a combo may fire while focus is in a text field. Plain and
* Shift-only keys type there, and so do the text-editing chords (⌘⌫, ⌥←, ⌘A);
* other ⌘/Ctrl and Alt chords and F-keys fire — except Ctrl+Alt off macOS,
* which is AltGr. */
export function firesInTextField(c: Combo, isMac: boolean): boolean {
if (FKEY.test(c.key)) return true;
if (TEXT_EDITING.has(c.key) || (c.mod && c.key === "A")) return false;
if (!isMac && c.mod && c.alt) return false;
return c.mod || c.alt;
}
/** 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>([
"cursor-up",
"cursor-down",
"page-up",
"page-down",
"toggle-select",
"next-tab",
"prev-tab",
"move-tab-left",
"move-tab-right",
"scale-up",
"scale-down",
"find-next",
"find-prev",
]);
export function isRepeatable(id: ShortcutId): boolean {
return REPEATABLE.has(id);
}
const RESERVED = {
mac: ["Mod+Q", "Mod+X", "Mod+C", "Mod+V", "Mod+Z", "Mod+Shift+Z", "Mod+H", "Mod+Alt+H", "Mod+M", "Mod+Backquote"],
other: ["Mod+X", "Mod+C", "Mod+V", "Mod+Z", "Mod+Y"],
};
/** Quit, the clipboard / undo keys and macOS's window keys (⌘H ⌘M ⌘`) can't be rebound. */
export function isReserved(c: Combo, isMac: boolean): boolean {
return parseList(isMac ? RESERVED.mac : RESERVED.other, isMac).some((r) => sameCombo(r, c));
}
/** Key names for the non-macOS spelling ("Ctrl+Shift+Del"); i18n supplies
* localized ones (German keyboards say Strg and Entf). */
export interface KeyNames {
ctrl: string;
alt: string;
shift: string;
enter: string;
backspace: string;
del: string;
esc: string;
space: string;
tab: string;
pgup: string;
pgdn: string;
home: string;
end: string;
ins: string;
menu: string;
num: string;
}
export const EN_KEY_NAMES: KeyNames = {
ctrl: "Ctrl",
alt: "Alt",
shift: "Shift",
enter: "Enter",
backspace: "Backspace",
del: "Del",
esc: "Esc",
space: "Space",
tab: "Tab",
pgup: "PgUp",
pgdn: "PgDn",
home: "Home",
end: "End",
ins: "Ins",
menu: "Menu",
num: "Num",
};
const ARROWS: Record<string, string> = { ArrowUp: "↑", ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→" };
const MAC_GLYPHS: Record<string, string> = { Enter: "↩", Escape: "⎋", Backspace: "⌫", Delete: "⌦", Tab: "⇥" };
function keyLabel(key: string, isMac: boolean, names: KeyNames): string {
if (Object.hasOwn(CHAR_OF, key)) return CHAR_OF[key];
if (Object.hasOwn(ARROWS, key)) return ARROWS[key];
if (isMac && Object.hasOwn(MAC_GLYPHS, key)) return MAC_GLYPHS[key];
const named: Record<string, string> = {
Enter: names.enter,
Escape: names.esc,
Backspace: names.backspace,
Delete: names.del,
Tab: names.tab,
Space: names.space,
Home: names.home,
End: names.end,
PageUp: names.pgup,
PageDown: names.pgdn,
Insert: names.ins,
ContextMenu: names.menu,
NumpadAdd: `${names.num} +`,
};
return Object.hasOwn(named, key) ? named[key] : key;
}
/** "⇧⌘N" on macOS (Apple's ⌃⌥⇧⌘ order), "Ctrl+Shift+N" elsewhere. */
export function formatCombo(c: Combo, isMac: boolean, names: KeyNames = EN_KEY_NAMES): string {
const key = keyLabel(c.key, isMac, names);
if (isMac) return `${c.ctrl ? "⌃" : ""}${c.alt ? "⌥" : ""}${c.shift ? "⇧" : ""}${c.mod ? "⌘" : ""}${key}`;
return [c.mod && names.ctrl, c.alt && names.alt, c.shift && names.shift, key].filter(Boolean).join("+");
}
const ACCELERATOR_KEYS: Record<string, string> = {
Enter: "return",
Escape: "escape",
Tab: "tab",
Space: "space",
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
Home: "home",
End: "end",
PageUp: "pageup",
PageDown: "pagedown",
};
/**
* Electrobun's native menu accelerator ("Command+Shift+n"), or null when the
* menu can't express the combo faithfully:
* - only ⌘ / ⌃ chords — the native parser turns any combo without a modifier
* token into ⌘+key;
* - no ⌫ / ⌦ (its "backspace" and "delete" tokens map to characters AppKit
* matches unreliably), Insert, the Menu key or the keypad;
* - no Shift+punctuation — AppKit matches the shifted character ("}"), not "]".
*/
export function toAccelerator(c: Combo): string | null {
if (!c.mod && !c.ctrl) return null;
if (c.shift && Object.hasOwn(CHAR_OF, c.key)) return null;
let key: string | undefined;
if (LETTER.test(c.key)) key = c.key.toLowerCase();
else if (DIGIT.test(c.key)) key = c.key;
else if (FKEY.test(c.key)) key = c.key.toLowerCase();
else if (Object.hasOwn(CHAR_OF, c.key)) key = c.key === "Minus" ? "minus" : CHAR_OF[c.key];
else if (Object.hasOwn(ACCELERATOR_KEYS, c.key)) key = ACCELERATOR_KEYS[c.key];
if (!key) return null;
return [c.mod && "Command", c.ctrl && "Control", c.alt && "Alt", c.shift && "Shift", key]
.filter(Boolean)
.join("+");
}
+2
View File
@@ -157,6 +157,8 @@ export type AppRPC = {
// settings window // settings window
/** Main window: open (or focus) the single Settings window. */ /** Main window: open (or focus) the single Settings window. */
openSettings: { params: Record<string, never>; response: Result }; openSettings: { params: Record<string, never>; response: Result };
/** Settings window only: its close-window key (Ctrl+W off macOS, ⌘W). */
settingsClose: { params: Record<string, never>; response: Result };
// auto-updater (electrobun Updater) // auto-updater (electrobun Updater)
/** Main window: re-run the update check on demand (menu item). */ /** Main window: re-run the update check on demand (menu item). */
checkForUpdate: { checkForUpdate: {
+10
View File
@@ -91,6 +91,14 @@ export interface AppSettings {
editor: "builtin" | "custom"; editor: "builtin" | "custom";
/** Full path to the external editor .app; empty = OS default application. */ /** Full path to the external editor .app; empty = OS default application. */
editorApp: string; editorApp: string;
/**
* Keyboard remaps from Settings → Keyboard: command id → combo strings, only
* for rows that differ from the defaults ([] unbinds). Format and sanitizing
* live in keymap.ts — a hand-edited entry it can't parse is simply ignored.
*/
keybindings: Record<string, string[]>;
/** Enter / double-click on a LOCAL file opens it in its default app. */
openFilesOnEnter: boolean;
// Auto-refresh the active tab's panels (local watched, remote polled). Always // Auto-refresh the active tab's panels (local watched, remote polled). Always
// on for now; uncomment (+ the settings.service and tab-bar breadcrumbs, and // on for now; uncomment (+ the settings.service and tab-bar breadcrumbs, and
// gate the poll/watch in panels.service) to expose a toggle later. // gate the poll/watch in panels.service) to expose a toggle later.
@@ -109,6 +117,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
autoClearCompleted: true, autoClearCompleted: true,
editor: "builtin", editor: "builtin",
editorApp: "", editorApp: "",
keybindings: {},
openFilesOnEnter: false,
// autoRefresh: true, // autoRefresh: true,
}; };
+84 -38
View File
@@ -1,30 +1,51 @@
import { Injectable, inject, signal } from '@angular/core'; import { Injectable, inject, signal } from '@angular/core';
import type { ColumnKey, EntryType } from '@shared/types'; import type { ColumnKey, EntryType } from '@shared/types';
import type { FileCommand } from './file-actions.service';
import type { Icon14 } from './icons';
import { I18nService } from './i18n.service'; import { I18nService } from './i18n.service';
import { IS_MAC, IS_WINDOWS, MOD_LABEL } from './platform'; import { stepIndex } from './menu-nav';
import type { Side } from './panels.service';
import { IS_MAC, IS_WINDOWS } from './platform';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
import type { ShortcutId } from './shortcuts.service';
/** Optional list columns, in display order (Name is fixed). */ /** Optional list columns, in display order (Name is fixed). */
export const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group']; export const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group'];
export interface MenuItem { export interface MenuItem {
type?: 'separator'; /** separator: a rule; header: a non-pickable section title (the source picker). */
type?: 'separator' | 'header';
label?: string; label?: string;
/** Emitted verbatim to the action dispatcher (`ctx-*` / `col:*` / `tab:*`). */ /** Handed to the request's onPick, else emitted to the action dispatcher
* (`col:*` / `tab:*`). */
action?: string; action?: string;
checked?: boolean; checked?: boolean;
tooltip?: string; tooltip?: string;
/** Shortcut hint, rendered right-aligned. */ /** The keymap command the item runs: its first key shows as the soft hint,
accelerator?: string; * and pressing that key while the menu is open picks the item. */
shortcut?: ShortcutId;
/** A 14px icon (core/icons.ts), drawn in the check column. */
icon?: Icon14;
/** Greyed and unclickable. Menus disable rather than hide, so the set of /** Greyed and unclickable. Menus disable rather than hide, so the set of
* commands stays in the same place from one right-click to the next. */ * commands stays in the same place from one right-click to the next. */
disabled?: boolean; disabled?: boolean;
} }
/** Anything with a viewport point: a MouseEvent, or a row's rect for a
* keyboard-opened menu. */
export interface MenuPoint {
clientX: number;
clientY: number;
}
export interface MenuRequest { export interface MenuRequest {
x: number; x: number;
y: number; y: number;
items: MenuItem[]; items: MenuItem[];
/** Highlighted on open: the first item when the keyboard opened the menu. */
initialIndex: number;
/** Where a pick goes instead of the shared dispatcher (App.handleMenuAction). */
onPick?: (action: string) => void;
} }
export interface TabMenuContext { export interface TabMenuContext {
@@ -38,25 +59,27 @@ export interface TabMenuContext {
} }
export interface RowMenuContext { export interface RowMenuContext {
path: string; side: Side;
name: string; name: string;
type: EntryType; type: EntryType;
isLocal: boolean; isLocal: boolean;
hasEntry: boolean; // false = right-click on empty panel space hasEntry: boolean; // false = right-click on empty panel space
} }
/** Items the highlight can land on. */
export const isSelectable = (item: MenuItem): boolean => !item.type && !item.disabled;
// PORTABILITY: shell-integration wording per OS (resolved at build time for locale) // PORTABILITY: shell-integration wording per OS (resolved at build time for locale)
const revealKey = (): string => const revealKey = (): string =>
IS_MAC ? 'ctx.revealFinder' : IS_WINDOWS ? 'ctx.revealExplorer' : 'ctx.revealFileManager'; IS_MAC ? 'ctx.revealFinder' : IS_WINDOWS ? 'ctx.revealExplorer' : 'ctx.revealFileManager';
const trashKey = (): string => (IS_WINDOWS ? 'ctx.moveToRecycleBin' : 'ctx.moveToTrash'); const trashKey = (): string => (IS_WINDOWS ? 'ctx.moveToRecycleBin' : 'ctx.moveToTrash');
/** /**
* Builds and opens the panel right-click menus. This used to be a native * Builds and opens the HTML context menus. This used to be a native
* electrobun ContextMenu on the bun side, but `showContextMenu` is a documented * electrobun ContextMenu on the bun side, but `showContextMenu` is a documented
* no-op on Linux (electrobun's own .hutch/devkit/api/sdks/main/proc/linux.md), * no-op on Linux (electrobun's own .hutch/devkit/api/sdks/main/proc/linux.md),
* which left the whole right-click surface dead there. The menu is plain HTML * which left the whole right-click surface dead there. The menu is plain HTML
* now — one code path on every platform — and still emits the exact same * now — one code path on every platform.
* action strings, so the dispatcher in app.ts is unchanged.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ContextMenuService { export class ContextMenuService {
@@ -65,76 +88,91 @@ export class ContextMenuService {
readonly request = signal<MenuRequest | null>(null); readonly request = signal<MenuRequest | null>(null);
open(event: MouseEvent, items: MenuItem[]): void { open(
this.request.set({ x: event.clientX, y: event.clientY, items }); point: MenuPoint,
items: MenuItem[],
opts: { keyboard?: boolean; onPick?: (action: string) => void } = {},
): void {
const initialIndex = opts.keyboard ? stepIndex(items.map(isSelectable), -1, 1) : -1;
this.request.set({ x: point.clientX, y: point.clientY, items, initialIndex, onPick: opts.onPick });
} }
close(): void { close(): void {
this.request.set(null); this.request.set(null);
} }
/** A picked item goes through the same dispatcher the app menubar uses. */ /** A pick without its own handler goes through the dispatcher the app
onPick(action: string): void { * menubar uses. */
emit(action: string): void {
this.rpc.emitMenuAction(action); this.rpc.emitMenuAction(action);
} }
/** Row / empty-space menu for a file panel. */ /**
* Row / empty-space menu for a file panel. Actions are FileCommand ids,
* picked through FileActionsService.run. Icons only on the everyday verbs —
* the same glyphs as their toolbar buttons.
*/
rowItems(ctx: RowMenuContext): MenuItem[] { rowItems(ctx: RowMenuContext): MenuItem[] {
const t = (key: string) => this.i18n.t(key); const t = (key: string) => this.i18n.t(key);
const cmd = (id: FileCommand, label: string, extra: Partial<MenuItem> = {}): MenuItem => ({
label,
action: id,
shortcut: id,
...extra,
});
const items: MenuItem[] = []; const items: MenuItem[] = [];
if (!ctx.hasEntry || ctx.name === '..') { if (!ctx.hasEntry || ctx.name === '..') {
// empty space (or the parent row): folder-level actions // empty space (or the parent row): folder-level actions
items.push({ label: t('ctx.newFolder'), action: 'ctx-new-folder' }); items.push(cmd('new-folder', t('ctx.newFolder'), { icon: 'folderAdd14' }));
items.push({ label: t('ctx.newFile'), action: 'ctx-new-file' }); items.push(cmd('new-file', t('ctx.newFile')));
items.push({ type: 'separator' }); items.push({ type: 'separator' });
items.push({ label: t('ctx.refresh'), action: 'ctx-refresh' }); items.push(cmd('refresh', t('ctx.refresh')));
if (ctx.isLocal) { if (ctx.isLocal) {
items.push({ type: 'separator' }); items.push({ type: 'separator' });
items.push({ label: t(revealKey()), action: 'ctx-reveal' }); items.push(cmd('reveal', t(revealKey())));
} }
return items; return items;
} }
items.push({ label: t('ctx.copyToOther'), action: 'ctx-copy' }); // the arrow points at the other panel, as on the toolbar's Send button
const sendIcon = ctx.side === 'left' ? 'arrowRight14' : 'arrowLeft14';
items.push(cmd('send', t('ctx.copyToOther'), { icon: sendIcon }));
if (ctx.type === 'dir') { if (ctx.type === 'dir') {
items.push({ label: t('ctx.open'), action: 'ctx-open' }); items.push(cmd('open', t('ctx.open')));
} else { } else {
if (ctx.isLocal) { if (ctx.isLocal) {
items.push({ label: t('ctx.open'), action: 'ctx-open-default', tooltip: t('ctx.openDefault') }); items.push(cmd('open-default', t('ctx.open'), { tooltip: t('ctx.openDefault') }));
} }
items.push({ label: t('ctx.edit'), action: 'ctx-edit', accelerator: 'F4' }); items.push(cmd('edit', t('ctx.edit'), { icon: 'fileText14' }));
} }
items.push({ label: t('ctx.rename'), action: 'ctx-rename' }); items.push(cmd('rename', t('ctx.rename'), { icon: 'edit14' }));
// chmod on Windows local files only flips the read-only bit — hide the lie // chmod on Windows local files only flips the read-only bit — hide the lie
if (!(ctx.isLocal && IS_WINDOWS)) { if (!(ctx.isLocal && IS_WINDOWS)) {
items.push({ label: t('ctx.permissions'), action: 'ctx-chmod' }); items.push(cmd('chmod', t('ctx.permissions')));
} }
items.push({ type: 'separator' }); items.push({ type: 'separator' });
items.push({ label: t('ctx.copyPath'), action: 'ctx-copy-path' }); items.push(cmd('copy-path', t('ctx.copyPath')));
if (ctx.isLocal) { if (ctx.isLocal) {
items.push({ label: t(revealKey()), action: 'ctx-reveal' }); items.push(cmd('reveal', t(revealKey())));
} }
items.push({ type: 'separator' }); items.push({ type: 'separator' });
if (ctx.isLocal) { if (ctx.isLocal) {
items.push({ items.push(cmd('trash', t(trashKey()), IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}));
label: t(trashKey()),
action: 'ctx-trash',
...(IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}),
});
} }
items.push({ label: t('ctx.delete'), action: 'ctx-delete' }); items.push(cmd('delete', t('ctx.delete'), { icon: 'delete14' }));
return items; return items;
} }
/** /**
* The workspace-tab menu. Actions carry the target tab's id, because the * The workspace-tab menu. Actions carry the target tab's id, because the
* right-clicked tab is not necessarily the active one — nothing here may * right-clicked tab is not necessarily the active one — nothing here may
* assume `activeWorkspace`. * assume `activeWorkspace`. (The hints name the keys, which act on the
* ACTIVE tab; picking by key while this menu is open acts on THIS one.)
* *
* Everything illegal is DISABLED, not omitted: a menu whose items move * Everything illegal is DISABLED, not omitted: a menu whose items move
* between right-clicks is worse than a dead row, and "Close Tab" greyed out explains * between right-clicks is worse than a dead row, and "Close Tab" greyed out
* why the last tab cannot be closed better than its absence does. * explains why the last tab cannot be closed better than its absence does.
* *
* Duplicate/Rename/Save-session are deliberately absent until tabs have an * Duplicate/Rename/Save-session are deliberately absent until tabs have an
* identity to duplicate (a stored connection id) — the sessions round. * identity to duplicate (a stored connection id) — the sessions round.
@@ -143,24 +181,32 @@ export class ContextMenuService {
const t = (key: string) => this.i18n.t(key); const t = (key: string) => this.i18n.t(key);
const last = ctx.tabCount <= 1; const last = ctx.tabCount <= 1;
return [ return [
{ label: t('ctx.tabNew'), action: 'tab:new', accelerator: `${MOD_LABEL}T` }, { label: t('ctx.tabNew'), action: 'tab:new', shortcut: 'new-tab', icon: 'add14' },
{ type: 'separator' }, { type: 'separator' },
{ {
label: t('ctx.tabClose'), label: t('ctx.tabClose'),
action: `tab:close:${ctx.id}`, action: `tab:close:${ctx.id}`,
accelerator: `${MOD_LABEL}W`, shortcut: 'close-tab',
icon: 'close14',
disabled: last,
},
{
label: t('ctx.tabCloseOthers'),
action: `tab:close-others:${ctx.id}`,
shortcut: 'close-other-tabs',
disabled: last, disabled: last,
}, },
{ label: t('ctx.tabCloseOthers'), action: `tab:close-others:${ctx.id}`, disabled: last },
{ {
label: t('ctx.tabCloseRight'), label: t('ctx.tabCloseRight'),
action: `tab:close-right:${ctx.id}`, action: `tab:close-right:${ctx.id}`,
shortcut: 'close-tabs-right',
disabled: ctx.index >= ctx.tabCount - 1, disabled: ctx.index >= ctx.tabCount - 1,
}, },
{ type: 'separator' }, { type: 'separator' },
{ {
label: t('ctx.tabDisconnect'), label: t('ctx.tabDisconnect'),
action: `tab:disconnect:${ctx.id}`, action: `tab:disconnect:${ctx.id}`,
shortcut: 'disconnect-tab',
disabled: !ctx.isRemote, disabled: !ctx.isRemote,
}, },
]; ];
+3 -1
View File
@@ -47,8 +47,10 @@ export class DialogsService {
}); });
} }
/** Any dialog up — the overwrite question included. Reads signals, so an
* effect or computed that asks re-runs when a dialog opens or closes. */
get anyOpen(): boolean { get anyOpen(): boolean {
return !!(this.prompt() || this.confirm() || this.chmod()); return !!(this.prompt() || this.confirm() || this.chmod() || this.overwrite());
} }
closeAll(): void { closeAll(): void {
+114 -17
View File
@@ -1,25 +1,50 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import type { FileEntry } from '@shared/types'; import type { FileEntry } from '@shared/types';
import { ContextMenuService } from './context-menu.service'; import { ContextMenuService, type MenuPoint } from './context-menu.service';
import { DialogsService } from './dialogs.service'; import { DialogsService } from './dialogs.service';
import { joinPath } from './format'; import { joinPath } from './format';
import { type PanelState, type Side } from './panels.service'; import { type PanelState, type Side, type SortKey } from './panels.service';
import { IS_WINDOWS } from './platform'; import { IS_WINDOWS } from './platform';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
import { SettingsService } from './settings.service'; import { SettingsService } from './settings.service';
import type { ShortcutId } from './shortcuts.service';
import { I18nService } from './i18n.service'; import { I18nService } from './i18n.service';
import { ToastService } from './toast.service'; import { ToastService } from './toast.service';
import { TransfersService } from './transfers.service';
/** The row (or empty space) the user last right-clicked; ctx-* actions apply here. */ /** What a file command acts on: a panel, and the row it was invoked on (the
export interface MenuTarget { * cursor row from the keyboard, the right-clicked row from a menu; null =
* the folder itself). */
export interface FileTarget {
side: Side; side: Side;
panel: PanelState; panel: PanelState;
entry: FileEntry | null; entry: FileEntry | null;
} }
/** The keymap commands that act on files — everything the keyboard, the
* right-click menu, the macOS menu bar and the panel toolbar run, all through
* run() so the four entry points can't drift apart. */
export type FileCommand = Extract<
ShortcutId,
| 'open'
| 'open-default'
| 'send'
| 'edit'
| 'rename'
| 'new-folder'
| 'new-file'
| 'chmod'
| 'delete'
| 'trash'
| 'copy-path'
| 'reveal'
| 'refresh'
| `sort-${string}`
>;
/** /**
* File operations invoked from a panel's toolbar / keyboard. They act on the * File operations, always against an explicit panel: on its selection if there
* GIVEN panel: on the selection if there is one, else on the cursor row. * is one, else on the target row.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class FileActionsService { export class FileActionsService {
@@ -29,29 +54,101 @@ export class FileActionsService {
private readonly dialogs = inject(DialogsService); private readonly dialogs = inject(DialogsService);
private readonly menu = inject(ContextMenuService); private readonly menu = inject(ContextMenuService);
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly transfers = inject(TransfersService);
menuTarget: MenuTarget | null = null; /** Run a file command. One switch for every entry point. */
run(id: FileCommand, target: FileTarget): void {
const { side, panel } = target;
const local = panel.sessionId() === 'local';
// the ".." row stands for the folder itself, as on the empty-space menu
const entry = target.entry && target.entry.name !== '..' ? target.entry : null;
switch (id) {
case 'open':
case 'open-default':
// folders, symlinks and ".." navigate; a LOCAL file opens in its default
// app — from the menu's Open always, from Enter / double-click only
// when Settings → File list says so
if (!target.entry) return;
if (target.entry.type !== 'file') panel.open(target.entry);
else if (local && (id === 'open-default' || this.settings.openFilesOnEnter())) {
this.shell('open', target.entry.path);
}
return;
case 'send':
void this.transfers.copyToOtherPanel(side);
return;
case 'edit':
void this.edit(panel);
return;
case 'rename':
this.rename(panel);
return;
case 'new-folder':
this.mkdir(panel);
return;
case 'new-file':
this.createFile(panel);
return;
case 'chmod':
this.chmod(panel);
return;
case 'delete':
this.delete(panel);
return;
case 'trash':
if (local) void this.trash(panel);
return;
case 'copy-path':
this.shell('copyPath', entry?.path ?? panel.cwd());
return;
case 'reveal':
if (local) this.shell('reveal', entry?.path ?? panel.cwd());
return;
case 'refresh':
void panel.refresh();
return;
default:
// sort-name, sort-size…: again = reverse
if (id.startsWith('sort-')) panel.toggleSort(id.slice('sort-'.length) as SortKey);
}
}
/** Right-click entry point: remember the target, then open the menu on it. */ /** Open the row / empty-space menu on `target`; its picks run here. */
openContextMenu(event: MouseEvent, target: MenuTarget): void { openContextMenu(point: MenuPoint, target: FileTarget, keyboard = false): void {
this.menuTarget = target; const { side, panel, entry } = target;
const { panel, entry } = target;
this.menu.open( this.menu.open(
event, point,
this.menu.rowItems({ this.menu.rowItems({
path: entry?.path ?? panel.cwd(), side,
name: entry?.name ?? '', name: entry?.name ?? '',
type: entry?.type ?? 'dir', type: entry?.type ?? 'dir',
isLocal: panel.sessionId() === 'local', isLocal: panel.sessionId() === 'local',
hasEntry: !!entry, hasEntry: !!entry,
}), }),
{ keyboard, onPick: (action) => this.run(action as FileCommand, target) },
); );
} }
async trash(panel: PanelState, entry: FileEntry): Promise<void> { /** Local shell integration — only bun can reach the OS shell / clipboard. */
const res = await this.rpc.request.moveToTrash({ path: entry.path }); private shell(action: 'open' | 'reveal' | 'copyPath', path: string): void {
if (!res.ok) this.toast.error(res.error); void this.rpc.request.shellItemAction({ action, path });
else this.toast.info(this.i18n.t('toasts.movedToTrash', { name: entry.name })); }
/** Move the selection (else the target row) to the Trash, one item at a time. */
async trash(panel: PanelState): Promise<void> {
const targets = this.targets(panel);
if (!targets.length) return;
let moved = 0;
for (const entry of targets) {
const res = await this.rpc.request.moveToTrash({ path: entry.path });
if (!res.ok) {
this.toast.error(res.error);
break;
}
moved++;
}
if (moved === 1) this.toast.info(this.i18n.t('toasts.movedToTrash', { name: targets[0].name }));
else if (moved > 1) this.toast.info(this.i18n.t('toasts.movedManyToTrash', { count: moved }));
await panel.refresh(); await panel.refresh();
} }
+13 -12
View File
@@ -3,6 +3,7 @@
// Only the icon glyph is colored; row text stays neutral. Docs and unknown // Only the icon glyph is colored; row text stays neutral. Docs and unknown
// files stay muted on purpose — sparing color is what keeps it scannable. // files stay muted on purpose — sparing color is what keeps it scannable.
import type { FileEntry } from '@shared/types'; import type { FileEntry } from '@shared/types';
import type { Icon16 } from './icons';
type Category = 'image' | 'audio' | 'video' | 'archive' | 'code' | 'doc'; type Category = 'image' | 'audio' | 'video' | 'archive' | 'code' | 'doc';
@@ -11,29 +12,29 @@ const MUTED = 'shrink-0 text-muted-foreground';
const FOLDER = 'shrink-0 text-icon-folder'; const FOLDER = 'shrink-0 text-icon-folder';
const LINK = 'shrink-0 text-icon-link'; const LINK = 'shrink-0 text-icon-link';
const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: string[] }> = { const CATEGORIES: Record<Category, { glyph: Icon16; class: string; extensions: string[] }> = {
image: { image: {
glyph: 'tdesignImage', glyph: 'image16',
class: 'shrink-0 text-icon-image', class: 'shrink-0 text-icon-image',
extensions: 'png jpg jpeg gif webp avif svg ico icns bmp tiff tif heic heif raw'.split(' '), extensions: 'png jpg jpeg gif webp avif svg ico icns bmp tiff tif heic heif raw'.split(' '),
}, },
audio: { audio: {
glyph: 'tdesignMusic', glyph: 'music16',
class: 'shrink-0 text-icon-media', class: 'shrink-0 text-icon-media',
extensions: 'mp3 wav flac aac ogg oga m4a opus wma aiff aif mid midi'.split(' '), extensions: 'mp3 wav flac aac ogg oga m4a opus wma aiff aif mid midi'.split(' '),
}, },
video: { video: {
glyph: 'tdesignVideo', glyph: 'video16',
class: 'shrink-0 text-icon-media', class: 'shrink-0 text-icon-media',
extensions: 'mp4 mkv mov avi webm m4v mpg mpeg wmv flv 3gp'.split(' '), extensions: 'mp4 mkv mov avi webm m4v mpg mpeg wmv flv 3gp'.split(' '),
}, },
archive: { archive: {
glyph: 'tdesignFileZip', glyph: 'fileZip16',
class: 'shrink-0 text-icon-archive', class: 'shrink-0 text-icon-archive',
extensions: 'zip tar gz tgz bz2 xz zst 7z rar dmg iso jar deb rpm pkg apk'.split(' '), extensions: 'zip tar gz tgz bz2 xz zst 7z rar dmg iso jar deb rpm pkg apk'.split(' '),
}, },
code: { code: {
glyph: 'tdesignCode', glyph: 'code16',
class: 'shrink-0 text-icon-code', class: 'shrink-0 text-icon-code',
// ".ts" is TypeScript here, not an MPEG transport stream (deliberate) // ".ts" is TypeScript here, not an MPEG transport stream (deliberate)
extensions: ( extensions: (
@@ -45,7 +46,7 @@ const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: s
).split(' '), ).split(' '),
}, },
doc: { doc: {
glyph: 'tdesignFile1', glyph: 'fileText16',
class: MUTED, // docs keep the neutral color; only the glyph changes class: MUTED, // docs keep the neutral color; only the glyph changes
extensions: 'md markdown txt rtf pdf doc docx xls xlsx ppt pptx csv tsv log odt ods odp epub'.split(' '), extensions: 'md markdown txt rtf pdf doc docx xls xlsx ppt pptx csv tsv log odt ods odp epub'.split(' '),
}, },
@@ -84,12 +85,12 @@ export function fileKindKey(entry: FileEntry): string {
return category ? KIND_KEYS[category] : 'file'; return category ? KIND_KEYS[category] : 'file';
} }
export function fileGlyph(entry: FileEntry): string { export function fileGlyph(entry: FileEntry): Icon16 {
if (entry.name === '..') return 'tdesignArrowUp'; if (entry.name === '..') return 'arrowUp16';
if (entry.type === 'dir') return 'tdesignFolder'; if (entry.type === 'dir') return 'folder16';
if (entry.type === 'symlink') return 'tdesignLink'; if (entry.type === 'symlink') return 'link16';
const category = categoryOf(entry.name); const category = categoryOf(entry.name);
return category ? CATEGORIES[category].glyph : 'tdesignFile'; return category ? CATEGORIES[category].glyph : 'file16';
} }
export function fileIconClass(entry: FileEntry): string { export function fileIconClass(entry: FileEntry): string {
+36
View File
@@ -0,0 +1,36 @@
/** What Tab can land on. Tab order is DOM order: the app sets no positive tabindex. */
const FOCUSABLE = 'button, input, select, textarea, [tabindex]';
/**
* Tab / Shift+Tab through the focusable controls under `root`, in DOM order,
* wrapping. Done by hand because WKWebView (macOS) skips buttons, checkboxes
* and radios on Tab unless the user turned on System Settings → Keyboard →
* Keyboard navigation, which left dialogs, Settings and the connection manager
* mouse-only there. It is also the focus trap the modal dialogs lacked. A radio
* group is one stop (its checked radio), as natively; arrows move inside it.
* Returns true when it took the key.
*/
export function cycleFocus(root: Element, event: KeyboardEvent): boolean {
if (event.key !== 'Tab' || event.metaKey || event.ctrlKey || event.altKey) return false;
const stops = [...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(isTabStop);
if (!stops.length) return false;
event.preventDefault();
const at = stops.indexOf(document.activeElement as HTMLElement);
const step = event.shiftKey ? -1 : 1;
const next = at < 0 ? (step > 0 ? 0 : stops.length - 1) : (at + step + stops.length) % stops.length;
stops[next].focus();
return true;
}
function isTabStop(el: HTMLElement): boolean {
if (el.tabIndex < 0 || el.matches(':disabled')) return false;
if (!el.getClientRects().length) return false; // display:none, or inside a hidden parent
if (el instanceof HTMLInputElement && el.type === 'radio' && el.name) {
const group = [
...document.querySelectorAll<HTMLInputElement>(`input[type=radio][name="${CSS.escape(el.name)}"]`),
];
const checked = group.find((radio) => radio.checked);
return el === (checked ?? group[0]);
}
return true;
}
+49
View File
@@ -0,0 +1,49 @@
import { expect, test } from 'bun:test';
import { APP_ICONS } from './icons';
import { PIXEL_ICONS } from './pixel-icons';
// The px grid of each size token (styles.css); lg only carries the spinner.
const GRID: Record<string, string> = { sm: '14', md: '16', lg: '20', xl: '24' };
const SKINS = `${import.meta.dir}/../skins`;
// Templates can't type-check icon names, so read them: every <ng-icon> has a
// size token, and every literal name (both sides of a [name] ternary too) is
// registered and drawn for that token's grid. Names computed in TS are typed.
test('every template icon is registered and drawn for the size it renders at', async () => {
const problems: string[] = [];
for (const file of new Bun.Glob('**/*.{html,ts}').scanSync(SKINS)) {
const src = (await Bun.file(`${SKINS}/${file}`).text()).replace(/<!--[\s\S]*?-->/g, '');
for (const [tag] of src.matchAll(/<ng-icon\b[^>]*>/g)) {
const token = tag.match(/size="var\(--icon-size-(\w+)\)"/)?.[1];
if (!token || !GRID[token]) {
problems.push(`${file}: no size token in ${tag}`);
continue;
}
const bound = tag.match(/\[name\]="([^"]*)"/)?.[1] ?? '';
// a ternary's results, not its condition ('local' in `id === 'local' ? …`)
const names = [...tag.matchAll(/\bname="(\w+)"/g), ...bound.matchAll(/[?:]\s*'(\w+)'/g)].map((m) => m[1]);
for (const name of names) {
if (!Object.hasOwn(APP_ICONS, name)) problems.push(`${file}: ${name} is not registered`);
else if (name !== 'tdesignLoading' && !name.endsWith(GRID[token])) {
problems.push(`${file}: ${name} renders at ${token} (${GRID[token]}px)`);
}
}
}
}
expect(problems).toEqual([]);
});
// A grid name's glyph is drawn on that grid (a TDesign glyph registered as
// foo14 would be a 24-unit viewBox), and the redrawn ones use plain attributes
// only: TDesign's stroke-width var or any style would bypass the grid.
test('every glyph is drawn on the grid its name says', () => {
const problems: string[] = [];
for (const [name, svg] of Object.entries(APP_ICONS)) {
const grid = name.match(/(\d+)$/)?.[1];
if (grid && !svg.includes(`viewBox="0 0 ${grid} ${grid}"`)) problems.push(`${name}: not drawn on a ${grid}px grid`);
}
for (const [name, svg] of Object.entries(PIXEL_ICONS)) {
if (/var\(|style=/.test(svg)) problems.push(`${name}: uses a CSS var or a style`);
}
expect(problems).toEqual([]);
});
+14 -68
View File
@@ -1,73 +1,19 @@
// Single icon registry for the whole app, registered once in app.config.ts. // Single icon registry for the whole app, registered once in app.config.ts.
// TDesign's package root exports every icon; named imports tree-shake. Names are // A name is <icon><px>: the pixel grid the glyph is drawn for, which must match
// plain strings in templates — the build never checks them, so every name used // the size token where it renders (pixel-icons.ts). Templates can't type-check
// anywhere must be listed here (an unregistered one renders nothing). // names and an unregistered one renders nothing (a console warning only), so
import { // icons.spec.ts checks every template name; TS code takes the Icon14/Icon16 types.
tdesignAdd, import { tdesignCloseCircle, tdesignLoading } from "@ng-icons/tdesign-icons";
tdesignArrowLeft, import { PIXEL_ICONS } from "./pixel-icons";
tdesignArrowRight,
tdesignArrowUp,
tdesignCheck,
tdesignCheckCircle,
tdesignChevronDown,
tdesignChevronRight,
tdesignChevronUp,
tdesignClose,
tdesignCloseCircle,
tdesignCode,
tdesignDelete,
tdesignDownload,
tdesignEdit,
tdesignFile,
tdesignFile1,
tdesignFileAdd,
tdesignFileZip,
tdesignFolder,
tdesignFolderAdd,
tdesignHardDrive,
tdesignImage,
tdesignLink,
tdesignLoading,
tdesignLockOn,
tdesignMusic,
tdesignRefresh,
tdesignSearch,
tdesignServer,
tdesignSetting,
tdesignVideo,
} from "@ng-icons/tdesign-icons";
export const APP_ICONS = { export const APP_ICONS = {
tdesignAdd, ...PIXEL_ICONS,
tdesignArrowLeft, // TDesign's own 24px grid: as sharp as a circle and its diagonals can be
tdesignArrowRight, closeCircle24: tdesignCloseCircle,
tdesignArrowUp, // spins, so no pixel grid holds; any size
tdesignCheck,
tdesignCheckCircle,
tdesignChevronDown,
tdesignChevronRight,
tdesignChevronUp,
tdesignClose,
tdesignCloseCircle,
tdesignCode,
tdesignDelete,
tdesignDownload,
tdesignEdit,
tdesignFile,
tdesignFile1,
tdesignFileAdd,
tdesignFileZip,
tdesignFolder,
tdesignFolderAdd,
tdesignHardDrive,
tdesignImage,
tdesignLink,
tdesignLoading, tdesignLoading,
tdesignLockOn,
tdesignMusic,
tdesignRefresh,
tdesignSearch,
tdesignServer,
tdesignSetting,
tdesignVideo,
}; };
export type IconName = keyof typeof APP_ICONS;
export type Icon14 = Extract<IconName, `${string}14`>;
export type Icon16 = Extract<IconName, `${string}16`>;
+13
View File
@@ -352,6 +352,19 @@ export class PanelsService {
this.focus.bump(); this.focus.bump();
} }
/** Each file list's key handler. A key that lands on <body> — a mouse click
* dropped focus (WebKit doesn't focus clicked buttons) — still reaches the
* active list through forwardListKey instead of dying until the next click. */
private readonly listKeys = new Map<Side, (event: KeyboardEvent) => void>();
registerListKeys(side: Side, handler: (event: KeyboardEvent) => void): void {
this.listKeys.set(side, handler);
}
forwardListKey(event: KeyboardEvent): void {
this.listKeys.get(this.activeSide())?.(event);
}
// ---- workspace (tab) management ---- // ---- workspace (tab) management ----
newWorkspace(): void { newWorkspace(): void {
+89
View File
@@ -0,0 +1,89 @@
// Icons drawn on the pixel grid they render at. A TDesign glyph is a 24-unit
// SVG with 2-unit strokes on whole units, so at 14 or 16px every straight line
// lands between pixels and smears over two at half ink on a 1x screen. Each
// glyph here is drawn on its own grid (viewBox 0 0 N N for an N px icon, one
// unit = one CSS pixel at 100% scale) with 1px strokes, so straight lines fill
// whole pixels at 1x and 2x. The name's suffix is that grid and must match the
// size token where it renders: 14 = --icon-size-sm, 16 = -md, 24 = -xl
// (icons.spec.ts checks). Drawing rules: docs/gotchas.md.
/** One glyph on an n-px grid: 1px strokes in the text colour. */
const svg = (n: number, body: string) =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n} ${n}" fill="none" stroke="currentColor">${body}</svg>`;
// Straight lines are runs of whole pixels, in pixel coordinates (inclusive),
// and end exactly on pixel edges. Nothing relies on caps or joins: WebKit
// drops square caps on a path of several subpaths, and Skia (WebView2,
// WebKitGTK) strokes 1px lines as hairlines that leave a joined corner pixel
// three-quarters inked.
/** Pixels x0..x1 of row y. */
const h = (y: number, x0: number, x1: number) => `M${x0} ${y + 0.5}H${x1 + 1}`;
/** Pixels y0..y1 of column x. */
const v = (x: number, y0: number, y1: number) => `M${x + 0.5} ${y0}V${y1 + 1}`;
/** The outline of the box from pixel (x0, y0) to (x1, y1). */
const box = (x0: number, y0: number, x1: number, y1: number) => h(y0, x0, x1) + h(y1, x0, x1) + v(x0, y0, y1) + v(x1, y0, y1);
/** Filled shapes in the text colour. */
const fill = (...d: string[]) => `<path fill="currentColor" stroke="none" d="${d.join('')}"/>`;
/** Filled pixels (indicator dots, zipper teeth), size × size each. */
const dots = (at: [number, number][], size = 1) => fill(...at.map(([x, y]) => `M${x} ${y}h${size}v${size}h-${size}z`));
const path = (...d: string[]) => `<path d="${d.join('')}"/>`;
/** Slanted and curved strokes: anti-aliased wherever they sit, so 1.25px to read
* as heavy as the crisp 1px runs. Their ends and vertices sit on pixel centres. */
const slant = (...d: string[]) => `<path stroke-width="1.25" d="${d.join('')}"/>`;
/** A circle as path data, for slant() or fill(). A whole-pixel centre with a half-pixel
* radius puts its top, bottom and sides on pixel centres. */
const circle = (cx: number, cy: number, r: number) => `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0`;
/** A page with its top-right corner folded: the outline, the fold and the cut. */
const FILE14 = path(h(1, 2, 9), v(2, 1, 12), h(12, 2, 11), v(11, 3, 12), v(8, 1, 4), h(4, 8, 11)) + slant('M9.5 1.5l2 2');
const FILE16 = path(h(1, 2, 10), v(2, 1, 14), h(14, 2, 13), v(13, 4, 14), v(9, 1, 5), h(5, 9, 13)) + slant('M10.5 1.5l3 3');
export const PIXEL_ICONS = {
add14: svg(14, path(v(7, 3, 11), h(7, 3, 11))),
// ---- boxes ----
server14: svg(14, path(box(1, 1, 12, 5), box(1, 8, 12, 12)) + dots([[3, 3], [3, 10]])),
server16: svg(16, path(box(1, 1, 14, 6), box(1, 9, 14, 14)) + dots([[4, 3], [4, 11]])),
// TDesign's own geometry, already on the 24px grid; only the dots move onto whole pixels
server24: svg(24, '<g stroke-width="2"><rect x="3" y="3" width="18" height="7"/><rect x="3" y="14" width="18" height="7"/></g>' + dots([[5, 5], [5, 16]], 2)),
hardDrive14: svg(14, path(box(1, 1, 12, 12), h(8, 1, 12)) + dots([[3, 10], [5, 10]])),
file14: svg(14, FILE14),
file16: svg(16, FILE16),
fileText14: svg(14, FILE14 + path(h(7, 4, 9), h(9, 4, 9))),
fileText16: svg(16, FILE16 + path(h(8, 5, 10), h(11, 5, 10))),
fileAdd14: svg(14, path(h(1, 2, 9), v(2, 1, 12), h(12, 2, 6), v(11, 3, 6), v(8, 1, 4), h(4, 8, 11), v(11, 9, 13), h(11, 9, 13)) + slant('M9.5 1.5l2 2')),
fileZip16: svg(16, FILE16 + dots([[6, 2], [5, 3], [6, 4], [5, 5], [6, 6], [5, 7], [6, 8], [5, 9]])),
folder14: svg(14, path(h(2, 1, 5), h(4, 7, 12), v(1, 2, 11), v(12, 4, 11), h(11, 1, 12)) + slant('M5.5 2.5l2 2')),
folder16: svg(16, path(h(2, 1, 6), h(4, 8, 14), v(1, 2, 13), v(14, 4, 13), h(13, 1, 14)) + slant('M6.5 2.5l2 2')),
folderAdd14: svg(14, path(h(2, 1, 5), h(4, 7, 12), v(12, 4, 6), v(1, 2, 11), h(11, 1, 7), v(11, 8, 12), h(10, 9, 13)) + slant('M5.5 2.5l2 2')),
delete14: svg(14, path(h(3, 2, 12), v(3, 3, 12), h(12, 3, 11), v(11, 3, 12), v(5, 1, 3), h(1, 5, 9), v(9, 1, 3), v(7, 5, 10))),
lockOn14: svg(14, path(box(2, 6, 11, 12), v(4, 4, 5), v(9, 4, 5), h(9, 5, 8)) + slant('M4.5 4a2.5 2.5 0 0 1 5 0')),
video16: svg(16, path(box(1, 1, 14, 14), v(6, 4, 11)) + slant('M6.5 4.5L12 8L6.5 11.5')),
// ---- 45° ----
chevronDown14: svg(14, slant('M3.5 5.5l3 3 3-3')),
chevronUp14: svg(14, slant('M3.5 8.5l3-3 3 3')),
chevronRight14: svg(14, slant('M5.5 3.5l3 3-3 3')),
arrowRight14: svg(14, path(h(7, 2, 10)) + slant('M7.5 4.5l3 3-3 3')),
arrowLeft14: svg(14, path(h(7, 3, 11)) + slant('M6.5 4.5l-3 3 3 3')),
arrowUp16: svg(16, path(v(7, 3, 13)) + slant('M3.5 6.5l4-4 4 4')),
close14: svg(14, slant('M4 4l6 6M10 4l-6 6')),
check14: svg(14, slant('M3.5 6.5l3 3 5-5')),
download14: svg(14, path(v(7, 2, 8), v(2, 8, 11), v(12, 8, 11), h(11, 2, 12)) + slant('M4.5 5.5l3 3 3-3')),
code16: svg(16, slant('M4 5L1 8l3 3M12 5l3 3-3 3M9.5 2.5l-3 11')),
// ---- circles and arcs ----
checkCircle14: svg(14, slant(circle(7, 7, 5.5), 'M4.5 6.5l2 2 3-3')),
closeCircle14: svg(14, slant(circle(7, 7, 5.5), 'M5 5l4 4M9 5l-4 4')),
search14: svg(14, slant(circle(6, 6, 4.5), 'M9.5 9.5l3 3')),
setting14: svg(14, path(v(2, 4, 9), v(11, 4, 9)) + slant('M2.5 4.5L7 1.5l4.5 3M2.5 9.5L7 12.5l4.5-3', circle(7, 7, 2.5))),
setting16: svg(16, path(v(2, 4, 11), v(13, 4, 11)) + slant('M2.5 4.5L8 1.5l5.5 3M2.5 11.5L8 14.5l5.5-3', circle(8, 8, 2.5))),
refresh14: svg(14, path(v(1, 9, 12), h(9, 1, 3), v(12, 1, 4), h(4, 10, 12)) + slant('M12.47 7.58A5.5 5.5 0 0 1 2.1 9.5M1.53 6.42A5.5 5.5 0 0 1 11.9 4.5')),
refresh16: svg(16, path(v(1, 10, 13), h(10, 1, 4), v(14, 2, 5), h(5, 11, 14)) + slant('M14.46 8.68A6.5 6.5 0 0 1 2 10.5M1.54 7.32A6.5 6.5 0 0 1 14 5.5')),
// ---- the rest ----
link16: svg(16, slant('M7.7 4.8L9.23 3.23A2.5 2.5 0 0 1 12.77 6.77L11.2 8.3M8.3 11.2L6.77 12.77A2.5 2.5 0 0 1 3.23 9.23L4.8 7.7M9.5 6.5l-3 3')),
music16: svg(16, path(h(2, 4, 12), h(5, 4, 12), v(4, 2, 12), v(12, 2, 12)) + fill(circle(3, 13, 2), circle(11, 13, 2))),
image16: svg(16, path(box(1, 1, 14, 14)) + slant('M1.5 10.5l4-4 8 8', circle(10.5, 5.5, 1))),
edit14: svg(14, path(v(2, 9, 11), h(11, 2, 4)) + slant('M2.5 8.5l7-7 2 2-7 7M7.5 3.5l2 2')),
};
+12 -4
View File
@@ -21,10 +21,6 @@ export const IS_WINDOWS = PLATFORM === 'win32';
export const IS_LINUX = PLATFORM === 'linux'; export const IS_LINUX = PLATFORM === 'linux';
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
export const APP_MODE: 'main' | 'editor' | 'manager' | 'settings' = export const APP_MODE: 'main' | 'editor' | 'manager' | 'settings' =
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main'; typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
@@ -35,6 +31,18 @@ export const EDITOR_CONTEXT =
* hiddenInset layout, see chromeOptions), which reserves `pl-24` for them. */ * hiddenInset layout, see chromeOptions), which reserves `pl-24` for them. */
export const IS_MAC_CHROME = IS_MAC; export const IS_MAC_CHROME = IS_MAC;
/**
* WebView2 (Windows) reloads the whole page on F5 / Ctrl+R / Ctrl+F5 /
* Ctrl+Shift+R unless the page claims the key — dropping every session and any
* unsaved editor text. Every window root calls this for keys none of its
* bindings took. Known ceiling: unverified whether Electrobun already turns
* WebView2's browser keys off; harmless if it does.
*/
export function blockReloadKeys(event: KeyboardEvent): void {
if (!IS_WINDOWS || event.defaultPrevented) return;
if (event.key === 'F5' || (event.ctrlKey && event.code === 'KeyR')) event.preventDefault();
}
/** /**
* Kill the webview's default right-click menu (Reload, Inspect Element…) * Kill the webview's default right-click menu (Reload, Inspect Element…)
* everywhere the app doesn't show its own native menu. Text fields keep the * everywhere the app doesn't show its own native menu. Text fields keep the
+23
View File
@@ -17,6 +17,9 @@ export class SettingsService {
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted); readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
readonly editor = signal<AppSettings['editor']>(DEFAULT_SETTINGS.editor); readonly editor = signal<AppSettings['editor']>(DEFAULT_SETTINGS.editor);
readonly editorApp = signal(DEFAULT_SETTINGS.editorApp); readonly editorApp = signal(DEFAULT_SETTINGS.editorApp);
/** Settings → Keyboard overrides; ShortcutsService applies them to the keymap. */
readonly keybindings = signal<AppSettings['keybindings']>(DEFAULT_SETTINGS.keybindings);
readonly openFilesOnEnter = signal(DEFAULT_SETTINGS.openFilesOnEnter);
// Auto-refresh is always on for now (see panels.service). To make it // Auto-refresh is always on for now (see panels.service). To make it
// user-controllable: uncomment the field in types.ts, then here add // user-controllable: uncomment the field in types.ts, then here add
// readonly autoRefresh = signal(DEFAULT_SETTINGS.autoRefresh); // readonly autoRefresh = signal(DEFAULT_SETTINGS.autoRefresh);
@@ -25,6 +28,10 @@ export class SettingsService {
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
private readonly i18n = inject(I18nService); private readonly i18n = inject(I18nService);
/** Saves are whole-object: one sent before the first getSettings lands would
* write these defaults over the user's settings (a ⌘+ pressed as a window
* opens would wipe every keyboard remap), so nothing persists until then. */
private loaded = false;
/** Whether the app is actually rendering dark right now (drives the Tone row). */ /** Whether the app is actually rendering dark right now (drives the Tone row). */
readonly effectiveDark = computed( readonly effectiveDark = computed(
@@ -45,6 +52,7 @@ export class SettingsService {
} }
private applyAll(settings: AppSettings): void { private applyAll(settings: AppSettings): void {
this.loaded = true;
this.locale.set(settings.locale); this.locale.set(settings.locale);
this.theme.set(settings.theme); this.theme.set(settings.theme);
this.darkTone.set(settings.darkTone); this.darkTone.set(settings.darkTone);
@@ -56,6 +64,8 @@ export class SettingsService {
this.autoClearCompleted.set(settings.autoClearCompleted); this.autoClearCompleted.set(settings.autoClearCompleted);
this.editor.set(settings.editor); this.editor.set(settings.editor);
this.editorApp.set(settings.editorApp); this.editorApp.set(settings.editorApp);
this.keybindings.set(settings.keybindings);
this.openFilesOnEnter.set(settings.openFilesOnEnter);
this.applyTheme(); this.applyTheme();
this.applyDarkTone(); this.applyDarkTone();
this.applyUiScale(); this.applyUiScale();
@@ -123,6 +133,16 @@ export class SettingsService {
void this.persist(); void this.persist();
} }
setKeybindings(keybindings: AppSettings['keybindings']): void {
this.keybindings.set(keybindings);
void this.persist();
}
setOpenFilesOnEnter(open: boolean): void {
this.openFilesOnEnter.set(open);
void this.persist();
}
private applyTheme(): void { private applyTheme(): void {
const theme = this.theme(); const theme = this.theme();
if (theme === 'system') { if (theme === 'system') {
@@ -172,10 +192,13 @@ export class SettingsService {
autoClearCompleted: this.autoClearCompleted(), autoClearCompleted: this.autoClearCompleted(),
editor: this.editor(), editor: this.editor(),
editorApp: this.editorApp(), editorApp: this.editorApp(),
keybindings: this.keybindings(),
openFilesOnEnter: this.openFilesOnEnter(),
}; };
} }
private async persist(): Promise<void> { private async persist(): Promise<void> {
if (!this.loaded) return;
await this.rpc.request.saveSettings({ settings: this.settings() }); await this.rpc.request.saveSettings({ settings: this.settings() });
} }
} }
+148 -96
View File
@@ -1,114 +1,166 @@
import { Injectable } from '@angular/core'; import { Injectable, computed, inject } from '@angular/core';
import {
type Combo,
EN_KEY_NAMES,
effectiveKeymap,
firesInTextField,
formatCombo,
isRepeatable,
type KeyEventLike,
type KeyNames,
matchesEvent,
parseCombo,
type ResolvedBinding,
type Scope,
sameCombo,
serializeCombo,
type ShortcutId,
} from '@shared/keymap';
import { I18nService } from './i18n.service';
import { IS_MAC } from './platform';
import { SettingsService } from './settings.service';
export type { ShortcutId } from '@shared/keymap';
/** A keydown as resolve() reads it: the matching fields plus where it came from. */
type KeyInput = KeyEventLike & { target?: EventTarget | null; isComposing?: boolean; keyCode?: number };
/** /**
* Every window-level shortcut the app answers to, by name. Components switch * The keyboard layer over the shared keymap (src/shared/keymap.ts): resolves
* on these ids instead of key-matching inline, so the whole keymap is ONE * keydowns against the EFFECTIVE keymap — defaults plus the Settings → Keyboard
* table — the precondition for the vim-style bindings planned next, which * remaps, live in every window — and formats the hints menus and tooltips show.
* will need a mode/leader state machine wrapped around exactly this point.
*/
export type ShortcutId =
// main window (workspace/tab management)
| 'new-tab'
| 'close-tab'
| 'cycle-tab'
| 'cycle-tab-back'
| 'go-to-tab' // 1-9; the digit rides in ShortcutMatch.digit
| 'open-settings'
// editor window
| 'save'
| 'find'
| 'close-window'
| 'toggle-wrap'
// any window
| 'scale-up'
| 'scale-down'
| 'scale-reset';
export interface ShortcutMatch {
id: ShortcutId;
/** Only for go-to-tab: the digit pressed, 1–9. */
digit?: number;
}
/** Which keymap a window consults. 'manager' and 'settings' answer only to the
* any-window shortcuts (scale). Escape/Tab stay component-local: their meaning
* is contextual state (find bar open? close prompt up? which pane?), not a
* global binding. */
export type ShortcutScope = 'main' | 'editor' | 'manager' | 'settings';
/**
* The keyboard layer: modifier helpers + one declarative keymap.
* *
* House rules encoded here rather than re-derived per component (they were * - Matching is exact: ⌃F is not ⌘F, Ctrl+Tab is not Tab. `Mod` is ⌘ on
* previously copy-pasted inline in six places, drifting apart): * macOS and Ctrl elsewhere.
* - `mod` is metaKey OR ctrlKey — ⌘ on macOS, Ctrl elsewhere. The keyboard * - On macOS the PAGE sees ⌘-keys first: WKWebView hands key equivalents to the
* does not change with the window chrome (platform.ts says the same about * page before the app menu (WebKit's WebViewImpl::performKeyEquivalent), so a
* MOD_LABEL). * key a window claims here (claim() → preventDefault) never reaches the menu
* - Layout-dependent keys match on `event.code`, not `event.key`: digits * bar, whose accelerators bun builds from the same table. Keys nobody claims
* (AZERTY's digit row shifts), +/- (key varies wildly across layouts), and * fall through to it.
* letter chords with Alt (macOS Option produces Ω for ⌥Z). Plain letter * - From a text field only firesInTextField() combos count (⌘S yes, Delete no).
* chords match on `key` so they follow the user's layout. * - Escape, the arrows inside menus and dialog Enter stay component-local:
* - isTypingTarget guards shortcuts that would be hostile mid-typing. It is * they are fixed, so nobody can remap themselves out of a dialog.
* deliberately NOT applied inside resolve(): the existing bindings rely on
* the path bar's stopPropagation and the editor WANTS Mod+S while typing.
* Callers opt in per shortcut (the scale keys do).
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ShortcutsService { export class ShortcutsService {
/** The platform command modifier: ⌘ on macOS, Ctrl elsewhere. Mouse events private readonly settings = inject(SettingsService);
* qualify too — Mod+click toggle-select in the panels uses the same rule. */ private readonly i18n = inject(I18nService);
isMod(event: KeyboardEvent | MouseEvent): boolean {
return event.metaKey || event.ctrlKey;
}
/** True when the event originates from something the user types into. */ readonly keymap = computed(() => effectiveKeymap(this.settings.keybindings(), IS_MAC));
isTypingTarget(event: KeyboardEvent): boolean { private readonly byId = computed(() => new Map(this.keymap().map((b) => [b.id, b])));
const target = event.target as HTMLElement | null; /** Localized key names for the non-mac spelling ("Strg+Entf"); reading t()
return !!target?.closest('input, textarea, select, [contenteditable="true"]'); * makes every hint follow a language switch. */
private readonly names = computed<KeyNames>(() => {
const names = { ...EN_KEY_NAMES };
for (const key of Object.keys(names) as (keyof KeyNames)[]) {
names[key] = this.i18n.t(`keys.names.${key}`);
}
return names;
});
/** The command a keydown triggers in `scope`, or null. Window scopes include
* `any`; `panel` doesn't (the window root runs those). */
resolve(event: KeyInput, scope: Scope): ShortcutId | null {
// an IME's Enter commits a candidate, it is never a shortcut
if (event.isComposing || event.keyCode === 229) return null;
const typing = this.isTypingTarget(event);
for (const binding of this.keymap()) {
if (!binding.scopes.includes(scope) && (scope === 'panel' || !binding.scopes.includes('any'))) {
continue;
}
for (const combo of binding.combos) {
if (typing && !firesInTextField(combo, IS_MAC)) continue;
if (matchesEvent(combo, event, IS_MAC)) return binding.id;
}
}
return null;
} }
/** /**
* Match an event against the scope's keymap. Returns null for anything the * resolve() + take the key: a match is preventDefault-ed, so the macOS menu
* table does not claim — callers must then leave the event alone. * never runs it a second time. A held key repeats only movement-like
* commands; any other repeat is swallowed (holding ⌘W closes one tab).
*/ */
resolve(event: KeyboardEvent, scope: ShortcutScope): ShortcutMatch | null { claim(event: KeyboardEvent, scope: Scope): ShortcutId | null {
const mod = this.isMod(event); const id = this.resolve(event, scope);
const key = event.key.toLowerCase(); if (!id) return null;
event.preventDefault();
return event.repeat && !isRepeatable(id) ? null : id;
}
// ---- any window: interface scale ---- /** Whether a keydown is one of `id`'s combos (an open menu matching its items). */
// event.code, not key: '+' needs Shift on many layouts and '-' moves matches(id: ShortcutId, event: KeyEventLike): boolean {
// around; the physical Equal/Minus keys (and the numpad pair) are stable. return !!this.byId().get(id)?.combos.some((c) => matchesEvent(c, event, IS_MAC));
if (mod && !event.altKey) { }
if (event.code === 'Equal' || event.code === 'NumpadAdd') return { id: 'scale-up' };
if (event.code === 'Minus' || event.code === 'NumpadSubtract') return { id: 'scale-down' }; binding(id: ShortcutId): ResolvedBinding | undefined {
if (event.code === 'Digit0' || event.code === 'Numpad0') return { id: 'scale-reset' }; return this.byId().get(id);
}
format(combo: Combo): string {
return formatCombo(combo, IS_MAC, this.names());
}
/** The hint menus show: the command's first key, or '' when unbound. */
label(id: ShortcutId): string {
const first = this.byId().get(id)?.combos[0];
return first ? this.format(first) : '';
}
labels(id: ShortcutId): string[] {
return (this.byId().get(id)?.combos ?? []).map((c) => this.format(c));
}
/** A tooltip with the command's keys: "Delete (F8 / Del)"; bare when unbound. */
tip(text: string, id: ShortcutId): string {
const keys = this.labels(id);
return keys.length ? `${text} (${keys.join(' / ')})` : text;
}
/** 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 ? this.format(parsed) : combo;
}
/** tip() for a fixed key. */
tipFixed(text: string, combo: string): string {
return `${text} (${this.fixedKey(combo)})`;
}
/**
* Store new combos for commands (Settings → Keyboard). Only rows that differ
* from their defaults are kept, so a reset — or a remap back to the
* default — leaves nothing behind.
*/
rebind(changes: Partial<Record<ShortcutId, Combo[]>>): void {
const next = { ...this.settings.keybindings() };
for (const [id, combos] of Object.entries(changes) as [ShortcutId, Combo[]][]) {
const defaults = this.byId().get(id)?.defaults ?? [];
const isDefault =
combos.length === defaults.length && combos.every((c, i) => sameCombo(c, defaults[i]));
if (isDefault) delete next[id];
else next[id] = combos.map(serializeCombo);
} }
this.settings.setKeybindings(next);
}
if (scope === 'manager' || scope === 'settings') return null; // any-window shortcuts only resetAll(): void {
this.settings.setKeybindings({});
}
if (scope === 'main') { /** The platform command modifier: ⌘ on macOS, Ctrl elsewhere — exactly one.
// on macOS the app menu's ⌘, intercepts first; this is the Windows/Linux path * Mouse events too: Mod+click toggle-selects in the panels. */
if (mod && key === ',') return { id: 'open-settings' }; isMod(event: KeyboardEvent | MouseEvent): boolean {
if (mod && key === 't') return { id: 'new-tab' }; return IS_MAC ? event.metaKey : event.ctrlKey;
if (mod && key === 'w') return { id: 'close-tab' }; }
// ctrlKey only, never meta: ⌘Tab is the macOS app switcher
if (event.ctrlKey && event.key === 'Tab') {
return { id: event.shiftKey ? 'cycle-tab-back' : 'cycle-tab' };
}
if (mod && !event.shiftKey && !event.altKey) {
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1];
if (digit) return { id: 'go-to-tab', digit: Number(digit) };
}
return null;
}
// scope === 'editor' /** True when the event comes from something the user types into. */
if (mod && key === 's') return { id: 'save' }; isTypingTarget(event: { target?: EventTarget | null }): boolean {
if (mod && key === 'f') return { id: 'find' }; const target = event.target as HTMLElement | null;
// on macOS the app menu intercepts ⌘W before this ever fires; this arm is return !!target?.closest?.(
// the Windows/Linux path 'textarea, select, [contenteditable="true"], input:not([type=checkbox]):not([type=radio]):not([type=button])',
if (mod && key === 'w') return { id: 'close-window' }; );
if (event.altKey && event.code === 'KeyZ') return { id: 'toggle-wrap' };
return null;
} }
} }
+8
View File
@@ -45,6 +45,14 @@ export class UpdaterService {
void this.rpc.request.applyUpdate({}); // quits + relaunches void this.rpc.request.applyUpdate({}); // quits + relaunches
} }
/** The pill's one action, also the "Install update" key: download when
* available, apply (relaunch) once ready; nothing otherwise. */
advance(): void {
const state = this.state();
if (state === 'available') void this.download();
else if (state === 'ready') this.apply();
}
/** Manual re-check (menu item). */ /** Manual re-check (menu item). */
async check(): Promise<void> { async check(): Promise<void> {
if (!this.rpc.available) return; if (!this.rpc.available) return;
+3 -2
View File
@@ -2,7 +2,7 @@
@if (!rpc.available) { @if (!rpc.available) {
<div class="flex flex-1 items-center justify-center"> <div class="flex flex-1 items-center justify-center">
<div class="max-w-sm border border-border bg-card p-6 text-center shadow-sm"> <div class="max-w-sm border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="tdesignCloseCircle" size="1.5rem" class="text-destructive" /> <ng-icon name="closeCircle24" size="var(--icon-size-xl)" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p> <p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground"> <p class="mt-1 text-xs text-muted-foreground">
{{ i18n.t('app.notConnectedBody') }} {{ i18n.t('app.notConnectedBody') }}
@@ -28,9 +28,10 @@
(mousedown)="focusManager()" (mousedown)="focusManager()"
> >
<div class="border border-border bg-card px-5 py-4 text-center shadow-xl"> <div class="border border-border bg-card px-5 py-4 text-center shadow-xl">
<ng-icon name="tdesignServer" size="1.25rem" class="text-muted-foreground" /> <ng-icon name="server24" size="var(--icon-size-xl)" class="text-muted-foreground" />
<p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p> <p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p>
<button <button
(click)="focusManager()"
class="mt-3 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90" class="mt-3 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
> >
{{ i18n.t('app.showConnections') }} {{ i18n.t('app.showConnections') }}
+135 -85
View File
@@ -1,22 +1,24 @@
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal, viewChild } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import { BRANDING } from '@shared/branding'; import { BRANDING } from '@shared/branding';
import { NATIVE_MENU_IDS } from '@shared/keymap';
import type { ColumnKey } from '@shared/types'; import type { ColumnKey } from '@shared/types';
import { ConnectionsService } from '@core/connections.service'; import { ConnectionsService } from '@core/connections.service';
import { ContextMenuService } from '@core/context-menu.service';
import { ContextMenu } from './context-menu'; import { ContextMenu } from './context-menu';
import { ChmodDialog } from './dialogs/chmod-dialog'; import { ChmodDialog } from './dialogs/chmod-dialog';
import { ConfirmDialog } from './dialogs/confirm-dialog'; import { ConfirmDialog } from './dialogs/confirm-dialog';
import { OverwriteDialog } from './dialogs/overwrite-dialog'; import { OverwriteDialog } from './dialogs/overwrite-dialog';
import { PromptDialog } from './dialogs/prompt-dialog'; import { PromptDialog } from './dialogs/prompt-dialog';
import { DialogsService } from '@core/dialogs.service'; import { DialogsService } from '@core/dialogs.service';
import { FileActionsService } from '@core/file-actions.service'; import { FileActionsService, type FileCommand } from '@core/file-actions.service';
import { FilePanel } from './file-panel/file-panel'; import { FilePanel } from './file-panel/file-panel';
import { PanelsService } from '@core/panels.service'; import { PanelsService } from '@core/panels.service';
import { blockNativeContextMenu } from '@core/platform'; import { blockNativeContextMenu, blockReloadKeys } 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, type ShortcutId } from '@core/shortcuts.service';
import { StatusBar } from './status-bar/status-bar'; import { StatusBar } from './status-bar/status-bar';
import { TabBar } from './tab-bar/tab-bar'; import { TabBar } from './tab-bar/tab-bar';
import { ToastService } from '@core/toast.service'; import { ToastService } from '@core/toast.service';
@@ -44,6 +46,7 @@ import { UpdaterService } from '@core/updater.service';
host: { host: {
'(window:keydown)': 'onWindowKeydown($event)', '(window:keydown)': 'onWindowKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)', '(window:contextmenu)': 'blockContextMenu($event)',
'(mousedown)': 'keepListFocus($event)',
}, },
}) })
export class App { export class App {
@@ -58,6 +61,8 @@ export class App {
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService); private readonly shortcuts = inject(ShortcutsService);
private readonly updater = inject(UpdaterService); private readonly updater = inject(UpdaterService);
private readonly menu = inject(ContextMenuService);
private readonly transferBar = viewChild(TransferBar);
/** The connection-manager window is open — this window is modal-blocked. */ /** The connection-manager window is open — this window is modal-blocked. */
readonly managerOpen = signal(false); readonly managerOpen = signal(false);
@@ -105,10 +110,6 @@ export class App {
/** Native application-menu / manager-window actions forwarded from bun. */ /** Native application-menu / manager-window actions forwarded from bun. */
private async handleMenuAction(action: string): Promise<void> { private async handleMenuAction(action: string): Promise<void> {
if (action.startsWith('ctx-')) {
this.handleContextAction(action);
return;
}
if (action.startsWith('connect:')) { if (action.startsWith('connect:')) {
const connection = await this.findConnection(action.slice('connect:'.length)); const connection = await this.findConnection(action.slice('connect:'.length));
if (connection) { if (connection) {
@@ -137,22 +138,19 @@ export class App {
} }
switch (action) { switch (action) {
case 'new-tab': case 'new-tab':
this.panels.newWorkspace();
break;
case 'close-tab': case 'close-tab':
this.panels.closeWorkspace(this.panels.activeWorkspaceId()); this.runWindowCommand(action);
break; break;
// the macOS File / View items act on the active panel, exactly like their keys
case 'new-folder': case 'new-folder':
this.actions.mkdir(this.panels.active());
break;
case 'new-file': case 'new-file':
this.actions.createFile(this.panels.active()); this.runFileCommand(action);
break; break;
case 'edit-file': case 'edit-file':
void this.actions.edit(this.panels.active()); this.runFileCommand('edit');
break; break;
case 'refresh-active': case 'refresh-active':
void this.panels.active().refresh(); this.runFileCommand('refresh');
break; break;
case 'check-for-updates': case 'check-for-updates':
void this.updater.check(); void this.updater.check();
@@ -160,37 +158,102 @@ export class App {
} }
} }
/** Window shortcuts — matching lives in ShortcutsService (one keymap for the private runFileCommand(id: FileCommand): void {
* app); this switch only maps ids onto actions. Panel-level keys stay on const panel = this.panels.active();
* the panels themselves. */ const entry = panel.sorted()[panel.cursor()] ?? null;
this.actions.run(id, { side: this.panels.activeSide(), panel, entry });
}
/**
* Window keys — the `main` scope of the shared keymap (ShortcutsService); the
* focused file list handles its own `panel` keys first. Whatever nobody
* claims ends at the WebView2 reload guard.
*/
onWindowKeydown(event: KeyboardEvent): void { onWindowKeydown(event: KeyboardEvent): void {
if (this.managerOpen()) return; // modal-blocked behind the manager this.handleWindowKey(event);
const match = this.shortcuts.resolve(event, 'main'); blockReloadKeys(event); // a no-op once something claimed the key
if (!match) return; }
// scale keys are hostile mid-typing ('-' in a rename prompt must type '-');
// the tab shortcuts keep firing there, as they always did private handleWindowKey(event: KeyboardEvent): void {
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return; // a component took it (the list, a menu, a dialog), or the connection
event.preventDefault(); // manager holds this window modal
switch (match.id) { if (event.defaultPrevented || this.managerOpen()) return;
if (this.dialogs.anyOpen || this.menu.request()) {
this.swallowBehindOverlay(event);
return;
}
if (event.target === document.body) {
// a click dropped focus to <body>: the active list still gets its keys
this.panels.forwardListKey(event);
this.panels.refocus();
if (event.defaultPrevented) return;
}
const id = this.shortcuts.claim(event, 'main');
if (id) this.runWindowCommand(id);
}
/**
* A dialog or menu owns the keyboard. Window commands are swallowed — an
* unclaimed ⌘T would fall through to the macOS menu bar and open a tab
* behind the dialog — and so are the panel commands the menu bar carries
* (⌘N, ⌘E…). The interface-scale keys still work.
*/
private swallowBehindOverlay(event: KeyboardEvent): void {
const id = this.shortcuts.resolve(event, 'main');
if (id === 'scale-up' || id === 'scale-down' || id === 'scale-reset') {
const claimed = this.shortcuts.claim(event, 'main');
if (claimed) this.runWindowCommand(claimed);
return;
}
const panelId = id ? null : this.shortcuts.resolve(event, 'panel');
if (id || (panelId && NATIVE_MENU_IDS.includes(panelId))) event.preventDefault();
}
private runWindowCommand(id: ShortcutId): void {
const active = this.panels.activeWorkspaceId();
switch (id) {
case 'new-tab': case 'new-tab':
this.panels.newWorkspace(); this.panels.newWorkspace();
break; break;
case 'close-tab': case 'close-tab':
this.panels.closeWorkspace(this.panels.activeWorkspaceId()); this.panels.closeWorkspace(active);
break; break;
case 'cycle-tab': case 'close-other-tabs':
this.panels.closeOtherWorkspaces(active);
break;
case 'close-tabs-right':
this.panels.closeWorkspacesToRight(active);
break;
case 'next-tab':
this.panels.cycleWorkspace(1); this.panels.cycleWorkspace(1);
break; break;
case 'cycle-tab-back': case 'prev-tab':
this.panels.cycleWorkspace(-1); this.panels.cycleWorkspace(-1);
break; break;
case 'go-to-tab': case 'go-to-last-tab':
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox this.panels.goToWorkspace(-1);
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1); break;
case 'move-tab-left':
this.moveActiveTab(-1);
break;
case 'move-tab-right':
this.moveActiveTab(1);
break;
case 'disconnect-tab':
void this.panels.disconnectWorkspace(active);
break; break;
case 'open-settings': case 'open-settings':
void this.rpc.request.openSettings({}); void this.rpc.request.openSettings({});
break; break;
case 'connection-manager':
void this.rpc.request.openConnectionManager({});
break;
case 'toggle-transfers':
this.toggleTransfers();
break;
case 'install-update':
this.updater.advance();
break;
case 'scale-up': case 'scale-up':
this.settings.setUiScale(this.settings.uiScale() + 0.1); this.settings.setUiScale(this.settings.uiScale() + 0.1);
break; break;
@@ -200,10 +263,48 @@ export class App {
case 'scale-reset': case 'scale-reset':
this.settings.setUiScale(1); this.settings.setUiScale(1);
break; break;
default: {
// go-to-tab-1…8: that tab (go-to-last-tab is ⌘9, like Firefox)
const n = this.shortcuts.binding(id)?.n;
if (n) this.panels.goToWorkspace(n - 1);
}
}
}
/** Move-tab keys: the keyboard's tab drag. */
private moveActiveTab(delta: 1 | -1): void {
const list = this.panels.workspaces();
const index = list.findIndex((w) => w.id === this.panels.activeWorkspaceId());
if (index >= 0) this.panels.moveWorkspace(list[index].id, index + delta);
}
/** ⌘J: open the transfer list with focus in it (Esc goes back to the files),
* or close it. It only renders with jobs, so with none there is nothing to show. */
private toggleTransfers(): void {
if (!this.transfers.hasJobs()) return;
const open = !this.transfers.expanded();
this.transfers.expanded.set(open);
if (open) this.transferBar()?.focusFirst();
else this.panels.refocus();
}
/**
* A click on a chrome button keeps keyboard focus where it was — normally the
* file list. WebKit drops focus to <body> on a button click (every list key
* then died until the list was clicked), Windows parks it on the button,
* where Space would click it again. The click itself still fires. Text
* fields, menus, dialogs and the draggable tabs keep their default.
*/
keepListFocus(event: MouseEvent): void {
const target = event.target as Element;
if (
target.closest('button') &&
!target.closest('input, select, textarea, [role=menu], [role=dialog], [draggable=true]')
) {
event.preventDefault();
} }
} }
/** Context-menu actions apply to the row/panel remembered at right-click time. */
/** /**
* Workspace-tab menu actions. The target id travels INSIDE the action string * Workspace-tab menu actions. The target id travels INSIDE the action string
* because the right-clicked tab is not necessarily the active one — nothing * because the right-clicked tab is not necessarily the active one — nothing
@@ -232,55 +333,4 @@ export class App {
break; break;
} }
} }
private handleContextAction(action: string): void {
const target = this.actions.menuTarget;
if (!target) return;
const { panel, side, entry } = target;
// the empty-space menu acts on the directory itself
const path = entry?.path ?? panel.cwd();
switch (action) {
case 'ctx-open':
if (entry) panel.open(entry);
break;
// local shell integration — only bun can reach the OS shell/clipboard
case 'ctx-reveal':
void this.rpc.request.shellItemAction({ action: 'reveal', path });
break;
case 'ctx-open-default':
void this.rpc.request.shellItemAction({ action: 'open', path });
break;
case 'ctx-copy-path':
void this.rpc.request.shellItemAction({ action: 'copyPath', path });
break;
case 'ctx-edit':
void this.actions.edit(panel);
break;
case 'ctx-copy':
void this.transfers.copyToOtherPanel(side);
break;
case 'ctx-rename':
this.actions.rename(panel);
break;
case 'ctx-chmod':
this.actions.chmod(panel);
break;
case 'ctx-delete':
this.actions.delete(panel);
break;
case 'ctx-trash':
if (entry) void this.actions.trash(panel, entry);
break;
case 'ctx-new-folder':
this.actions.mkdir(panel);
break;
case 'ctx-new-file':
this.actions.createFile(panel);
break;
case 'ctx-refresh':
void panel.refresh();
break;
}
}
} }
@@ -9,19 +9,19 @@
[class.h-9]="!isMacChrome" [class.h-9]="!isMacChrome"
[class.pl-3]="!isMacChrome" [class.pl-3]="!isMacChrome"
> >
<ng-icon name="tdesignServer" size="0.95rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="server16" size="var(--icon-size-md)" class="shrink-0 text-muted-foreground" />
<h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1> <h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1>
<div class="flex-1"></div> <div class="flex-1"></div>
<button <button
(click)="importWinScp()" (click)="importWinScp()"
[disabled]="importing()" [disabled]="importing()"
[title]="i18n.t('connMgr.importWinscpHint')" [title]="shortcuts.tip(i18n.t('connMgr.importWinscpHint'), 'conn-import')"
class="flex items-center gap-1.5 px-2 py-1 text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50" class="flex items-center gap-1.5 px-2 py-1 text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50"
> >
@if (importing()) { @if (importing()) {
<ng-icon name="tdesignLoading" size="0.85rem" class="animate-spin" /> <ng-icon name="tdesignLoading" size="var(--icon-size-sm)" class="animate-spin" />
} @else { } @else {
<!-- <ng-icon name="tdesignFileZip" size="0.85rem" /> --> <!-- <ng-icon name="tdesignFileZip" size="var(--icon-size-sm)" /> -->
} }
{{ i18n.t('connMgr.importWinscp') }} {{ i18n.t('connMgr.importWinscp') }}
</button> </button>
@@ -33,21 +33,22 @@
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5"> <div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
<button <button
(click)="newConnection()" (click)="newConnection()"
[title]="i18n.t('connMgr.newConnectionHint')" [title]="shortcuts.tip(i18n.t('connMgr.newConnectionHint'), 'conn-new')"
class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="tdesignAdd" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }} <ng-icon name="add14" size="var(--icon-size-sm)" /> {{ i18n.t('connMgr.newConnection') }}
</button> </button>
<button <button
(click)="newStack()" (click)="newStack()"
[title]="i18n.t('connMgr.newStackHint')" [title]="shortcuts.tip(i18n.t('connMgr.newStackHint'), 'conn-new-stack')"
class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="tdesignFolderAdd" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }} <ng-icon name="folderAdd14" size="var(--icon-size-sm)" /> {{ i18n.t('connMgr.newStack') }}
</button> </button>
</div> </div>
<div class="flex-1 overflow-y-auto py-1"> <!-- ↑ ↓ ← → Home End Enter move through it (onListKeydown) -->
<div #list (keydown)="onListKeydown($event)" class="flex-1 overflow-y-auto py-1">
<!-- stacks --> <!-- stacks -->
@for (group of store.grouped().stacks; track group.stack.id) { @for (group of store.grouped().stacks; track group.stack.id) {
<div <div
@@ -59,50 +60,52 @@
[class.ring-inset]="dropTarget() === group.stack.id" [class.ring-inset]="dropTarget() === group.stack.id"
[class.ring-primary]="dropTarget() === group.stack.id" [class.ring-primary]="dropTarget() === group.stack.id"
> >
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5"> <div [attr.data-stack]="group.stack.id" class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
<button <button
data-row
(click)="toggleStack(group.stack)" (click)="toggleStack(group.stack)"
class="grid size-4 shrink-0 place-items-center text-muted-foreground hover:text-foreground" class="grid size-4 shrink-0 place-items-center text-muted-foreground hover:text-foreground"
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')" [title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
> >
<ng-icon <ng-icon
[name]="group.stack.collapsed ? 'tdesignChevronRight' : 'tdesignChevronDown'" [name]="group.stack.collapsed ? 'chevronRight14' : 'chevronDown14'"
size="0.8rem" size="var(--icon-size-sm)"
/> />
</button> </button>
<span class="truncate text-[0.6875rem] font-medium text-muted-foreground uppercase"> <span class="truncate text-[0.6875rem] font-medium text-muted-foreground uppercase">
{{ group.stack.name }} {{ group.stack.name }}
</span> </span>
<div class="min-w-0 flex-1"></div> <div class="min-w-0 flex-1"></div>
<!-- count and actions swap on hover: they never share the space, <!-- count and actions swap on hover — and when the keyboard is in
so a long stack name can't collide with the buttons --> the row: they never share the space, so a long stack name
can't collide with the buttons -->
<span <span
class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-hover:hidden" class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-focus-within:hidden group-hover:hidden"
> >
{{ group.connections.length }} {{ group.connections.length }}
</span> </span>
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex"> <div class="hidden shrink-0 items-center gap-0.5 group-focus-within:flex group-hover:flex">
<button <button
(click)="renameStack(group.stack)" (click)="renameStack(group.stack)"
[title]="i18n.t('connMgr.renameStack')" [title]="shortcuts.tip(i18n.t('connMgr.renameStack'), 'conn-rename-stack')"
class="grid size-4 place-items-center text-muted-foreground hover:text-foreground" class="grid size-4 place-items-center text-muted-foreground hover:text-foreground"
> >
<ng-icon name="tdesignEdit" size="0.75rem" /> <ng-icon name="edit14" size="var(--icon-size-sm)" />
</button> </button>
<button <button
(click)="deleteStack(group.stack)" (click)="deleteStack(group.stack)"
[title]="i18n.t('connMgr.deleteStack')" [title]="shortcuts.tip(i18n.t('connMgr.deleteStack'), 'conn-delete')"
class="grid size-4 place-items-center text-muted-foreground hover:text-destructive" class="grid size-4 place-items-center text-muted-foreground hover:text-destructive"
> >
<ng-icon name="tdesignDelete" size="0.75rem" /> <ng-icon name="delete14" size="var(--icon-size-sm)" />
</button> </button>
@if (group.connections.length) { @if (group.connections.length) {
<button <button
(click)="openStack(group.stack.id)" (click)="openStack(group.stack.id)"
[title]="i18n.t('connMgr.openStack')" [title]="shortcuts.tip(i18n.t('connMgr.openStack'), 'conn-connect')"
class="grid size-4 place-items-center text-muted-foreground hover:text-primary" class="grid size-4 place-items-center text-muted-foreground hover:text-primary"
> >
<ng-icon name="tdesignArrowRight" size="0.8rem" /> <ng-icon name="arrowRight14" size="var(--icon-size-sm)" />
</button> </button>
} }
</div> </div>
@@ -110,6 +113,8 @@
@if (!group.stack.collapsed) { @if (!group.stack.collapsed) {
@for (connection of group.connections; track connection.id) { @for (connection of group.connections; track connection.id) {
<button <button
data-row
[attr.data-conn]="connection.id"
(click)="edit(connection)" (click)="edit(connection)"
draggable="true" draggable="true"
(dragstart)="onDragStart($event, connection)" (dragstart)="onDragStart($event, connection)"
@@ -118,7 +123,7 @@
[class.bg-accent]="draft()?.id === connection.id" [class.bg-accent]="draft()?.id === connection.id"
[class.opacity-50]="draggedId() === connection.id" [class.opacity-50]="draggedId() === connection.id"
> >
<ng-icon name="tdesignServer" size="0.85rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="server14" size="var(--icon-size-sm)" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span> <span class="truncate">{{ connection.name }}</span>
</button> </button>
} @empty { } @empty {
@@ -146,6 +151,8 @@
} }
@for (connection of store.grouped().loose; track connection.id) { @for (connection of store.grouped().loose; track connection.id) {
<button <button
data-row
[attr.data-conn]="connection.id"
(click)="edit(connection)" (click)="edit(connection)"
draggable="true" draggable="true"
(dragstart)="onDragStart($event, connection)" (dragstart)="onDragStart($event, connection)"
@@ -154,7 +161,7 @@
[class.bg-accent]="draft()?.id === connection.id" [class.bg-accent]="draft()?.id === connection.id"
[class.opacity-50]="draggedId() === connection.id" [class.opacity-50]="draggedId() === connection.id"
> >
<ng-icon name="tdesignServer" size="0.85rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="server14" size="var(--icon-size-sm)" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span> <span class="truncate">{{ connection.name }}</span>
</button> </button>
} }
@@ -174,10 +181,15 @@
<!-- edit form --> <!-- edit form -->
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
@if (draft(); as d) { @if (draft(); as d) {
<div class="grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5"> <!-- Enter in a field = Connect, the primary button -->
<div
(keydown.enter)="onFormEnter($event)"
class="grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5"
>
<label class="col-span-2 flex flex-col gap-1 text-xs"> <label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span> <span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
<input <input
#nameBox
[value]="d.name" [value]="d.name"
(input)="patch('name', $any($event.target).value)" (input)="patch('name', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.namePlaceholder')" [placeholder]="i18n.t('connMgr.namePlaceholder')"
@@ -327,9 +339,10 @@
<div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5"> <div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5">
<button <button
(click)="deleteCurrent()" (click)="deleteCurrent()"
[title]="shortcuts.tip(i18n.t('common.delete'), 'conn-delete')"
class="flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive" class="flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
> >
<ng-icon name="tdesignDelete" size="0.85rem" /> {{ i18n.t('common.delete') }} <ng-icon name="delete14" size="var(--icon-size-sm)" /> {{ i18n.t('common.delete') }}
</button> </button>
<button <button
(click)="moreOpen.set(!moreOpen())" (click)="moreOpen.set(!moreOpen())"
@@ -341,6 +354,7 @@
<button <button
(click)="save()" (click)="save()"
[disabled]="!valid()" [disabled]="!valid()"
[title]="shortcuts.tip(i18n.t('common.save'), 'conn-save')"
class="px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50" class="px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
> >
{{ i18n.t('common.save') }} {{ i18n.t('common.save') }}
@@ -348,10 +362,11 @@
<button <button
(click)="saveAndConnect()" (click)="saveAndConnect()"
[disabled]="!valid() || busy()" [disabled]="!valid() || busy()"
[title]="shortcuts.tip(i18n.t('common.connect'), 'conn-connect')"
class="flex items-center gap-1.5 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" class="flex items-center gap-1.5 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
@if (busy()) { @if (busy()) {
<ng-icon name="tdesignLoading" size="0.85rem" class="animate-spin" /> <ng-icon name="tdesignLoading" size="var(--icon-size-sm)" class="animate-spin" />
} }
{{ i18n.t('common.connect') }} {{ i18n.t('common.connect') }}
</button> </button>
@@ -359,7 +374,7 @@
} @else { } @else {
<div class="grid flex-1 place-items-center"> <div class="grid flex-1 place-items-center">
<div class="text-center"> <div class="text-center">
<ng-icon name="tdesignServer" size="1.75rem" class="text-muted-foreground" /> <ng-icon name="server24" size="var(--icon-size-xl)" class="text-muted-foreground" />
<p class="mt-2 text-xs text-muted-foreground"> <p class="mt-2 text-xs text-muted-foreground">
{{ i18n.t('connMgr.selectPrompt') }} {{ i18n.t('connMgr.selectPrompt') }}
</p> </p>
@@ -1,4 +1,14 @@
import { Component, computed, inject, signal } from '@angular/core'; import {
Component,
ElementRef,
Injector,
afterNextRender,
computed,
effect,
inject,
signal,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import type { SavedConnection, Stack } from '@shared/types'; import type { SavedConnection, Stack } from '@shared/types';
@@ -6,10 +16,12 @@ import { ConnectionsService } from '@core/connections.service';
import { ConfirmDialog } from '../dialogs/confirm-dialog'; import { ConfirmDialog } from '../dialogs/confirm-dialog';
import { PromptDialog } from '../dialogs/prompt-dialog'; import { PromptDialog } from '../dialogs/prompt-dialog';
import { DialogsService } from '@core/dialogs.service'; import { DialogsService } from '@core/dialogs.service';
import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform'; import { cycleFocus } from '@core/focus-cycle';
import { FocusService } from '@core/focus.service';
import { blockNativeContextMenu, blockReloadKeys, 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, type ShortcutId } from '@core/shortcuts.service';
import { Toasts } from '../toasts/toasts'; import { Toasts } from '../toasts/toasts';
interface Draft { interface Draft {
@@ -76,8 +88,8 @@ function fromDraft(d: Draft): SavedConnection {
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts], imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts],
templateUrl: './connection-manager.html', templateUrl: './connection-manager.html',
host: { host: {
'(window:keydown.escape)': 'onEscape()',
'(window:keydown)': 'onWindowKeydown($event)', '(window:keydown)': 'onWindowKeydown($event)',
'(window:focusin)': 'rememberFocus($event)',
'(window:contextmenu)': 'blockContextMenu($event)', '(window:contextmenu)': 'blockContextMenu($event)',
}, },
}) })
@@ -90,7 +102,13 @@ export class ConnectionManager {
// injected for its side effects: fetches settings and applies theme/accent/ // injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root // text-size data attributes to this window's document root
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService); readonly shortcuts = inject(ShortcutsService);
private readonly focus = inject(FocusService);
private readonly injector = inject(Injector);
private readonly list = viewChild.required<ElementRef<HTMLElement>>('list');
private readonly nameBox = viewChild<ElementRef<HTMLInputElement>>('nameBox');
/** Where focus was outside a dialog, to come back to when it closes. */
private lastFocus: HTMLElement | null = null;
readonly draft = signal<Draft | null>(null); readonly draft = signal<Draft | null>(null);
readonly busy = signal(false); readonly busy = signal(false);
@@ -102,6 +120,27 @@ export class ConnectionManager {
/** Highlighted drop target: a stack id, 'loose', or null. */ /** Highlighted drop target: a stack id, 'loose', or null. */
readonly dropTarget = signal<string | null>(null); readonly dropTarget = signal<string | null>(null);
constructor() {
// the keyboard starts in the list — and lands there again whenever focus
// fell to <body> (the store loads after the first render; a deleted row
// takes its focus with it)
effect(() => {
this.store.grouped();
afterNextRender(() => this.focusFallback(), { injector: this.injector });
});
// dialogs bump FocusService when they close: return to where the user was
effect(() => {
this.focus.tick();
afterNextRender(
() => {
if (this.lastFocus?.isConnected) this.lastFocus.focus();
else this.focusFallback();
},
{ injector: this.injector },
);
});
}
readonly valid = computed(() => { readonly valid = computed(() => {
const d = this.draft(); const d = this.draft();
if (!d) return false; if (!d) return false;
@@ -141,19 +180,174 @@ export class ConnectionManager {
void this.rpc.request.managerClose({}); void this.rpc.request.managerClose({});
} }
onEscape(): void { /**
// in-window dialogs (confirm/prompt) handle their own Escape first * This window's keys (the `manager` scope), then the fixed ones: Esc closes
if (!this.dialogs.anyOpen) this.close(); * the window — from a text field it first just leaves the field, so a
* half-typed connection isn't thrown away — and Tab is walked by hand
* (WKWebView skips buttons and radios on Tab otherwise). The dialogs take
* their own Esc and stop it, so it never reaches here.
*/
onWindowKeydown(event: KeyboardEvent): void {
const id = this.shortcuts.claim(event, 'manager');
if (id) this.runCommand(id);
else if (event.key === 'Escape' && !this.dialogs.anyOpen) this.onEscape(event);
else cycleFocus(document.body, event);
blockReloadKeys(event);
} }
/** Any-window shortcuts (interface scale). Escape has its own binding above. */ private onEscape(event: KeyboardEvent): void {
onWindowKeydown(event: KeyboardEvent): void {
const match = this.shortcuts.resolve(event, 'manager');
if (!match || this.shortcuts.isTypingTarget(event)) return;
event.preventDefault(); event.preventDefault();
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1); if (this.shortcuts.isTypingTarget(event)) {
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1); (event.target as HTMLElement).blur();
else if (match.id === 'scale-reset') this.settings.setUiScale(1); this.focusFallback(true);
} else {
this.close();
}
}
private runCommand(id: ShortcutId): void {
const row = this.focusedRow();
switch (id) {
case 'close-window':
this.close();
break;
case 'conn-new':
this.newConnection();
this.focusName();
break;
case 'conn-new-stack':
this.newStack();
break;
case 'conn-save':
void this.save();
break;
case 'conn-connect':
// the focused stack opens in tabs; a focused connection, or the form, connects
if (row?.stack) this.openStack(row.stack.id);
else {
if (row?.connection) this.edit(row.connection);
void this.saveAndConnect();
}
break;
case 'conn-delete':
if (row?.stack) this.deleteStack(row.stack);
else {
if (row?.connection) this.edit(row.connection);
this.deleteCurrent();
}
break;
case 'conn-rename-stack':
if (row?.stack) this.renameStack(row.stack);
break;
case 'conn-import':
void this.importWinScp();
break;
case 'scale-up':
this.settings.setUiScale(this.settings.uiScale() + 0.1);
break;
case 'scale-down':
this.settings.setUiScale(this.settings.uiScale() - 0.1);
break;
case 'scale-reset':
this.settings.setUiScale(1);
break;
}
}
// ---- keyboard: the list ----
/** The stack or connection row the focus sits in (its buttons included). */
private focusedRow(): { stack?: Stack; connection?: SavedConnection } | null {
const el = document.activeElement as HTMLElement | null;
const stackId = el?.closest<HTMLElement>('[data-stack]')?.dataset['stack'];
if (stackId) {
const stack = this.store.stacks().find((s) => s.id === stackId);
return stack ? { stack } : null;
}
const connId = el?.closest<HTMLElement>('[data-conn]')?.dataset['conn'];
const connection = connId && this.store.connections().find((c) => c.id === connId);
return connection ? { connection } : null;
}
private rows(): HTMLElement[] {
return [...this.list().nativeElement.querySelectorAll<HTMLElement>('[data-row]')];
}
/**
* ↑ ↓ Home End move between rows (focus only — loading a connection would
* replace the form without asking); ← / → collapse and expand a stack, ← on
* a connection goes up to its stack; Enter on a connection opens it in the
* form (Enter / Space on a stack toggles it, natively).
*/
onListKeydown(event: KeyboardEvent): void {
if (event.metaKey || event.ctrlKey || event.altKey) return;
const rows = this.rows();
const at = rows.indexOf(document.activeElement as HTMLElement);
const row = this.focusedRow();
let target: HTMLElement | undefined;
switch (event.key) {
case 'ArrowDown':
target = rows[Math.min(rows.length - 1, at + 1)];
break;
case 'ArrowUp':
target = rows[Math.max(0, at - 1)];
break;
case 'Home':
target = rows[0];
break;
case 'End':
target = rows.at(-1);
break;
case 'ArrowLeft':
if (row?.stack && !row.stack.collapsed) this.toggleStack(row.stack);
else if (row?.connection?.stackId) target = this.stackRow(row.connection.stackId);
else return;
break;
case 'ArrowRight':
if (row?.stack?.collapsed) this.toggleStack(row.stack);
else return;
break;
case 'Enter':
if (!row?.connection) return;
this.edit(row.connection);
this.focusName();
break;
default:
return;
}
event.preventDefault();
target?.focus();
}
private stackRow(stackId: string): HTMLElement | undefined {
return this.rows().find((el) => el.closest<HTMLElement>('[data-stack]')?.dataset['stack'] === stackId);
}
/** Enter in a form field = Connect, the primary button (not mid-IME). */
onFormEnter(event: Event): void {
const key = event as KeyboardEvent;
const target = key.target as HTMLElement;
if (key.isComposing || !target.matches('input:not([type=radio]):not([type=checkbox])')) return;
key.preventDefault();
if (this.valid()) void this.saveAndConnect();
}
rememberFocus(event: FocusEvent): void {
const target = event.target as HTMLElement;
if (target instanceof HTMLElement && !target.closest('[role=dialog]')) this.lastFocus = target;
}
/** Focus the edited connection's row, else the first row — only when focus
* is nowhere (on <body>), unless forced. */
private focusFallback(force = false): void {
if (!force && document.activeElement !== document.body) return;
const id = this.draft()?.id;
const own = id ? this.rows().find((el) => el.closest<HTMLElement>('[data-conn]')?.dataset['conn'] === id) : undefined;
(own ?? this.rows()[0])?.focus();
}
private focusName(): void {
afterNextRender(() => this.nameBox()?.nativeElement.focus(), { injector: this.injector });
} }
blockContextMenu(event: Event): void { blockContextMenu(event: Event): void {
+80 -34
View File
@@ -8,10 +8,11 @@ import {
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { ContextMenuService, type MenuItem } from '@core/context-menu.service'; import { ContextMenuService, isSelectable, type MenuItem } from '@core/context-menu.service';
import { FocusService } from '@core/focus.service'; import { FocusService } from '@core/focus.service';
import { stepIndex } from '@core/menu-nav'; import { stepIndex } from '@core/menu-nav';
import { remPx } from '@core/metrics'; import { remPx } from '@core/metrics';
import { ShortcutsService } from '@core/shortcuts.service';
/** Rough per-item metrics in REM, used only to decide whether the menu must /** Rough per-item metrics in REM, used only to decide whether the menu must
* flip. Converted through remPx() at open time so the math tracks the same * flip. Converted through remPx() at open time so the math tracks the same
@@ -20,13 +21,19 @@ import { remPx } from '@core/metrics';
* scale while the flip math still believed 224 and fired at the wrong edge. */ * scale while the flip math still believed 224 and fired at the wrong edge. */
const ITEM_H_REM = 1.625; // px-3 py-1 row + --path-font line ≈ 26px @16 const ITEM_H_REM = 1.625; // px-3 py-1 row + --path-font line ≈ 26px @16
const SEPARATOR_H_REM = 0.4375; // my-1 + 1px border ≈ 7px @16 const SEPARATOR_H_REM = 0.4375; // my-1 + 1px border ≈ 7px @16
const MENU_W_REM = 14; // w-56 const HEADER_H_REM = 1.25; // pt-1.5 pb-0.5 + the 0.625rem caps line
const MENU_W_REM = 15; // w-60
const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
/** /**
* The panel right-click menu. Replaces electrobun's native ContextMenu, which * The HTML context menu (rows, tabs, column headers, the panel source picker).
* is a no-op on Linux — see ContextMenuService for the why. Emits the same * Replaces electrobun's native ContextMenu, which is a no-op on Linux — see
* `ctx-*` / `col:*` action strings the native menu did. * ContextMenuService for the why.
*
* Keyboard: ↑↓ Home End move one highlight shared with the mouse, Enter/Space
* pick, Esc closes, and an item's own shortcut picks it while the menu is open
* (F2 renames from an open row menu). Every key it handles stops here, so the
* window's shortcut handler never runs it a second time.
*/ */
@Component({ @Component({
selector: 'app-context-menu', selector: 'app-context-menu',
@@ -43,15 +50,21 @@ const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
(keydown)="onKeydown($event)" (keydown)="onKeydown($event)"
[style.left.px]="position().x" [style.left.px]="position().x"
[style.top.px]="position().y" [style.top.px]="position().y"
class="fixed z-50 w-56 border border-border/60 bg-chrome py-1 text-foreground shadow-xl outline-none" [style.max-height.px]="position().maxHeight"
class="fixed z-50 w-60 overflow-y-auto border border-border/60 bg-chrome py-1 text-foreground shadow-xl outline-none"
> >
@for (item of req.items; track $index) { @for (item of req.items; track $index) {
@if (item.type === 'separator') { @if (item.type === 'separator') {
<div class="my-1 border-t border-border/60"></div> <div class="my-1 border-t border-border/60"></div>
} @else if (item.type === 'header') {
<div class="truncate px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
{{ item.label }}
</div>
} @else { } @else {
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
tabindex="-1"
[attr.id]="'ctx-item-' + $index" [attr.id]="'ctx-item-' + $index"
[title]="item.tooltip ?? ''" [title]="item.tooltip ?? ''"
[disabled]="!!item.disabled" [disabled]="!!item.disabled"
@@ -61,14 +74,22 @@ const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
(click)="pick(item)" (click)="pick(item)"
class="flex w-full items-center gap-2 px-3 py-1 text-left text-(length:--path-font) disabled:pointer-events-none disabled:opacity-50" class="flex w-full items-center gap-2 px-3 py-1 text-left text-(length:--path-font) disabled:pointer-events-none disabled:opacity-50"
> >
<span class="w-3.5 shrink-0"> <!-- the check column: a checkmark, else the item's icon -->
<span class="grid w-4 shrink-0 place-items-center">
@if (item.checked) { @if (item.checked) {
<ng-icon name="tdesignCheck" size="0.85rem" class="text-primary" /> <ng-icon name="check14" size="var(--icon-size-sm)" class="text-primary" />
} @else if (item.icon) {
<ng-icon
[name]="item.icon"
size="var(--icon-size-sm)"
[class.text-muted-foreground]="$index !== activeIndex() || !!item.disabled"
/>
} }
</span> </span>
<span class="flex-1 truncate">{{ item.label }}</span> <span class="flex-1 truncate">{{ item.label }}</span>
@if (item.accelerator) { @if (item.shortcut && shortcuts.label(item.shortcut); as hint) {
<span class="shrink-0 text-muted-foreground">{{ item.accelerator }}</span> <!-- soft: the row's own colour at 60%, so it turns soft green with the highlight -->
<span class="ml-4 shrink-0 opacity-60">{{ hint }}</span>
} }
</button> </button>
} }
@@ -86,30 +107,37 @@ const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
}) })
export class ContextMenu { export class ContextMenu {
readonly menu = inject(ContextMenuService); readonly menu = inject(ContextMenuService);
readonly shortcuts = inject(ShortcutsService);
private readonly focus = inject(FocusService); private readonly focus = inject(FocusService);
private readonly menuEl = viewChild<ElementRef<HTMLDivElement>>('menuEl'); private readonly menuEl = viewChild<ElementRef<HTMLDivElement>>('menuEl');
readonly activeIndex = signal(-1); readonly activeIndex = signal(-1);
/** Click point, flipped/clamped so the menu never overflows the viewport. /** Click point, flipped/clamped so the menu never overflows the viewport.
* All UI metrics convert through the live root font-size; window.inner* is * A menu taller than the viewport (a long source picker) scrolls inside a
* viewport CSS px, so both sides of every comparison share units. */ * clamped box. All UI metrics convert through the live root font-size;
* window.inner* is viewport CSS px, so both sides of every comparison share
* units. */
readonly position = computed(() => { readonly position = computed(() => {
const req = this.menu.request(); const req = this.menu.request();
if (!req) return { x: 0, y: 0 }; if (!req) return { x: 0, y: 0, maxHeight: 0 };
const itemH = remPx(ITEM_H_REM); const heights = {
const separatorH = remPx(SEPARATOR_H_REM); separator: remPx(SEPARATOR_H_REM),
header: remPx(HEADER_H_REM),
item: remPx(ITEM_H_REM),
};
const content =
req.items.reduce((sum, i) => sum + heights[i.type ?? 'item'], 0) + remPx(0.5); // py-1 ×2
const maxHeight = window.innerHeight - 2 * EDGE_GAP;
const height = Math.min(content, maxHeight);
const menuW = remPx(MENU_W_REM); const menuW = remPx(MENU_W_REM);
const height =
req.items.reduce((sum, i) => sum + (i.type === 'separator' ? separatorH : itemH), 0) +
remPx(0.5); // the menu's py-1 (0.25rem x2)
const x = const x =
req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : req.x; req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : req.x;
const y = const y =
req.y + height > window.innerHeight - EDGE_GAP req.y + height > window.innerHeight - EDGE_GAP
? Math.max(EDGE_GAP, req.y - height) ? Math.max(EDGE_GAP, req.y - height)
: req.y; : req.y;
return { x, y }; return { x, y, maxHeight };
}); });
constructor() { constructor() {
@@ -117,22 +145,25 @@ export class ContextMenu {
// the viewChild signal, NOT on request(): under zoneless CD an effect can // the viewChild signal, NOT on request(): under zoneless CD an effect can
// run before the @if has rendered the element. // run before the @if has rendered the element.
effect(() => this.menuEl()?.nativeElement.focus()); effect(() => this.menuEl()?.nativeElement.focus());
// a fresh menu starts with nothing highlighted — or, opened from the
// keyboard, with its first item
effect(() => this.activeIndex.set(this.menu.request()?.initialIndex ?? -1));
} }
close(): void { close(): void {
if (!this.menu.request()) return; if (!this.menu.request()) return;
this.menu.close(); this.menu.close();
this.activeIndex.set(-1); // a fresh menu starts with nothing highlighted
this.focus.bump(); this.focus.bump();
} }
pick(item: MenuItem): void { pick(item: MenuItem): void {
// the keyboard path never consults the disabled attribute — guard here // the keyboard path never consults the disabled attribute — guard here
if (item.disabled) return; if (item.disabled || !item.action) return;
if (!item.action) return;
const action = item.action; const action = item.action;
const onPick = this.menu.request()?.onPick;
this.close(); this.close();
this.menu.onPick(action); if (onPick) onPick(action);
else this.menu.emit(action);
} }
/** Right-clicking the backdrop closes rather than stacking a second menu. */ /** Right-clicking the backdrop closes rather than stacking a second menu. */
@@ -143,33 +174,48 @@ export class ContextMenu {
onKeydown(event: KeyboardEvent): void { onKeydown(event: KeyboardEvent): void {
const items = this.menu.request()?.items ?? []; const items = this.menu.request()?.items ?? [];
const selectable = items.map(isSelectable);
switch (event.key) { switch (event.key) {
case 'Escape': case 'Escape':
event.preventDefault();
event.stopPropagation();
this.close(); this.close();
break; break;
case 'ArrowDown': case 'ArrowDown':
event.preventDefault(); this.highlight(stepIndex(selectable, this.activeIndex(), 1));
this.step(items, 1);
break; break;
case 'ArrowUp': case 'ArrowUp':
event.preventDefault(); this.highlight(stepIndex(selectable, this.activeIndex(), -1));
this.step(items, -1); break;
case 'Home':
this.highlight(stepIndex(selectable, -1, 1));
break;
case 'End':
this.highlight(stepIndex(selectable, -1, -1));
break; break;
case 'Enter': case 'Enter':
case ' ': { case ' ': {
event.preventDefault();
const item = items[this.activeIndex()]; const item = items[this.activeIndex()];
if (item) this.pick(item); if (item) this.pick(item);
break; break;
} }
case 'Tab':
// focus stays on the menu: the items are not Tab stops
break;
default: {
// an item's own shortcut picks it, as in a native menu
const hit = items.find(
(i) => isSelectable(i) && i.shortcut && this.shortcuts.matches(i.shortcut, event),
);
if (!hit) return; // not ours: let it bubble
this.pick(hit);
}
} }
event.preventDefault();
event.stopPropagation();
} }
/** Move the highlight by one selectable item (see stepIndex). */ /** Move the highlight and keep it in view (the menu may scroll). */
private step(items: MenuItem[], delta: 1 | -1): void { private highlight(index: number): void {
const selectable = items.map((i) => i.type !== 'separator' && !i.disabled); this.activeIndex.set(index);
this.activeIndex.set(stepIndex(selectable, this.activeIndex(), delta)); document.getElementById(`ctx-item-${index}`)?.scrollIntoView({ block: 'nearest' });
} }
} }
@@ -1,6 +1,16 @@
import { Component, computed, inject, input, signal } from '@angular/core'; import {
Component,
ElementRef,
afterNextRender,
computed,
inject,
input,
signal,
viewChild,
} from '@angular/core';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import { DialogsService, type ChmodRequest } from '@core/dialogs.service'; import { DialogsService, type ChmodRequest } from '@core/dialogs.service';
import { cycleFocus } from '@core/focus-cycle';
import { FocusService } from '@core/focus.service'; import { FocusService } from '@core/focus.service';
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const; const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
@@ -11,9 +21,12 @@ const PERMS = ['r', 'w', 'x'] as const;
template: ` template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()"> <div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div <div
role="dialog"
aria-modal="true"
[attr.aria-label]="i18n.t('dialogs.chmodTitle')"
class="w-80 border border-border bg-card p-4 text-card-foreground shadow-xl" class="w-80 border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()" (mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()" (keydown)="onKeydown($event)"
> >
<h2 class="text-sm font-semibold">{{ i18n.t('dialogs.chmodTitle') }}</h2> <h2 class="text-sm font-semibold">{{ i18n.t('dialogs.chmodTitle') }}</h2>
<p class="mt-1 truncate text-xs text-muted-foreground">{{ targetLabel() }}</p> <p class="mt-1 truncate text-xs text-muted-foreground">{{ targetLabel() }}</p>
@@ -49,6 +62,7 @@ const PERMS = ['r', 'w', 'x'] as const;
<div class="mt-3 flex items-center gap-2"> <div class="mt-3 flex items-center gap-2">
<label class="text-xs text-muted-foreground">{{ i18n.t('dialogs.octal') }}</label> <label class="text-xs text-muted-foreground">{{ i18n.t('dialogs.octal') }}</label>
<input <input
#octalBox
[value]="octal()" [value]="octal()"
(input)="setOctal($any($event.target).value)" (input)="setOctal($any($event.target).value)"
maxlength="4" maxlength="4"
@@ -84,6 +98,32 @@ export class ChmodDialog {
private readonly dialogs = inject(DialogsService); private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService); private readonly focus = inject(FocusService);
private readonly octalBox = viewChild.required<ElementRef<HTMLInputElement>>('octalBox');
constructor() {
// typing the octal is the fast path; the checkboxes are a Tab away
afterNextRender(() => {
const el = this.octalBox().nativeElement;
el.focus();
el.select();
});
}
/** Esc cancels (and stops here); Tab stays inside; Enter applies — except on
* a button, which Enter clicks natively, and mid IME composition. */
onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.cancel();
} else if (event.key === 'Enter') {
if (event.isComposing || (event.target as HTMLElement).closest('button')) return;
event.preventDefault();
this.submit();
} else {
cycleFocus(event.currentTarget as Element, event);
}
}
// mode is initialized lazily from the request on first read // mode is initialized lazily from the request on first read
private readonly modeOverride = signal<number | null>(null); private readonly modeOverride = signal<number | null>(null);
@@ -1,6 +1,7 @@
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core'; import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import { DialogsService, type ConfirmRequest } from '@core/dialogs.service'; import { DialogsService, type ConfirmRequest } from '@core/dialogs.service';
import { cycleFocus } from '@core/focus-cycle';
import { FocusService } from '@core/focus.service'; import { FocusService } from '@core/focus.service';
@Component({ @Component({
@@ -8,9 +9,12 @@ import { FocusService } from '@core/focus.service';
template: ` template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()"> <div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div <div
role="dialog"
aria-modal="true"
[attr.aria-label]="request().title"
class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl" class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()" (mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()" (keydown)="onKeydown($event)"
> >
<h2 class="text-sm font-semibold">{{ request().title }}</h2> <h2 class="text-sm font-semibold">{{ request().title }}</h2>
<p class="mt-2 text-xs text-muted-foreground">{{ request().body }}</p> <p class="mt-2 text-xs text-muted-foreground">{{ request().body }}</p>
@@ -49,6 +53,17 @@ export class ConfirmDialog {
afterNextRender(() => this.confirmBtn().nativeElement.focus()); afterNextRender(() => this.confirmBtn().nativeElement.focus());
} }
/** Esc cancels (and stops here — see PromptDialog); Tab stays inside. */
onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.cancel();
} else {
cycleFocus(event.currentTarget as Element, event);
}
}
submit(): void { submit(): void {
const req = this.request(); const req = this.request();
this.close(); this.close();
@@ -1,7 +1,7 @@
import { import {
Component, Component,
ElementRef, ElementRef,
afterNextRender, afterRenderEffect,
computed, computed,
inject, inject,
input, input,
@@ -10,6 +10,7 @@ import {
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service'; import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service';
import { cycleFocus } from '@core/focus-cycle';
import { FocusService } from '@core/focus.service'; import { FocusService } from '@core/focus.service';
@Component({ @Component({
@@ -18,9 +19,11 @@ import { FocusService } from '@core/focus.service';
template: ` template: `
<div <div
class="fixed inset-0 z-40 grid place-items-center bg-black/40" class="fixed inset-0 z-40 grid place-items-center bg-black/40"
(keydown.escape)="pick('cancel')" (keydown)="onKeydown($event)"
> >
<div <div
role="dialog"
aria-modal="true"
class="w-[26rem] border border-border bg-card p-4 text-card-foreground shadow-xl" class="w-[26rem] border border-border bg-card p-4 text-card-foreground shadow-xl"
> >
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
@@ -60,8 +63,8 @@ import { FocusService } from '@core/focus.service';
</div> </div>
<div class="mt-1.5 flex items-center gap-1.5 text-xs"> <div class="mt-1.5 flex items-center gap-1.5 text-xs">
<ng-icon <ng-icon
[name]="isDir() ? 'tdesignFolder' : 'tdesignFile'" [name]="isDir() ? 'folder14' : 'file14'"
size="0.9rem" size="var(--icon-size-sm)"
class="shrink-0 text-muted-foreground" class="shrink-0 text-muted-foreground"
/> />
<span class="font-mono tabular-nums"> <span class="font-mono tabular-nums">
@@ -127,7 +130,23 @@ export class OverwriteDialog {
private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn'); private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn');
constructor() { constructor() {
afterNextRender(() => this.primaryBtn().nativeElement.focus()); // per QUESTION, not per component: a run of collisions reuses this one
// instance (the signal goes null → next inside one change detection)
afterRenderEffect(() => {
this.request();
this.primaryBtn().nativeElement.focus();
});
}
/** Esc cancels the whole run (and stops here); Tab stays inside. */
onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.pick('cancel');
} else {
cycleFocus(event.currentTarget as Element, event);
}
} }
readonly isDir = computed(() => this.request().srcEntry.type === 'dir'); readonly isDir = computed(() => this.request().srcEntry.type === 'dir');
@@ -9,6 +9,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import { DialogsService, type PromptRequest } from '@core/dialogs.service'; import { DialogsService, type PromptRequest } from '@core/dialogs.service';
import { cycleFocus } from '@core/focus-cycle';
import { FocusService } from '@core/focus.service'; import { FocusService } from '@core/focus.service';
@Component({ @Component({
@@ -16,16 +17,19 @@ import { FocusService } from '@core/focus.service';
template: ` template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()"> <div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div <div
role="dialog"
aria-modal="true"
[attr.aria-label]="request().title"
class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl" class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()" (mousedown)="$event.stopPropagation()"
(keydown)="onKeydown($event)"
> >
<h2 class="text-sm font-semibold">{{ request().title }}</h2> <h2 class="text-sm font-semibold">{{ request().title }}</h2>
<input <input
#box #box
[value]="value()" [value]="value()"
(input)="value.set($any($event.target).value)" (input)="value.set($any($event.target).value)"
(keydown.enter)="submit()" (keydown.enter)="onEnter($event)"
(keydown.escape)="cancel()"
[placeholder]="request().placeholder ?? ''" [placeholder]="request().placeholder ?? ''"
spellcheck="false" spellcheck="false"
class="mt-3 h-8 w-full border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring" class="mt-3 h-8 w-full border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring"
@@ -67,6 +71,25 @@ export class PromptDialog {
}); });
} }
/** Esc anywhere in the dialog cancels; Tab stays inside it. Esc stops here:
* a window-level Escape (the connection manager closes on it) must not see
* the same key. */
onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.cancel();
} else {
cycleFocus(event.currentTarget as Element, event);
}
}
/** Enter submits — but not the Enter that commits an IME candidate. */
onEnter(event: Event): void {
if ((event as KeyboardEvent).isComposing) return;
this.submit();
}
submit(): void { submit(): void {
const value = this.value().trim(); const value = this.value().trim();
if (!value) return; if (!value) return;
@@ -2,7 +2,7 @@
@if (!ctx) { @if (!ctx) {
<div class="flex flex-1 items-center justify-center"> <div class="flex flex-1 items-center justify-center">
<div class="max-w-sm border border-border bg-card p-6 text-center shadow-sm"> <div class="max-w-sm border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="tdesignCloseCircle" size="1.5rem" class="text-destructive" /> <ng-icon name="closeCircle24" size="var(--icon-size-xl)" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p> <p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground"> <p class="mt-1 text-xs text-muted-foreground">
{{ i18n.t('editor.noFileBody') }} {{ i18n.t('editor.noFileBody') }}
@@ -20,7 +20,7 @@
[class.h-9]="!isMacChrome" [class.h-9]="!isMacChrome"
[class.pl-3]="!isMacChrome" [class.pl-3]="!isMacChrome"
> >
<ng-icon name="tdesignFile1" size="0.95rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="fileText16" size="var(--icon-size-md)" class="shrink-0 text-muted-foreground" />
<h1 class="min-w-0 truncate font-mono text-sm font-semibold"> <h1 class="min-w-0 truncate font-mono text-sm font-semibold">
{{ ctx.name }}{{ dirty() ? ' •' : '' }} {{ ctx.name }}{{ dirty() ? ' •' : '' }}
</h1> </h1>
@@ -28,7 +28,7 @@
<div class="flex-1"></div> <div class="flex-1"></div>
<button <button
(click)="toggleWrap()" (click)="toggleWrap()"
[title]="i18n.t('editor.wrapHint', { alt: altLabel })" [title]="shortcuts.tip(i18n.t('editor.wrapHint'), 'toggle-wrap')"
class="px-2 py-1 text-[0.6875rem] electrobun-webkit-app-region-no-drag hover:bg-accent" class="px-2 py-1 text-[0.6875rem] electrobun-webkit-app-region-no-drag hover:bg-accent"
[class.bg-accent]="wrap()" [class.bg-accent]="wrap()"
[class.text-foreground]="wrap()" [class.text-foreground]="wrap()"
@@ -38,16 +38,16 @@
</button> </button>
<button <button
(click)="openFind()" (click)="openFind()"
[title]="i18n.t('editor.findHint', { mod: modLabel })" [title]="shortcuts.tip(i18n.t('editor.findHint'), 'find')"
class="grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground" class="grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
> >
<ng-icon name="tdesignSearch" size="0.85rem" /> <ng-icon name="search14" size="var(--icon-size-sm)" />
</button> </button>
</header> </header>
@if (findOpen()) { @if (findOpen()) {
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-toolbar px-3 py-1.5"> <div class="flex shrink-0 items-center gap-2 border-b border-border bg-toolbar px-3 py-1.5">
<ng-icon name="tdesignSearch" size="0.85rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="search14" size="var(--icon-size-sm)" class="shrink-0 text-muted-foreground" />
<input <input
#findBox #findBox
[value]="findQuery()" [value]="findQuery()"
@@ -66,17 +66,17 @@
</span> </span>
<button <button
(click)="findStep(-1)" (click)="findStep(-1)"
[title]="i18n.t('editor.prevMatch')" [title]="shortcuts.tipFixed(i18n.t('editor.prevMatch'), 'Shift+Enter')"
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="tdesignChevronUp" size="0.8rem" /> <ng-icon name="chevronUp14" size="var(--icon-size-sm)" />
</button> </button>
<button <button
(click)="findStep(1)" (click)="findStep(1)"
[title]="i18n.t('editor.nextMatch')" [title]="shortcuts.tipFixed(i18n.t('editor.nextMatch'), 'Enter')"
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="tdesignChevronDown" size="0.8rem" /> <ng-icon name="chevronDown14" size="var(--icon-size-sm)" />
</button> </button>
<div class="flex-1"></div> <div class="flex-1"></div>
<button <button
@@ -84,7 +84,7 @@
[title]="i18n.t('editor.closeHint')" [title]="i18n.t('editor.closeHint')"
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="tdesignClose" size="0.8rem" /> <ng-icon name="close14" size="var(--icon-size-sm)" />
</button> </button>
</div> </div>
} }
@@ -93,19 +93,19 @@
<div <div
class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[0.6875rem] text-destructive" class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[0.6875rem] text-destructive"
> >
<ng-icon name="tdesignCloseCircle" size="0.85rem" class="shrink-0" /> <ng-icon name="closeCircle14" size="var(--icon-size-sm)" class="shrink-0" />
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }} {{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
</div> </div>
} }
@if (loading()) { @if (loading()) {
<div class="flex flex-1 items-center justify-center"> <div class="flex flex-1 items-center justify-center">
<ng-icon name="tdesignLoading" size="1.25rem" class="animate-spin text-primary" /> <ng-icon name="tdesignLoading" size="var(--icon-size-lg)" class="animate-spin text-primary" />
</div> </div>
} @else if (loadError(); as error) { } @else if (loadError(); as error) {
<div class="flex flex-1 items-center justify-center"> <div class="flex flex-1 items-center justify-center">
<div class="max-w-md border border-border bg-card p-6 text-center shadow-sm"> <div class="max-w-md border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="tdesignCloseCircle" size="1.5rem" class="text-destructive" /> <ng-icon name="closeCircle24" size="var(--icon-size-xl)" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p> <p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ error }}</p> <p class="mt-1 text-xs text-muted-foreground">{{ error }}</p>
<button <button
@@ -141,7 +141,7 @@
</div> </div>
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2"> <footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2">
<span class="text-[0.6875rem] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span> <span class="text-[0.6875rem] text-muted-foreground">{{ footerHint() }}</span>
<span class="text-[0.6875rem] text-muted-foreground">·</span> <span class="text-[0.6875rem] text-muted-foreground">·</span>
<span class="text-[0.6875rem] tabular-nums text-muted-foreground"> <span class="text-[0.6875rem] tabular-nums text-muted-foreground">
{{ i18n.t('editor.lines', { count: lineCount() }) }} {{ i18n.t('editor.lines', { count: lineCount() }) }}
@@ -159,7 +159,7 @@
class="flex items-center gap-1.5 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" class="flex items-center gap-1.5 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
@if (saving()) { @if (saving()) {
<ng-icon name="tdesignLoading" size="0.85rem" class="animate-spin" /> <ng-icon name="tdesignLoading" size="var(--icon-size-sm)" class="animate-spin" />
} }
{{ i18n.t('common.save') }} {{ i18n.t('common.save') }}
</button> </button>
@@ -171,6 +171,10 @@
@if (closePrompt()) { @if (closePrompt()) {
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()"> <div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()">
<div <div
#prompt
role="dialog"
aria-modal="true"
[attr.aria-label]="i18n.t('editor.saveChangesTitle', { name: ctx.name })"
class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl" class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()" (mousedown)="$event.stopPropagation()"
> >
@@ -193,6 +197,7 @@
{{ i18n.t('common.cancel') }} {{ i18n.t('common.cancel') }}
</button> </button>
<button <button
#promptSave
(click)="saveAndClose()" (click)="saveAndClose()"
[disabled]="saving()" [disabled]="saving()"
class="bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" class="bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
@@ -2,7 +2,9 @@ import {
Component, Component,
DOCUMENT, DOCUMENT,
ElementRef, ElementRef,
Injector,
afterNextRender, afterNextRender,
afterRenderEffect,
computed, computed,
effect, effect,
inject, inject,
@@ -11,7 +13,8 @@ import {
} from '@angular/core'; } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '@core/platform'; import { cycleFocus } from '@core/focus-cycle';
import { blockNativeContextMenu, blockReloadKeys, EDITOR_CONTEXT, 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';
@@ -43,8 +46,6 @@ export class EditorWindow {
readonly i18n = inject(I18nService); readonly i18n = inject(I18nService);
readonly ctx = EDITOR_CONTEXT; readonly ctx = EDITOR_CONTEXT;
readonly isMacChrome = IS_MAC_CHROME; readonly isMacChrome = IS_MAC_CHROME;
readonly modLabel = MOD_LABEL;
readonly altLabel = ALT_LABEL;
readonly loading = signal(true); readonly loading = signal(true);
readonly loadError = signal<string | null>(null); readonly loadError = signal<string | null>(null);
@@ -92,6 +93,9 @@ export class EditorWindow {
); );
private readonly box = viewChild<ElementRef<HTMLTextAreaElement>>('box'); private readonly box = viewChild<ElementRef<HTMLTextAreaElement>>('box');
private readonly prompt = viewChild<ElementRef<HTMLElement>>('prompt');
private readonly promptSave = viewChild<ElementRef<HTMLButtonElement>>('promptSave');
private readonly injector = inject(Injector);
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter'); private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox'); private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
private cachedCharWidth = 0; private cachedCharWidth = 0;
@@ -102,7 +106,22 @@ export class EditorWindow {
// injected for its side effects: fetches settings and applies theme/accent/ // injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root // text-size data attributes to this window's document root
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService); readonly shortcuts = inject(ShortcutsService);
/** "⌘S save · ⌘F find · ⌘W close" — the keys from the live keymap, so a remap
* shows here too; an unbound command drops out. */
readonly footerHint = computed(() =>
(
[
['save', 'editor.verbs.save'],
['find', 'editor.verbs.find'],
['close-window', 'editor.verbs.close'],
] as const
)
.filter(([id]) => this.shortcuts.label(id))
.map(([id, verb]) => `${this.shortcuts.label(id)} ${this.i18n.t(verb)}`)
.join(' · '),
);
private draftTimer: ReturnType<typeof setTimeout> | null = null; private draftTimer: ReturnType<typeof setTimeout> | null = null;
private lastSentDirty = false; private lastSentDirty = false;
@@ -122,6 +141,11 @@ export class EditorWindow {
}); });
afterNextRender(() => void this.load(ctx.sessionId, ctx.path)); afterNextRender(() => void this.load(ctx.sessionId, ctx.path));
// the unsaved-changes prompt takes the keyboard, so keys can't edit the
// hidden text behind it: focus its Save button whenever it shows
afterRenderEffect(() => {
if (this.closePrompt()) this.promptSave()?.nativeElement.focus();
});
} }
private async load(sessionId: string, path: string): Promise<void> { private async load(sessionId: string, path: string): Promise<void> {
@@ -134,7 +158,8 @@ export class EditorWindow {
this.savedContent.set(res.content); this.savedContent.set(res.content);
this.text.set(res.content); this.text.set(res.content);
this.loading.set(false); this.loading.set(false);
queueMicrotask(() => this.box()?.nativeElement.focus()); // after the render that creates the textarea — a microtask runs before it
afterNextRender(() => this.box()?.nativeElement.focus(), { injector: this.injector });
} }
blockContextMenu(event: Event): void { blockContextMenu(event: Event): void {
@@ -168,11 +193,14 @@ export class EditorWindow {
} }
this.findOpen.set(true); this.findOpen.set(true);
this.activeMatch.set(0); this.activeMatch.set(0);
queueMicrotask(() => { afterNextRender(
const input = this.findBox()?.nativeElement; () => {
input?.focus(); const input = this.findBox()?.nativeElement;
input?.select(); input?.focus();
}); input?.select();
},
{ injector: this.injector },
);
} }
closeFind(): void { closeFind(): void {
@@ -197,6 +225,29 @@ export class EditorWindow {
this.goTo((this.activeMatch() + delta + count) % count); this.goTo((this.activeMatch() + delta + count) % count);
} }
/** ⌘G / ⇧⌘G (F3 / ⇧F3): the next / previous match from the caret, find bar
* open or not — the query survives closing it. With no query yet, open the bar. */
findNext(delta: 1 | -1): void {
if (!this.findQuery()) {
this.openFind();
return;
}
const list = this.matches();
if (!list.length) return;
const el = this.box()?.nativeElement;
let index = -1;
if (delta > 0) {
const caret = el?.selectionEnd ?? 0;
index = list.findIndex((start) => start >= caret);
if (index < 0) index = 0;
} else {
const caret = el?.selectionStart ?? 0;
for (let i = list.length - 1; i >= 0 && index < 0; i--) if (list[i] < caret) index = i;
if (index < 0) index = list.length - 1;
}
this.goTo(index);
}
onFindKeydown(event: KeyboardEvent): void { onFindKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
@@ -319,55 +370,78 @@ export class EditorWindow {
// ---- keyboard ---- // ---- keyboard ----
onKeydown(event: KeyboardEvent): void { onKeydown(event: KeyboardEvent): void {
// Chord shortcuts resolve through the shared keymap (ShortcutsService); this.handleKey(event);
// Escape and Tab stay below because their meaning is THIS window's state blockReloadKeys(event); // F5 / Ctrl+R must not reload over unsaved text
// (close prompt up? find bar open? caret in the textarea?), not a binding. }
const match = this.shortcuts.resolve(event, 'editor');
if (match) { /**
// scale keys must not fire while typing — but the editor's own chords * The editor's keys come from the shared keymap (`editor` scope); they fire
// (save/find/wrap) are WANTED mid-typing, so the guard is per-shortcut * mid-typing because they are all chords or F-keys. Escape and Tab stay here:
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return; * their meaning is this window's state (prompt up? find bar open?).
event.preventDefault(); */
switch (match.id) { private handleKey(event: KeyboardEvent): void {
case 'save': if (event.defaultPrevented) return;
void this.save(); const id = this.shortcuts.claim(event, 'editor');
break; if (this.closePrompt()) {
case 'find': // the unsaved-changes prompt owns the keyboard: ⌘S is its Save, Esc its
this.openFind(); // Cancel, Tab walks its buttons; other commands wait
break; if (id === 'save') void this.saveAndClose();
case 'close-window': else if (id === 'scale-up' || id === 'scale-down' || id === 'scale-reset') this.scale(id);
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W else if (!id && event.key === 'Escape') {
// and bun routes it back as editorCloseRequest event.preventDefault();
this.requestClose(); this.cancelClose();
break; } else if (!id) {
case 'toggle-wrap': const prompt = this.prompt()?.nativeElement;
this.toggleWrap(); if (prompt) cycleFocus(prompt, event);
break;
case 'scale-up':
this.settings.setUiScale(this.settings.uiScale() + 0.1);
break;
case 'scale-down':
this.settings.setUiScale(this.settings.uiScale() - 0.1);
break;
case 'scale-reset':
this.settings.setUiScale(1);
break;
} }
return; return;
} }
if (event.key === 'Escape' && this.closePrompt()) { switch (id) {
event.preventDefault(); case 'save':
this.cancelClose(); void this.save();
} else if (event.key === 'Escape' && this.findOpen()) { return;
case 'find':
this.openFind();
return;
case 'find-next':
this.findNext(1);
return;
case 'find-prev':
this.findNext(-1);
return;
case 'close-window':
this.requestClose();
return;
case 'toggle-wrap':
this.toggleWrap();
return;
case 'scale-up':
case 'scale-down':
case 'scale-reset':
this.scale(id);
return;
}
if (event.key === 'Escape' && this.findOpen()) {
event.preventDefault(); event.preventDefault();
this.closeFind(); this.closeFind();
} else if (event.key === 'Tab' && event.target === this.box()?.nativeElement) { } else if (
event.key === 'Tab' &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
event.target === this.box()?.nativeElement
) {
// keep Tab as an input character inside the editor // keep Tab as an input character inside the editor
event.preventDefault(); event.preventDefault();
this.insertTab(); this.insertTab();
} }
} }
private scale(id: 'scale-up' | 'scale-down' | 'scale-reset'): void {
if (id === 'scale-reset') this.settings.setUiScale(1);
else this.settings.setUiScale(this.settings.uiScale() + (id === 'scale-up' ? 0.1 : -0.1));
}
private insertTab(): void { private insertTab(): void {
const el = this.box()!.nativeElement; const el = this.box()!.nativeElement;
// execCommand keeps the native undo stack intact where supported // execCommand keeps the native undo stack intact where supported
@@ -4,24 +4,25 @@
[style.box-shadow]="activeMarker()" [style.box-shadow]="activeMarker()"
> >
<button <button
(click)="toggleMenu()" #sourceBtn
(click)="openSourcePicker(false)"
class="flex min-w-0 items-center gap-1.5 px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent" class="flex min-w-0 items-center gap-1.5 px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent"
[title]="i18n.t('panel.choosePanel')" [title]="shortcuts.tip(i18n.t('panel.choosePanel'), 'source-picker')"
> >
@if (panel().connecting()) { @if (panel().connecting()) {
<ng-icon name="tdesignLoading" class="shrink-0 animate-spin text-primary" size="var(--action-icon)" /> <ng-icon name="tdesignLoading" class="shrink-0 animate-spin text-primary" size="var(--icon-size-sm)" />
} @else { } @else {
<ng-icon <ng-icon
[name]="isRemote() ? 'tdesignServer' : 'tdesignHardDrive'" [name]="isRemote() ? 'server14' : 'hardDrive14'"
[class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'" [class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
size="var(--action-icon)" size="var(--icon-size-sm)"
/> />
} }
<span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span> <span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span>
<ng-icon <ng-icon
name="tdesignChevronDown" name="chevronDown14"
class="shrink-0 text-muted-foreground" class="shrink-0 text-muted-foreground"
size="calc(var(--action-icon) - 0.1rem)" size="var(--icon-size-sm)"
/> />
</button> </button>
<div class="flex-1"></div> <div class="flex-1"></div>
@@ -29,135 +30,61 @@
<!-- panel actions: act on THIS panel. Frequent/destructive verbs grow text <!-- panel actions: act on THIS panel. Frequent/destructive verbs grow text
labels when the panel is wide enough (container query). --> labels when the panel is wide enough (container query). -->
<button <button
(click)="copyToOther()" (click)="run('send')"
[title]="side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft')" [title]="shortcuts.tip(side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft'), 'send')"
class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-primary" class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-primary"
> >
<ng-icon [name]="copyIcon()" size="var(--action-icon)" /> <ng-icon [name]="copyIcon()" size="var(--icon-size-sm)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.copy') }}</span> <span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.copy') }}</span>
</button> </button>
<button <button
(click)="run('edit')" (click)="run('edit')"
[title]="i18n.t('panel.editFile')" [title]="shortcuts.tip(i18n.t('panel.editFile'), 'edit')"
class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-foreground" class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="tdesignFile1" size="var(--action-icon)" /> <ng-icon name="fileText14" size="var(--icon-size-sm)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span> <span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span>
</button> </button>
<button <button
(click)="run('rename')" (click)="run('rename')"
[title]="i18n.t('panel.rename')" [title]="shortcuts.tip(i18n.t('panel.rename'), 'rename')"
class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-foreground" class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="tdesignEdit" size="var(--action-icon)" /> <ng-icon name="edit14" size="var(--icon-size-sm)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span> <span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span>
</button> </button>
<button <button
(click)="run('mkdir')" (click)="run('new-folder')"
[title]="i18n.t('panel.newFolder')" [title]="shortcuts.tip(i18n.t('panel.newFolder'), 'new-folder')"
class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="tdesignFolderAdd" size="var(--action-icon)" /> <ng-icon name="folderAdd14" size="var(--icon-size-sm)" />
</button> </button>
<button <button
(click)="run('createFile')" (click)="run('new-file')"
[title]="i18n.t('panel.newFile')" [title]="shortcuts.tip(i18n.t('panel.newFile'), 'new-file')"
class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="tdesignFileAdd" size="var(--action-icon)" /> <ng-icon name="fileAdd14" size="var(--icon-size-sm)" />
</button> </button>
@if (chmodAvailable()) { @if (chmodAvailable()) {
<button <button
(click)="run('chmod')" (click)="run('chmod')"
[title]="i18n.t('panel.permissions')" [title]="shortcuts.tip(i18n.t('panel.permissions'), 'chmod')"
class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="tdesignLockOn" size="var(--action-icon)" /> <ng-icon name="lockOn14" size="var(--icon-size-sm)" />
</button> </button>
} }
<button <button
(click)="run('delete')" (click)="run('delete')"
[title]="i18n.t('panel.deleteHint')" [title]="shortcuts.tip(i18n.t('common.delete'), 'delete')"
class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-destructive" class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-destructive"
> >
<ng-icon name="tdesignDelete" size="var(--action-icon)" /> <ng-icon name="delete14" size="var(--icon-size-sm)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span> <span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span>
</button> </button>
@if (menuOpen()) {
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
<div
class="absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto border border-border bg-card py-1 text-card-foreground shadow-xl"
>
<button
(click)="pickLocal()"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="tdesignHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1">{{ i18n.t('common.local') }}</span>
@if (!isRemote()) {
<ng-icon name="tdesignCheck" size="0.85rem" class="shrink-0 text-primary" />
}
</button>
<!-- Windows drive roots (empty list elsewhere) -->
@for (drive of drives(); track drive) {
<button
(click)="pickDrive(drive)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="tdesignHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 font-mono">{{ drive }}</span>
</button>
}
@if (connections.grouped().loose.length || connections.grouped().stacks.length) {
<div class="my-1 border-t border-border"></div>
}
@for (connection of connections.grouped().loose; track connection.id) {
<button
(click)="pickConnection(connection)"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="tdesignServer" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ connection.name }}</span>
</button>
}
@for (group of connections.grouped().stacks; track group.stack.id) {
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
{{ group.stack.name }}
</div>
@for (connection of group.connections; track connection.id) {
<button
(click)="pickConnection(connection)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="tdesignServer" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ connection.name }}</span>
</button>
}
}
@if (connections.sshHosts().length) {
<div class="my-1 border-t border-border"></div>
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
~/.ssh/config
</div>
@for (host of connections.sshHosts(); track host.name) {
<button
(click)="pickSshHost(host)"
[disabled]="host.unsupported"
[title]="sshHostTitle(host)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent disabled:opacity-60 disabled:hover:bg-transparent"
>
<ng-icon name="tdesignServer" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ host.name }}</span>
</button>
}
}
</div>
}
</div> </div>
<!-- path bar --> <!-- path bar -->
@@ -165,31 +92,34 @@
<button <button
(click)="panel().up()" (click)="panel().up()"
[disabled]="blocked()" [disabled]="blocked()"
[title]="i18n.t('panel.parentDir')" [title]="shortcuts.tip(i18n.t('panel.parentDir'), 'parent')"
class="grid size-(--path-ctl-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent" class="grid size-(--path-ctl-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
> >
<ng-icon name="tdesignArrowUp" size="1rem" /> <ng-icon name="arrowUp16" size="var(--icon-size-md)" />
</button> </button>
<!-- no blanket stopPropagation: the window handler lets only ⌘/Ctrl chords
through from a text field, and its WebView2 reload guard must see F5 -->
<input <input
#pathBox
[value]="pathDraft()" [value]="pathDraft()"
(input)="pathDraft.set($any($event.target).value)" (input)="pathDraft.set($any($event.target).value)"
(keydown.enter)="commitPath()" (keydown.enter)="commitPath()"
(keydown.escape)="resetPath()" (keydown.escape)="resetPath()"
(keydown)="$event.stopPropagation()"
[disabled]="blocked()" [disabled]="blocked()"
[title]="shortcuts.tip(i18n.t('keys.focus-path'), 'focus-path')"
spellcheck="false" spellcheck="false"
class="h-(--path-ctl-h) min-w-0 flex-1 border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50" class="h-(--path-ctl-h) min-w-0 flex-1 border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
/> />
<button <button
(click)="panel().refresh()" (click)="run('refresh')"
[disabled]="blocked()" [disabled]="blocked()"
[title]="i18n.t('panel.refresh')" [title]="shortcuts.tip(i18n.t('panel.refresh'), 'refresh')"
class="grid size-(--path-ctl-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent" class="grid size-(--path-ctl-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
> >
@if (panel().loading()) { @if (panel().loading()) {
<ng-icon name="tdesignLoading" size="1rem" class="animate-spin" /> <ng-icon name="tdesignLoading" size="var(--icon-size-md)" class="animate-spin" />
} @else { } @else {
<ng-icon name="tdesignRefresh" size="1rem" /> <ng-icon name="refresh16" size="var(--icon-size-md)" />
} }
</button> </button>
</div> </div>
@@ -200,7 +130,11 @@
[style.grid-template-columns]="gridTemplate()" [style.grid-template-columns]="gridTemplate()"
class="grid border-b border-border bg-toolbar-3 text-[0.6875rem] font-medium text-muted-foreground" class="grid border-b border-border bg-toolbar-3 text-[0.6875rem] font-medium text-muted-foreground"
> >
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')"> <button
class="px-2 py-1 text-left hover:text-foreground"
[title]="sortTip('name')"
(click)="panel().toggleSort('name')"
>
{{ i18n.t('columns.name') }} {{ sortIndicator('name') }} {{ i18n.t('columns.name') }} {{ sortIndicator('name') }}
</button> </button>
@for (col of visibleColumns(); track col.key) { @for (col of visibleColumns(); track col.key) {
@@ -208,6 +142,7 @@
class="px-2 py-1 hover:text-foreground" class="px-2 py-1 hover:text-foreground"
[class.text-right]="col.key === 'size'" [class.text-right]="col.key === 'size'"
[class.text-left]="col.key !== 'size'" [class.text-left]="col.key !== 'size'"
[title]="sortTip(col.key)"
(click)="panel().toggleSort(col.key)" (click)="panel().toggleSort(col.key)"
> >
{{ i18n.t('columns.' + col.key) }} {{ sortIndicator(col.key) }} {{ i18n.t('columns.' + col.key) }} {{ sortIndicator(col.key) }}
@@ -218,7 +153,7 @@
<!-- type-to-filter bar (appears while a query is active) --> <!-- type-to-filter bar (appears while a query is active) -->
@if (panel().filter(); as query) { @if (panel().filter(); as query) {
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1"> <div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
<ng-icon name="tdesignSearch" size="0.85rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="search14" size="var(--icon-size-sm)" class="shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span> <span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
<span class="shrink-0 text-[0.6875rem] text-muted-foreground"> <span class="shrink-0 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('panel.matches', { count: filterMatches() }) }} {{ i18n.t('panel.matches', { count: filterMatches() }) }}
@@ -228,7 +163,7 @@
[title]="i18n.t('panel.clearFilter')" [title]="i18n.t('panel.clearFilter')"
class="grid size-5 shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-5 shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="tdesignClose" size="0.8rem" /> <ng-icon name="close14" size="var(--icon-size-sm)" />
</button> </button>
</div> </div>
} }
@@ -246,7 +181,7 @@
@if (panel().connecting()) { @if (panel().connecting()) {
<div class="panel-fade-in grid h-full place-items-center"> <div class="panel-fade-in grid h-full place-items-center">
<div class="flex flex-col items-center gap-3"> <div class="flex flex-col items-center gap-3">
<ng-icon name="tdesignLoading" size="1.4rem" class="animate-spin text-primary" /> <ng-icon name="tdesignLoading" size="var(--icon-size-xl)" class="animate-spin text-primary" />
<span class="text-xs text-muted-foreground"> <span class="text-xs text-muted-foreground">
{{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }} {{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }}
</span> </span>
@@ -275,7 +210,7 @@
[class.ring-ring]="i === panel().cursor() && isActive()" [class.ring-ring]="i === panel().cursor() && isActive()"
> >
<div class="flex min-w-0 items-center gap-1.5 px-2"> <div class="flex min-w-0 items-center gap-1.5 px-2">
<ng-icon [name]="iconFor(entry)" size="0.95rem" [class]="iconClass(entry)" /> <ng-icon [name]="iconFor(entry)" size="var(--icon-size-md)" [class]="iconClass(entry)" />
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span> <span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
</div> </div>
@for (col of visibleColumns(); track col.key) { @for (col of visibleColumns(); track col.key) {
@@ -325,7 +260,7 @@
</div> </div>
@if (panel().slowLoad() && !panel().connecting()) { @if (panel().slowLoad() && !panel().connecting()) {
<div class="panel-fade-in absolute inset-0 z-10 grid place-items-center bg-background/60"> <div class="panel-fade-in absolute inset-0 z-10 grid place-items-center bg-background/60">
<ng-icon name="tdesignLoading" size="1.4rem" class="animate-spin text-primary" /> <ng-icon name="tdesignLoading" size="var(--icon-size-xl)" class="animate-spin text-primary" />
</div> </div>
} }
</div> </div>
+255 -116
View File
@@ -9,20 +9,22 @@ import {
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { isRepeatable } from '@shared/keymap';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types'; import type { ColumnKey, FileEntry, SshConfigHost } from '@shared/types';
import { ConnectionsService } from '@core/connections.service'; import { ConnectionsService } from '@core/connections.service';
import { ContextMenuService } from '@core/context-menu.service'; import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
import { DialogsService } from '@core/dialogs.service'; import { DialogsService } from '@core/dialogs.service';
import { FileActionsService } from '@core/file-actions.service'; import { FileActionsService, type FileCommand, type FileTarget } from '@core/file-actions.service';
import { fileGlyph, fileIconClass, fileKindKey } from '@core/file-icons'; import { fileGlyph, fileIconClass, fileKindKey } from '@core/file-icons';
import { formatMode } from '@core/format'; import { formatMode } from '@core/format';
import type { Icon14, Icon16 } from '@core/icons';
import { remPx } from '@core/metrics';
import { PanelsService, type Side, type SortKey } from '@core/panels.service'; import { PanelsService, type Side, type SortKey } from '@core/panels.service';
import { IS_WINDOWS } from '@core/platform'; import { IS_WINDOWS } 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, type ShortcutId } from '@core/shortcuts.service';
import { TransfersService } from '@core/transfers.service';
/** Optional columns in fixed display order, with their track widths. */ /** Optional columns in fixed display order, with their track widths. */
const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [ const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
@@ -34,6 +36,33 @@ const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
{ key: 'group', label: 'Group', width: '6rem' }, { key: 'group', label: 'Group', width: '6rem' },
]; ];
/** Rows a PageUp / PageDown jumps. */
const PAGE = 20;
/** Cursor movements: with Shift they extend the selection instead. */
const MOVES = new Set<ShortcutId>([
'cursor-up',
'cursor-down',
'page-up',
'page-down',
'cursor-first',
'cursor-last',
]);
/** The same keydown with Shift lifted — what a Shift+movement spells underneath. */
function withoutShift(event: KeyboardEvent) {
return {
key: event.key,
code: event.code,
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
altKey: event.altKey,
shiftKey: false,
target: event.target,
getModifierState: (key: string) => event.getModifierState(key),
};
}
@Component({ @Component({
selector: 'app-file-panel', selector: 'app-file-panel',
imports: [NgIcon], imports: [NgIcon],
@@ -46,12 +75,11 @@ export class FilePanel {
readonly panels = inject(PanelsService); readonly panels = inject(PanelsService);
readonly actions = inject(FileActionsService); readonly actions = inject(FileActionsService);
readonly transfers = inject(TransfersService);
readonly connections = inject(ConnectionsService); readonly connections = inject(ConnectionsService);
readonly dialogs = inject(DialogsService); readonly dialogs = inject(DialogsService);
readonly shortcuts = inject(ShortcutsService);
private readonly menu = inject(ContextMenuService); private readonly menu = inject(ContextMenuService);
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
readonly panel = computed(() => this.panels.get(this.side())); readonly panel = computed(() => this.panels.get(this.side()));
readonly isActive = computed(() => this.panels.activeSide() === this.side()); readonly isActive = computed(() => this.panels.activeSide() === this.side());
@@ -64,16 +92,17 @@ export class FilePanel {
/** Input is ignored while the panel is connecting or a listing is in flight. */ /** Input is ignored while the panel is connecting or a listing is in flight. */
readonly blocked = computed(() => this.panel().busy()); readonly blocked = computed(() => this.panel().busy());
/** The copy button points toward the other panel. */ /** The copy button points toward the other panel. */
readonly copyIcon = computed(() => (this.side() === 'left' ? 'tdesignArrowRight' : 'tdesignArrowLeft')); readonly copyIcon = computed<Icon14>(() => (this.side() === 'left' ? 'arrowRight14' : 'arrowLeft14'));
readonly pathDraft = signal(''); readonly pathDraft = signal('');
readonly menuOpen = signal(false);
/** Windows drive roots for the source picker; always empty elsewhere. */ /** Windows drive roots for the source picker; always empty elsewhere. */
readonly drives = signal<string[]>([]); readonly drives = signal<string[]>([]);
/** chmod on Windows local files only flips the read-only bit — hide it. */ /** chmod on Windows local files only flips the read-only bit — hide it. */
readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote())); readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote()));
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list'); private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
private readonly sourceBtn = viewChild.required<ElementRef<HTMLButtonElement>>('sourceBtn');
private readonly pathBox = viewChild.required<ElementRef<HTMLInputElement>>('pathBox');
private lastClickedIndex = 0; private lastClickedIndex = 0;
// locale-reactive wrappers: reading i18n.locale() inside makes the file-list // locale-reactive wrappers: reading i18n.locale() inside makes the file-list
@@ -95,17 +124,6 @@ export class FilePanel {
['minmax(0,1fr)', ...this.visibleColumns().map((c) => c.width)].join(' '), ['minmax(0,1fr)', ...this.visibleColumns().map((c) => c.width)].join(' '),
); );
headerContextMenu(event: MouseEvent): void {
event.preventDefault();
this.menu.open(event, this.menu.columnItems(this.settings.columns()));
}
pickDrive(drive: string): void {
this.menuOpen.set(false);
this.activate();
void this.panel().load(drive);
}
constructor() { constructor() {
if (IS_WINDOWS && this.rpc.available) { if (IS_WINDOWS && this.rpc.available) {
void this.rpc.request.listDrives({}).then((res) => { void this.rpc.request.listDrives({}).then((res) => {
@@ -114,11 +132,23 @@ export class FilePanel {
} }
// keep the editable path bar in sync with the actual cwd // keep the editable path bar in sync with the actual cwd
effect(() => this.pathDraft.set(this.panel().cwd())); effect(() => this.pathDraft.set(this.panel().cwd()));
// focus follows the active side (Tab switches panels, dialogs refocus on close) // focus follows the active side (Tab switches panels, dialogs and menus
// refocus on close) — but never while a dialog or menu is up: a run of
// overwrite questions reuses one dialog, and stealing focus back to the
// list behind it would hand Enter and Delete to the panel
effect(() => { effect(() => {
this.panels.focusTick(); this.panels.focusTick();
if (this.isActive()) this.listEl().nativeElement.focus(); if (this.isActive() && !this.dialogs.anyOpen && !this.menu.request()) {
this.listEl().nativeElement.focus();
}
}); });
// keys that land on <body> (a click dropped focus) still reach this list
effect(() => this.panels.registerListKeys(this.side(), (event) => this.onKeydown(event)));
}
headerContextMenu(event: MouseEvent): void {
event.preventDefault();
this.menu.open(event, this.menu.columnItems(this.settings.columns()));
} }
activate(): void { activate(): void {
@@ -129,38 +159,95 @@ export class FilePanel {
this.panels.cancelConnect(this.side()); this.panels.cancelConnect(this.side());
} }
copyToOther(): void { /** A toolbar button: the same command the keyboard and the menus run. */
run(id: FileCommand): void {
if (this.blocked()) return; if (this.blocked()) return;
this.activate(); this.activate();
void this.transfers.copyToOtherPanel(this.side()); this.actions.run(id, this.cursorTarget());
} }
run(action: 'edit' | 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void { private cursorTarget(): FileTarget {
if (this.blocked()) return; const panel = this.panel();
return { side: this.side(), panel, entry: panel.sorted()[panel.cursor()] ?? null };
}
// ---- source picker: what this panel shows ----
/** The session button's menu (also ⌘K): Local, drives, saved connections by
* stack, ~/.ssh/config hosts — through the shared context menu, so it gets
* the same keyboard handling and look. */
async openSourcePicker(keyboard: boolean): Promise<void> {
this.activate(); this.activate();
void this.actions[action](this.panel()); // re-read ~/.ssh/config on each open so external edits show up — BEFORE
// the items are built, the menu holds a snapshot
await this.connections.reloadSshHosts();
const rect = this.sourceBtn().nativeElement.getBoundingClientRect();
this.menu.open({ clientX: rect.left, clientY: rect.bottom + remPx(0.25) }, this.sourceItems(), {
keyboard,
onPick: (action) => this.pickSource(action),
});
} }
pickLocal(): void { private sourceItems(): MenuItem[] {
this.menuOpen.set(false); const items: MenuItem[] = [
void this.panels.disconnectPanel(this.side()); {
label: this.i18n.t('common.local'),
action: 'local',
icon: 'hardDrive14',
checked: !this.isRemote(),
},
];
// Windows drive roots (empty list elsewhere)
for (const drive of this.drives()) {
items.push({ label: drive, action: `drive:${drive}`, icon: 'hardDrive14' });
}
const { loose, stacks } = this.connections.grouped();
if (loose.length || stacks.length) items.push({ type: 'separator' });
const server = (connection: { id: string; name: string }): MenuItem => ({
label: connection.name,
action: `conn:${connection.id}`,
icon: 'server14',
});
items.push(...loose.map(server));
for (const group of stacks) {
items.push({ type: 'header', label: group.stack.name }, ...group.connections.map(server));
}
const hosts = this.connections.sshHosts();
if (hosts.length) {
items.push({ type: 'separator' }, { type: 'header', label: this.i18n.t('panel.sshConfig') });
for (const host of hosts) {
items.push({
label: host.name,
action: `ssh:${host.name}`,
icon: 'server14',
disabled: !!host.unsupported,
tooltip: this.sshHostTitle(host),
});
}
}
return items;
} }
pickConnection(connection: SavedConnection): void { private pickSource(action: string): void {
this.menuOpen.set(false); const [kind, ...rest] = action.split(':');
const value = rest.join(':'); // a drive root ("C:\") has a colon of its own
this.activate(); this.activate();
void this.panels.connectPanel(this.side(), connection, connection.name, connection.localDir); if (kind === 'local') {
void this.panels.disconnectPanel(this.side());
} else if (kind === 'drive') {
void this.panel().load(value);
} else if (kind === 'conn') {
const connection = this.connections.connections().find((c) => c.id === value);
if (connection) {
void this.panels.connectPanel(this.side(), connection, connection.name, connection.localDir);
}
} else if (kind === 'ssh') {
const host = this.connections.sshHosts().find((h) => h.name === value);
if (host) this.connectSshHost(host);
}
} }
toggleMenu(): void { private connectSshHost(host: SshConfigHost): void {
this.menuOpen.set(!this.menuOpen());
// re-read ~/.ssh/config on each open so external edits show up
if (this.menuOpen()) void this.connections.reloadSshHosts();
}
pickSshHost(host: SshConfigHost): void {
this.menuOpen.set(false);
this.activate();
void this.panels.connectPanel( void this.panels.connectPanel(
this.side(), this.side(),
{ {
@@ -181,7 +268,7 @@ export class FilePanel {
return `${host.username}@${host.hostname}:${host.port}`; return `${host.username}@${host.hostname}:${host.port}`;
} }
iconFor(entry: FileEntry): string { iconFor(entry: FileEntry): Icon16 {
return fileGlyph(entry); return fileGlyph(entry);
} }
@@ -209,6 +296,12 @@ export class FilePanel {
return this.panel().sortAsc() ? '↑' : '↓'; return this.panel().sortAsc() ? '↑' : '↓';
} }
/** The sort header's tooltip: its keymap command and keys. */
sortTip(key: SortKey): string {
const id = `sort-${key}` as const;
return this.shortcuts.tip(this.i18n.t(`keys.${id}`), id);
}
commitPath(): void { commitPath(): void {
if (this.blocked()) return; if (this.blocked()) return;
const value = this.pathDraft().trim(); const value = this.pathDraft().trim();
@@ -221,6 +314,12 @@ export class FilePanel {
this.listEl().nativeElement.focus(); this.listEl().nativeElement.focus();
} }
private focusPath(): void {
const el = this.pathBox().nativeElement;
el.focus();
el.select();
}
rowClick(event: MouseEvent, index: number): void { rowClick(event: MouseEvent, index: number): void {
if (this.blocked()) return; if (this.blocked()) return;
this.activate(); this.activate();
@@ -257,105 +356,145 @@ export class FilePanel {
this.actions.openContextMenu(event, { side: this.side(), panel: this.panel(), entry: null }); this.actions.openContextMenu(event, { side: this.side(), panel: this.panel(), entry: null });
} }
openAt(index: number): void { /** ⇧F10 / Menu / ⌃↩: the cursor row's menu, under the row. The selection is
if (this.blocked()) return; * kept — the menu's commands act on it, like the F-keys do. */
const entry = this.panel().sorted()[index]; private openKeyboardMenu(): void {
if (entry) this.panel().open(entry); const panel = this.panel();
const index = panel.cursor();
const entry = panel.sorted()[index] ?? null;
const row = this.listEl().nativeElement.querySelector<HTMLElement>(`[data-index="${index}"]`);
row?.scrollIntoView({ block: 'nearest' });
const rect = (row ?? this.listEl().nativeElement).getBoundingClientRect();
this.actions.openContextMenu(
{ clientX: rect.left + remPx(1.75), clientY: row ? rect.bottom : rect.top + remPx(0.5) },
{ side: this.side(), panel, entry },
true,
);
} }
onKeydown(event: KeyboardEvent): void { openAt(index: number): void {
if (this.blocked()) return; if (this.blocked()) return;
const panel = this.panel(); const panel = this.panel();
const key = event.key; const entry = panel.sorted()[index];
if (entry) this.actions.run('open', { side: this.side(), panel, entry });
}
if (key === 'Tab') { /**
event.preventDefault(); * The list's keys, in order:
this.panels.setActiveSide(this.panels.otherSide(this.side())); * 1. while a query is being typed, printable keys (Space too) extend it,
* Backspace trims and Esc clears it — fixed, ahead of every binding;
* 2. the `panel` keymap (Shift + a movement extends the selection);
* 3. a `main` binding (⌘T, Ctrl+Tab…) is left to bubble to App;
* 4. any other printable key starts a type-to-filter query;
* 5. Esc clears the selection.
*/
onKeydown(event: KeyboardEvent): void {
const panel = this.panel();
if (this.blocked()) {
// a busy panel takes no input — except Esc, which cancels a connect; its
// own keys are swallowed so Tab can't wander out of the list mid-load
if (event.key === 'Escape' && panel.connecting()) {
event.preventDefault();
this.cancelConnect();
} else if (this.shortcuts.resolve(event, 'panel')) {
event.preventDefault();
}
return; return;
} }
if (this.shortcuts.isMod(event) && key.toLowerCase() === 'a') { const plain = !event.metaKey && !event.ctrlKey && !event.altKey;
event.preventDefault(); const printable = plain && event.key.length === 1 && !event.isComposing;
panel.selectAll();
return; if (panel.filter() !== '' && plain) {
if (printable) return this.setFilter(event, panel.filter() + event.key);
if (event.key === 'Backspace' && !event.shiftKey) return this.setFilter(event, panel.filter().slice(0, -1));
if (event.key === 'Escape') return this.setFilter(event, '');
} }
// type-to-filter: printable characters build the query (Space only once a const command = this.panelCommand(event);
// query exists — an empty-filter Space keeps its toggle-select meaning) if (command) {
if (
key.length === 1 &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
(key !== ' ' || panel.filter() !== '')
) {
event.preventDefault(); event.preventDefault();
panel.setFilter(panel.filter() + key); if (event.repeat && !isRepeatable(command.id)) return;
this.runKey(command.id, command.extend);
this.scrollCursorIntoView(); this.scrollCursorIntoView();
return; return;
} }
if (key === 'Backspace' && panel.filter() !== '') { if (this.shortcuts.resolve(event, 'main')) return;
event.preventDefault();
panel.setFilter(panel.filter().slice(0, -1));
return;
}
if (key === 'Escape' && panel.filter() !== '') {
event.preventDefault();
panel.setFilter('');
return;
}
switch (key) { if (printable && event.key !== ' ') return this.setFilter(event, event.key);
case 'ArrowDown': if (event.key === 'Escape' && plain && panel.selected().size) {
panel.moveCursor(1); event.preventDefault();
break; panel.clearSelection();
case 'ArrowUp': }
}
/** The panel command a keydown spells. Shift + a movement key is that
* movement extending the selection — a fixed rule, so it follows remaps. */
private panelCommand(event: KeyboardEvent): { id: ShortcutId; extend: boolean } | null {
const id = this.shortcuts.resolve(event, 'panel');
if (id) return { id, extend: false };
if (!event.shiftKey) return null;
const base = this.shortcuts.resolve(withoutShift(event), 'panel');
return base && MOVES.has(base) ? { id: base, extend: true } : null;
}
private runKey(id: ShortcutId, extend: boolean): void {
const panel = this.panel();
const from = panel.cursor();
switch (id) {
case 'cursor-up':
panel.moveCursor(-1); panel.moveCursor(-1);
break; break;
case 'PageDown': case 'cursor-down':
panel.moveCursor(20); panel.moveCursor(1);
break; break;
case 'PageUp': case 'page-up':
panel.moveCursor(-20); panel.moveCursor(-PAGE);
break; break;
case 'Home': case 'page-down':
panel.moveCursor(PAGE);
break;
case 'cursor-first':
panel.cursor.set(0); panel.cursor.set(0);
break; break;
case 'End': case 'cursor-last':
panel.cursor.set(Math.max(0, panel.sorted().length - 1)); panel.cursor.set(Math.max(0, panel.sorted().length - 1));
break; break;
case ' ': case 'toggle-select': {
{ const entry = panel.sorted()[from];
const entry = panel.sorted()[panel.cursor()]; if (entry) panel.toggleSelect(entry.name);
if (entry) panel.toggleSelect(entry.name); panel.moveCursor(1);
panel.moveCursor(1); return;
} }
break; case 'select-all':
case 'Enter': panel.selectAll();
this.openAt(panel.cursor()); return;
return void event.preventDefault(); case 'parent':
case 'Backspace':
panel.up(); panel.up();
break; return;
case 'F2': case 'switch-panel':
this.actions.rename(panel); this.panels.setActiveSide(this.panels.otherSide(this.side()));
break; return;
case 'F4': case 'row-menu':
void this.actions.edit(panel); this.openKeyboardMenu();
break; return;
case 'F5': case 'focus-path':
void this.transfers.copyToOtherPanel(this.side()); this.focusPath();
break; return;
case 'F7': case 'source-picker':
this.actions.mkdir(panel); void this.openSourcePicker(true);
break; return;
case 'F8':
case 'Delete':
this.actions.delete(panel);
break;
default: default:
return; // let unhandled keys through // every other panel-scope command acts on files (keymap.ts)
this.actions.run(id as FileCommand, this.cursorTarget());
return;
} }
// Shift + movement: select every row the cursor passed over
if (extend) panel.selectRange(from, panel.cursor());
}
private setFilter(event: KeyboardEvent, query: string): void {
event.preventDefault(); event.preventDefault();
this.panel().setFilter(query);
this.scrollCursorIntoView(); this.scrollCursorIntoView();
} }
@@ -9,16 +9,22 @@
[class.h-9]="!isMacChrome" [class.h-9]="!isMacChrome"
[class.pl-3]="!isMacChrome" [class.pl-3]="!isMacChrome"
> >
<ng-icon name="tdesignSetting" size="0.95rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="setting16" size="var(--icon-size-md)" class="shrink-0 text-muted-foreground" />
<h1 class="text-sm font-semibold">{{ i18n.t('options.title') }}</h1> <h1 class="text-sm font-semibold">{{ i18n.t('options.title') }}</h1>
</header> </header>
<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"
@@ -244,7 +250,7 @@
[title]="i18n.t('options.resetDefault')" [title]="i18n.t('options.resetDefault')"
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="tdesignClose" size="0.7rem" /> <ng-icon name="close14" size="var(--icon-size-sm)" />
</button> </button>
} }
</div> </div>
@@ -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="search14"
size="var(--icon-size-sm)"
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="close14" size="var(--icon-size-sm)" />
</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="add14" size="var(--icon-size-sm)" />
</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="refresh14" size="var(--icon-size-sm)" />
</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, 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', 'manager', '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,14 +142,151 @@ export class SettingsWindow {
void this.rpc.request.showAbout({}); void this.rpc.request.showAbout({});
} }
/** Any-window shortcuts (interface scale); ⌘W arrives as a menu action bun-side. */ // ---- sections ----
onWindowKeydown(event: KeyboardEvent): void {
const match = this.shortcuts.resolve(event, 'settings'); /** ↑ / ↓ in the sidebar move between sections (and focus follows). */
if (!match || this.shortcuts.isTypingTarget(event)) return; onNavKeydown(event: KeyboardEvent): void {
const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
if (!step) return;
event.preventDefault(); event.preventDefault();
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1); const next = SECTIONS[(SECTIONS.indexOf(this.section()) + step + SECTIONS.length) % SECTIONS.length];
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1); this.section.set(next);
else if (match.id === 'scale-reset') this.settings.setUiScale(1); 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 { blockContextMenu(event: Event): void {
@@ -5,6 +5,7 @@ import type { TransferSnapshot } from '@shared/types';
import { formatSpeed } from '@core/format'; import { formatSpeed } from '@core/format';
import { PanelsService } from '@core/panels.service'; import { PanelsService } from '@core/panels.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { TransfersService } from '@core/transfers.service'; import { TransfersService } from '@core/transfers.service';
@Component({ @Component({
@@ -18,10 +19,10 @@ import { TransfersService } from '@core/transfers.service';
<button <button
(click)="transfers.expanded.set(!transfers.expanded())" (click)="transfers.expanded.set(!transfers.expanded())"
class="flex min-w-0 items-center gap-1.5 hover:text-foreground" class="flex min-w-0 items-center gap-1.5 hover:text-foreground"
[title]="i18n.t('transfer.showDetails')" [title]="shortcuts.tip(i18n.t('transfer.showDetails'), 'toggle-transfers')"
> >
@if (transfers.currentJob(); as job) { @if (transfers.currentJob(); as job) {
<ng-icon name="tdesignLoading" size="0.8rem" class="shrink-0 animate-spin text-primary" /> <ng-icon name="tdesignLoading" size="var(--icon-size-sm)" class="shrink-0 animate-spin text-primary" />
<span class="truncate"> <span class="truncate">
{{ i18n.t('transfer.copying', { name: job.name, percent: percent(job), speed: speedSuffix(job) }) }} {{ i18n.t('transfer.copying', { name: job.name, percent: percent(job), speed: speedSuffix(job) }) }}
@if (transfers.queuedCount(); as queued) { @if (transfers.queuedCount(); as queued) {
@@ -29,12 +30,12 @@ import { TransfersService } from '@core/transfers.service';
} }
</span> </span>
} @else if (transfers.failedJobs().length; as failed) { } @else if (transfers.failedJobs().length; as failed) {
<ng-icon name="tdesignCloseCircle" size="0.8rem" class="shrink-0 text-destructive" /> <ng-icon name="closeCircle14" size="var(--icon-size-sm)" class="shrink-0 text-destructive" />
<span class="text-destructive"> <span class="text-destructive">
{{ i18n.t('transfer.failed', { count: failed }) }} {{ i18n.t('transfer.failed', { count: failed }) }}
</span> </span>
} @else if (transfers.hasJobs()) { } @else if (transfers.hasJobs()) {
<ng-icon name="tdesignCheckCircle" size="0.8rem" class="shrink-0 text-success" /> <ng-icon name="checkCircle14" size="var(--icon-size-sm)" class="shrink-0 text-success" />
<span>{{ i18n.t('transfer.finished') }}</span> <span>{{ i18n.t('transfer.finished') }}</span>
} @else { } @else {
<span>{{ i18n.t('transfer.ready') }}</span> <span>{{ i18n.t('transfer.ready') }}</span>
@@ -44,8 +45,8 @@ import { TransfersService } from '@core/transfers.service';
<!-- active panel info --> <!-- active panel info -->
<div class="flex shrink-0 items-center gap-1.5"> <div class="flex shrink-0 items-center gap-1.5">
<ng-icon <ng-icon
[name]="activePanel().sessionId() === 'local' ? 'tdesignHardDrive' : 'tdesignServer'" [name]="activePanel().sessionId() === 'local' ? 'hardDrive14' : 'server14'"
size="0.8rem" size="var(--icon-size-sm)"
[class.text-primary]="activePanel().sessionId() !== 'local'" [class.text-primary]="activePanel().sessionId() !== 'local'"
/> />
<span class="max-w-56 truncate"> <span class="max-w-56 truncate">
@@ -62,6 +63,7 @@ import { TransfersService } from '@core/transfers.service';
export class StatusBar { export class StatusBar {
readonly i18n = inject(I18nService); readonly i18n = inject(I18nService);
readonly transfers = inject(TransfersService); readonly transfers = inject(TransfersService);
readonly shortcuts = inject(ShortcutsService);
readonly panels = inject(PanelsService); readonly panels = inject(PanelsService);
readonly settings = inject(SettingsService); readonly settings = inject(SettingsService);
readonly activePanel = computed(() => this.panels.get(this.panels.activeSide())); readonly activePanel = computed(() => this.panels.get(this.panels.activeSide()));
+17 -18
View File
@@ -3,8 +3,9 @@ import { NgIcon } from '@ng-icons/core';
import { ContextMenuService } from '@core/context-menu.service'; import { ContextMenuService } from '@core/context-menu.service';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import { PanelsService, type Workspace } from '@core/panels.service'; import { PanelsService, type Workspace } from '@core/panels.service';
import { IS_MAC_CHROME, MOD_LABEL } from '@core/platform'; import { IS_MAC_CHROME } from '@core/platform';
import { RpcService } from '@core/rpc.service'; import { RpcService } from '@core/rpc.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { UpdaterService } from '@core/updater.service'; import { UpdaterService } from '@core/updater.service';
@Component({ @Component({
@@ -51,8 +52,8 @@ import { UpdaterService } from '@core/updater.service';
[title]="panels.workspaceName(ws)" [title]="panels.workspaceName(ws)"
> >
<ng-icon <ng-icon
[name]="panels.workspaceIsRemote(ws) ? 'tdesignServer' : 'tdesignHardDrive'" [name]="panels.workspaceIsRemote(ws) ? 'server14' : 'hardDrive14'"
size="0.85rem" size="var(--icon-size-sm)"
class="shrink-0" class="shrink-0"
[class.text-primary]="panels.workspaceIsRemote(ws)" [class.text-primary]="panels.workspaceIsRemote(ws)"
/> />
@@ -63,10 +64,10 @@ import { UpdaterService } from '@core/updater.service';
(click)="close($event, ws.id)" (click)="close($event, ws.id)"
tabindex="-1" tabindex="-1"
aria-hidden="true" aria-hidden="true"
[title]="i18n.t('tabBar.closeTab', { mod: modLabel })" [title]="i18n.t('tabBar.closeTab')"
class="mr-1 grid size-4 shrink-0 place-items-center text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground" class="mr-1 grid size-4 shrink-0 place-items-center text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground"
> >
<ng-icon name="tdesignClose" size="0.7rem" /> <ng-icon name="close14" size="var(--icon-size-sm)" />
</button> </button>
} @else { } @else {
<span class="mr-1 size-4 shrink-0"></span> <span class="mr-1 size-4 shrink-0"></span>
@@ -79,10 +80,10 @@ import { UpdaterService } from '@core/updater.service';
</div> </div>
<button <button
(click)="panels.newWorkspace()" (click)="panels.newWorkspace()"
[title]="i18n.t('tabBar.newTab', { mod: modLabel })" [title]="shortcuts.tip(i18n.t('tabBar.newTab'), 'new-tab')"
class="ml-0.5 grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground" class="ml-0.5 grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
> >
<ng-icon name="tdesignAdd" size=".8rem" /> <ng-icon name="add14" size="var(--icon-size-sm)" />
</button> </button>
<div class="flex-1"></div> <div class="flex-1"></div>
@@ -105,13 +106,13 @@ import { UpdaterService } from '@core/updater.service';
<span class="relative flex items-center gap-1.5"> <span class="relative flex items-center gap-1.5">
@switch (updater.state()) { @switch (updater.state()) {
@case ('available') { @case ('available') {
<ng-icon name="tdesignDownload" size=".8rem" />{{ i18n.t('tabBar.update') }} <ng-icon name="download14" size="var(--icon-size-sm)" />{{ i18n.t('tabBar.update') }}
} }
@case ('downloading') { @case ('downloading') {
<ng-icon name="tdesignLoading" size=".8rem" class="animate-spin" />{{ i18n.t('tabBar.updating') }} {{ updater.progress() }}% <ng-icon name="tdesignLoading" size="var(--icon-size-sm)" class="animate-spin" />{{ i18n.t('tabBar.updating') }} {{ updater.progress() }}%
} }
@case ('ready') { @case ('ready') {
<ng-icon name="tdesignRefresh" size=".8rem" />{{ i18n.t('tabBar.restartToUpdate') }} <ng-icon name="refresh14" size="var(--icon-size-sm)" />{{ i18n.t('tabBar.restartToUpdate') }}
} }
} }
</span> </span>
@@ -121,20 +122,20 @@ import { UpdaterService } from '@core/updater.service';
<!-- connection manager (own native window) --> <!-- connection manager (own native window) -->
<button <button
(click)="openManager()" (click)="openManager()"
[title]="i18n.t('tabBar.manageConnections')" [title]="shortcuts.tip(i18n.t('tabBar.manageConnections'), 'connection-manager')"
class="grid h-6 px-2 py-1 place-items-center text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground" class="grid h-6 px-2 py-1 place-items-center text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
> >
<!-- <ng-icon name="tdesignSetting" size="0.9rem" /> --> <!-- <ng-icon name="setting14" size="var(--icon-size-sm)" /> -->
{{ i18n.t('tabBar.connections') }} {{ i18n.t('tabBar.connections') }}
</button> </button>
<!-- settings (own native window) --> <!-- settings (own native window) -->
<button <button
(click)="openSettings()" (click)="openSettings()"
[title]="i18n.t('tabBar.options')" [title]="shortcuts.tip(i18n.t('tabBar.options'), 'open-settings')"
class="grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground" class="grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
> >
<ng-icon name="tdesignSetting" size="0.9rem" /> <ng-icon name="setting14" size="var(--icon-size-sm)" />
</button> </button>
</div> </div>
`, `,
@@ -145,8 +146,8 @@ export class TabBar {
readonly updater = inject(UpdaterService); readonly updater = inject(UpdaterService);
private readonly contextMenu = inject(ContextMenuService); private readonly contextMenu = inject(ContextMenuService);
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
readonly shortcuts = inject(ShortcutsService);
readonly isMacChrome = IS_MAC_CHROME; readonly isMacChrome = IS_MAC_CHROME;
readonly modLabel = MOD_LABEL;
// drag & drop reordering // drag & drop reordering
readonly draggedId = signal<number | null>(null); readonly draggedId = signal<number | null>(null);
@@ -213,9 +214,7 @@ export class TabBar {
/** Update pill: first click downloads, second click (when ready) applies + relaunches. */ /** Update pill: first click downloads, second click (when ready) applies + relaunches. */
onUpdateClick(): void { onUpdateClick(): void {
const state = this.updater.state(); this.updater.advance();
if (state === 'available') void this.updater.download();
else if (state === 'ready') this.updater.apply();
} }
/** Insertion line at the drop position (inset shadow: no layout shift). */ /** Insertion line at the drop position (inset shadow: no layout shift). */
+11 -7
View File
@@ -1,5 +1,7 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import type { Icon14 } from '@core/icons';
import { ToastService } from '@core/toast.service'; import { ToastService } from '@core/toast.service';
/** Toast stack overlay; shared by the main window and editor windows. */ /** Toast stack overlay; shared by the main window and editor windows. */
@@ -7,7 +9,7 @@ import { ToastService } from '@core/toast.service';
selector: 'app-toasts', selector: 'app-toasts',
imports: [NgIcon], imports: [NgIcon],
template: ` template: `
<div class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2"> <div aria-live="polite" class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
@for (t of toast.toasts(); track t.id) { @for (t of toast.toasts(); track t.id) {
<div <div
(mouseenter)="toast.pause(t.id)" (mouseenter)="toast.pause(t.id)"
@@ -16,15 +18,16 @@ import { ToastService } from '@core/toast.service';
> >
<ng-icon <ng-icon
[name]="toastIcon(t.kind)" [name]="toastIcon(t.kind)"
size="1rem" size="var(--icon-size-sm)"
[class]="t.kind === 'error' ? 'shrink-0 text-destructive' : 'shrink-0 text-success'" [class]="t.kind === 'error' ? 'shrink-0 text-destructive' : 'shrink-0 text-success'"
/> />
<span class="min-w-0 flex-1 wrap-break-word">{{ t.text }}</span> <span class="min-w-0 flex-1 wrap-break-word">{{ t.text }}</span>
<button <button
(click)="toast.dismiss(t.id)" (click)="toast.dismiss(t.id)"
[attr.aria-label]="i18n.t('transfer.dismiss')"
class="shrink-0 text-muted-foreground hover:text-foreground" class="shrink-0 text-muted-foreground hover:text-foreground"
> >
<ng-icon name="tdesignClose" size="0.9rem" /> <ng-icon name="close14" size="var(--icon-size-sm)" />
</button> </button>
<!-- auto-dismiss countdown; freezes with the timer while hovered --> <!-- auto-dismiss countdown; freezes with the timer while hovered -->
<div <div
@@ -41,10 +44,11 @@ import { ToastService } from '@core/toast.service';
}) })
export class Toasts { export class Toasts {
readonly toast = inject(ToastService); readonly toast = inject(ToastService);
readonly i18n = inject(I18nService);
toastIcon(kind: 'error' | 'info' | 'success'): string { toastIcon(kind: 'error' | 'info' | 'success'): Icon14 {
if (kind === 'error') return 'tdesignCloseCircle'; if (kind === 'error') return 'closeCircle14';
if (kind === 'success') return 'tdesignCheckCircle'; if (kind === 'success') return 'checkCircle14';
return 'tdesignCheck'; return 'check14';
} }
} }
@@ -8,13 +8,13 @@
[disabled]="!transfers.finishedJobs().length" [disabled]="!transfers.finishedJobs().length"
class="flex items-center gap-1 px-1.5 py-0.5 hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent" class="flex items-center gap-1 px-1.5 py-0.5 hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
> >
<ng-icon name="tdesignDelete" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }} <ng-icon name="delete14" size="var(--icon-size-sm)" /> {{ i18n.t('transfer.clearFinished') }}
</button> </button>
</div> </div>
<div class="max-h-40 overflow-y-auto border-t border-border bg-chrome"> <div class="max-h-40 overflow-y-auto border-t border-border bg-chrome">
@for (job of transfers.jobs(); track job.id) { @for (job of transfers.jobs(); track job.id) {
<div class="flex items-center gap-2 px-3 py-1.5 text-xs"> <div class="flex items-center gap-2 px-3 py-1.5 text-xs">
<ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" /> <ng-icon [name]="stateIcon(job)" size="var(--icon-size-sm)" [class]="stateClass(job)" />
<span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span> <span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="truncate text-[0.6875rem] text-muted-foreground">{{ progressLabel(job) }}</div> <div class="truncate text-[0.6875rem] text-muted-foreground">{{ progressLabel(job) }}</div>
@@ -40,7 +40,7 @@
[title]="i18n.t('transfer.cancel')" [title]="i18n.t('transfer.cancel')"
class="shrink-0 p-1 text-muted-foreground hover:bg-accent hover:text-destructive" class="shrink-0 p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
> >
<ng-icon name="tdesignClose" size="0.9rem" /> <ng-icon name="close14" size="var(--icon-size-sm)" />
</button> </button>
} @else { } @else {
<button <button
@@ -48,7 +48,7 @@
[title]="i18n.t('transfer.dismiss')" [title]="i18n.t('transfer.dismiss')"
class="shrink-0 p-1 text-muted-foreground hover:bg-accent hover:text-foreground" class="shrink-0 p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="tdesignClose" size="0.9rem" /> <ng-icon name="close14" size="var(--icon-size-sm)" />
</button> </button>
} }
</div> </div>
@@ -1,8 +1,11 @@
import { Component, inject } from '@angular/core'; import { Component, ElementRef, Injector, afterNextRender, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import type { TransferSnapshot } from '@shared/types'; import type { TransferSnapshot } from '@shared/types';
import { cycleFocus } from '@core/focus-cycle';
import { formatEta } from '@core/format'; import { formatEta } from '@core/format';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import type { Icon14 } from '@core/icons';
import { PanelsService } from '@core/panels.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { TransfersService } from '@core/transfers.service'; import { TransfersService } from '@core/transfers.service';
@@ -10,11 +13,35 @@ import { TransfersService } from '@core/transfers.service';
selector: 'app-transfer-bar', selector: 'app-transfer-bar',
imports: [NgIcon], imports: [NgIcon],
templateUrl: './transfer-bar.html', templateUrl: './transfer-bar.html',
host: { '(keydown)': 'onKeydown($event)' },
}) })
export class TransferBar { export class TransferBar {
readonly i18n = inject(I18nService); readonly i18n = inject(I18nService);
readonly transfers = inject(TransfersService); readonly transfers = inject(TransfersService);
readonly settings = inject(SettingsService); readonly settings = inject(SettingsService);
private readonly panels = inject(PanelsService);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly injector = inject(Injector);
/** ⌘J opened the list: focus its first control once it has rendered. */
focusFirst(): void {
afterNextRender(
() => this.host.nativeElement.querySelector<HTMLElement>('button:not(:disabled)')?.focus(),
{ injector: this.injector },
);
}
/** Tab walks Clear finished and the jobs' Cancel / Dismiss; Esc goes back to
* the file list. */
onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.panels.refocus();
} else {
cycleFocus(this.host.nativeElement, event);
}
}
readonly formatSize = (bytes: number): string => this.i18n.size(bytes); readonly formatSize = (bytes: number): string => this.i18n.size(bytes);
/** "14.2 MB/s · 0:42 left" while a job is moving bytes. */ /** "14.2 MB/s · 0:42 left" while a job is moving bytes. */
@@ -30,13 +57,13 @@ export class TransferBar {
return parts.join(' · '); return parts.join(' · ');
} }
stateIcon(job: TransferSnapshot): string { stateIcon(job: TransferSnapshot): Icon14 | 'tdesignLoading' {
switch (job.state) { switch (job.state) {
case 'completed': case 'completed':
return 'tdesignCheckCircle'; return 'checkCircle14';
case 'failed': case 'failed':
case 'cancelled': case 'cancelled':
return 'tdesignCloseCircle'; return 'closeCircle14';
default: default:
return 'tdesignLoading'; return 'tdesignLoading';
} }
+23 -1
View File
@@ -197,7 +197,21 @@
--path-ctl-h: 1.75rem; --path-ctl-h: 1.75rem;
--action-h: 1.5rem; --action-h: 1.5rem;
--action-font: 0.6875rem; --action-font: 0.6875rem;
--action-icon: 0.9rem; }
/* Icon sizes, by TDesign's rule. Its glyphs are drawn on a 24px grid with a
2px stroke and ship for 16px; its components make an icon 2px larger than
the text beside it, in whole pixels (small button: 12px text, 14px icon).
Every <ng-icon> takes one of these four: a one-off size like 0.9rem (14.4px)
puts every stroke between pixels, and one-offs drift until neighbours never
match. Rem like the rest, so the interface scale moves them too.
sm, md and xl are also the pixel grids the glyphs in core/pixel-icons.ts
are drawn on (14, 16, 24): change one and those glyphs blur until redrawn. */
:root {
--icon-size-sm: 0.875rem; /* 14px: beside 11-12px text, and on small icon buttons */
--icon-size-md: 1rem; /* 16px: beside 13-14px text (file rows, window titles), path-bar buttons */
--icon-size-lg: 1.25rem; /* 20px: a spinner standing alone */
--icon-size-xl: 1.5rem; /* 24px: a status mark on a card or an empty pane */
} }
/* Soft entrance for the panel busy states (connecting placeholder, load overlay). */ /* Soft entrance for the panel busy states (connecting placeholder, load overlay). */
@@ -285,6 +299,14 @@ body {
opacity: 0.5; opacity: 0.5;
} }
/* Where the keyboard is: a Tab-walked button, tab or sidebar entry (see
focus-cycle.ts). :focus-visible only — a click never paints it. Inputs keep
their own focus:ring, the file list its cursor row (outline-none wins). */
:where(button, [role='tab'], [tabindex]:not([tabindex='-1'])):focus-visible {
outline: 2px solid var(--ring);
outline-offset: -2px;
}
/* No native arrow: a caret drawn from two currentColor triangles follows the /* No native arrow: a caret drawn from two currentColor triangles follows the
theme (a data-URI svg can't). The opened option list stays native — macOS theme (a data-URI svg can't). The opened option list stays native — macOS
draws that popup itself. */ draws that popup itself. */