Compare commits
7
Commits
0eb5608873
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5be41db032 | ||
|
|
5d23809d48 | ||
|
|
3f7145d69a | ||
|
|
fa2cec7136 | ||
|
|
e28314054f | ||
|
|
8f8f230711 | ||
|
|
d361297690 |
@@ -35,13 +35,15 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu
|
||||
a standard titlebar.
|
||||
- **Menu-bar (tray) icon**: template icon that adapts to light/dark; click it
|
||||
to bring the app to the front.
|
||||
- **Edit files** (F4 / toolbar / File menu): a built-in editor works on local
|
||||
- **Edit files** (F4 / ⌘E / toolbar / File menu): a built-in editor works on local
|
||||
and remote files directly; or pick an external app (Settings → Editor →
|
||||
Custom → Choose…). External edits of remote files download to a temp folder,
|
||||
are watched, and every save auto-uploads (visible in the transfer list) until
|
||||
the session disconnects. Leftover temp files from a crash are offered for
|
||||
cleanup at the next start.
|
||||
- Browse, sort (name/size/modified, folders first), keyboard navigation.
|
||||
- Browse, sort (name/size/modified, folders first), full keyboard control
|
||||
with remappable shortcuts (see below). Enter / double-click on a local file
|
||||
can open it in its default app (Settings → File list; off by default).
|
||||
- Create folder / file, rename, delete (recursive), change permissions (chmod).
|
||||
- Upload, download, and remote→remote send — files **and** whole folders —
|
||||
with a transfer queue, per-job progress, and cancel.
|
||||
@@ -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) |
|
||||
| Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` |
|
||||
| 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) |
|
||||
|
||||
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
|
||||
|
||||
`Tab` switch panel · `↑`/`↓`/`PgUp`/`PgDn`/`Home`/`End` move cursor ·
|
||||
`Space` select · `Enter` open folder · `Backspace` parent ·
|
||||
`Ctrl/Cmd+A` select all · `F5` send to other panel · `F2` rename ·
|
||||
`F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab ·
|
||||
`Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab · `Cmd/Ctrl+,` settings ·
|
||||
`Cmd/Ctrl+=`/`-`/`0` interface bigger / smaller / 100%.
|
||||
The whole app works from the keyboard, and **every command can be remapped** in
|
||||
**Settings → Keyboard**: press **+** on a row and type the keys, see conflicts,
|
||||
reset one row or all. Remaps apply live in every window, the right-click menus'
|
||||
hints and the toolbar tooltips follow, and so does the macOS menu bar. The
|
||||
defaults:
|
||||
|
||||
(On Macs, function keys may need `fn` — every action also has a toolbar button.)
|
||||
| Command | macOS | Windows / Linux |
|
||||
| --- | --- | --- |
|
||||
| **File list** | | |
|
||||
| Move up | `↑` | `↑` |
|
||||
| Move down | `↓` | `↓` |
|
||||
| Page up | `PgUp` | `PgUp` |
|
||||
| Page down | `PgDn` | `PgDn` |
|
||||
| First item | `Home` | `Home` |
|
||||
| Last item | `End` | `End` |
|
||||
| Select / deselect | `Space` | `Space` `Ins` |
|
||||
| Select all | `⌘A` | `Ctrl+A` |
|
||||
| Open | `↩` `⌘↓` | `Enter` |
|
||||
| Parent folder | `⌫` `⌘↑` | `Backspace` `Alt+↑` |
|
||||
| Switch panel | `⇥` `⇧⇥` | `Tab` `Shift+Tab` |
|
||||
| Show the item's menu | `⇧F10` `⌃↩` | `Shift+F10` `Menu` |
|
||||
| **Files** | | |
|
||||
| Send to the other panel | `F5` | `F5` |
|
||||
| Edit | `⌘E` `F4` | `F4` `Ctrl+E` |
|
||||
| Rename | `F2` | `F2` |
|
||||
| New folder | `⇧⌘N` `F7` | `F7` `Ctrl+Shift+N` |
|
||||
| New file | `⌘N` `⇧F4` | `Shift+F4` `Ctrl+N` |
|
||||
| Permissions | `⌘I` | `Ctrl+I` |
|
||||
| Delete | `F8` `⌦` | `F8` `Del` |
|
||||
| Move to Trash | `⌘⌫` | `Ctrl+Del` |
|
||||
| Copy path | `⌥⌘C` | `Ctrl+Shift+C` |
|
||||
| Reveal in file manager | `⌥⌘R` | `Alt+Shift+R` |
|
||||
| Open in default app | `⌘O` | `Ctrl+O` |
|
||||
| Refresh | `⌘R` | `Ctrl+R` |
|
||||
| Go to path | `⌘L` | `Ctrl+L` |
|
||||
| Choose what the panel shows | `⌘K` | `Ctrl+K` |
|
||||
| **Sorting** | | |
|
||||
| Sort by name | `⌥1` | `Alt+1` |
|
||||
| Sort by size | `⌥2` | `Alt+2` |
|
||||
| Sort by modified | `⌥3` | `Alt+3` |
|
||||
| Sort by kind | `⌥4` | `Alt+4` |
|
||||
| Sort by rights | `⌥5` | `Alt+5` |
|
||||
| Sort by owner | `⌥6` | `Alt+6` |
|
||||
| Sort by group | `⌥7` | `Alt+7` |
|
||||
| **Tabs** | | |
|
||||
| New tab | `⌘T` | `Ctrl+T` |
|
||||
| Close tab | `⌘W` | `Ctrl+W` |
|
||||
| Close other tabs | `⌥⌘W` | `Ctrl+Shift+W` |
|
||||
| Close tabs to the right | — | — |
|
||||
| Next tab | `⌃⇥` `⇧⌘]` | `Ctrl+Tab` `Ctrl+PgDn` |
|
||||
| Previous tab | `⌃⇧⇥` `⇧⌘[` | `Ctrl+Shift+Tab` `Ctrl+PgUp` |
|
||||
| Go to tab 1 | `⌘1` | `Ctrl+1` |
|
||||
| Go to tab 2 | `⌘2` | `Ctrl+2` |
|
||||
| Go to tab 3 | `⌘3` | `Ctrl+3` |
|
||||
| Go to tab 4 | `⌘4` | `Ctrl+4` |
|
||||
| Go to tab 5 | `⌘5` | `Ctrl+5` |
|
||||
| Go to tab 6 | `⌘6` | `Ctrl+6` |
|
||||
| Go to tab 7 | `⌘7` | `Ctrl+7` |
|
||||
| Go to tab 8 | `⌘8` | `Ctrl+8` |
|
||||
| Go to last tab | `⌘9` | `Ctrl+9` |
|
||||
| Move tab left | `⌃⇧PgUp` | `Ctrl+Shift+PgUp` |
|
||||
| Move tab right | `⌃⇧PgDn` | `Ctrl+Shift+PgDn` |
|
||||
| Disconnect tab | `⌘D` | `Ctrl+D` |
|
||||
| **Window** | | |
|
||||
| Settings | `⌘,` | `Ctrl+,` |
|
||||
| Connection manager | `⇧⌘K` | `Ctrl+Shift+K` |
|
||||
| Show / hide transfers | `⌘J` | `Ctrl+J` |
|
||||
| Install update | — | — |
|
||||
| Bigger interface | `⌘=` `⇧⌘=` `⌘Num +` | `Ctrl+=` `Ctrl+Shift+=` `Ctrl+Num +` |
|
||||
| Smaller interface | `⌘-` | `Ctrl+-` |
|
||||
| Interface at 100% | `⌘0` | `Ctrl+0` |
|
||||
| Close window | `⌘W` | `Ctrl+W` |
|
||||
| **Editor** | | |
|
||||
| Save | `⌘S` | `Ctrl+S` |
|
||||
| Find | `⌘F` | `Ctrl+F` |
|
||||
| Find next | `⌘G` | `F3` |
|
||||
| Find previous | `⇧⌘G` | `Shift+F3` |
|
||||
| Wrap lines | `⌥Z` | `Alt+Z` |
|
||||
| **Connection manager** | | |
|
||||
| New connection | `⌘N` | `Ctrl+N` |
|
||||
| New stack | `⇧⌘N` | `Ctrl+Shift+N` |
|
||||
| Save connection | `⌘S` | `Ctrl+S` |
|
||||
| Connect / open stack | `⌘↩` | `Ctrl+Enter` |
|
||||
| Delete connection or stack | `⌘⌫` | `Del` |
|
||||
| Rename stack | `F2` | `F2` |
|
||||
| Import from WinSCP | `⌘O` | `Ctrl+O` |
|
||||
|
||||
**Fixed keys** (not remappable): Esc closes menus, dialogs and the find bar —
|
||||
in the file list it clears the filter, then cancels a connect, then clears the
|
||||
selection. Enter / Esc confirm / cancel dialogs and Tab walks their controls
|
||||
(on macOS too, without the system's Keyboard navigation setting). Typing in the
|
||||
file list filters it (Backspace trims); Shift + any movement key selects the
|
||||
rows the cursor passes. ↑ ↓ Home End Enter work in every menu.
|
||||
|
||||
(On Macs, function keys may need `fn` — every F-key command also has a ⌘ key
|
||||
or a toolbar button.)
|
||||
|
||||
## Same-file protection
|
||||
|
||||
|
||||
+205
-37
@@ -78,9 +78,12 @@ Re-opening focuses the existing window. The webview fetches its own content
|
||||
**Close safety** (designed when electrobun window `close` was NOT cancellable —
|
||||
1.18.1 had no `allow` response like `before-quit`; 2.x adds a cancellable
|
||||
`will-close` for the titlebar close, not used yet — see gotchas):
|
||||
1. ⌘W (routed via the app menu → `editorCloseRequest`) / Close button →
|
||||
styled in-window Save/Don't Save/Cancel; the confirm path calls
|
||||
`editorClose` which sets `suppressPrompt` before `win.close()`.
|
||||
1. ⌘W (the page's own `close-window` key; a ⌘W it doesn't claim falls through
|
||||
to the app menu, which bun routes back as `editorCloseRequest`) / Close
|
||||
button → styled in-window Save/Don't Save/Cancel, which takes focus (Save;
|
||||
⌘S = Save, Esc = Cancel, Tab cycles) so keys can't edit the text behind it;
|
||||
the confirm path calls `editorClose` which sets `suppressPrompt` before
|
||||
`win.close()`.
|
||||
2. Traffic-light close → the window is already gone, but the editor mirrored
|
||||
its draft to bun (`editorState`, 400 ms debounce + flush on blur/dirty-flip)
|
||||
→ native Save/Discard box from the held draft. A failed save (dead session)
|
||||
@@ -96,7 +99,11 @@ to the main window (panels showing that directory refresh). Editor upgrades:
|
||||
scroll-synced line gutter (hidden when wrap is on — wrapped line numbers would
|
||||
need per-edit height measurement), Alt/⌥Z wrap toggle, custom find bar
|
||||
(matches computed in TS, `setSelectionRange` + scrollTop math — deliberately
|
||||
not `findInPage`, which is a shim on WebView2).
|
||||
not `findInPage`, which is a shim on WebView2). Find next / previous (⌘G /
|
||||
⇧⌘G, F3 / ⇧F3) step from the caret with the bar open or closed — the query
|
||||
survives closing it. Focus after a state change goes through
|
||||
`afterNextRender({ injector })`: under zoneless CD a microtask runs BEFORE the
|
||||
render that creates the `@if` element.
|
||||
|
||||
### Connection-manager window (`src/bun/manager-window.ts`)
|
||||
|
||||
@@ -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
|
||||
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/`)
|
||||
|
||||
Single instance, centered over main, **not modal**: every control is a plain
|
||||
`SettingsService` setter, so each edit is an ordinary save that bun
|
||||
rebroadcasts — the other windows follow live while it stays open. Opened by
|
||||
the tab-bar gear and Ctrl+, (`openSettings` from main) and, on macOS, the app
|
||||
menu's "Settings…" (⌘,, which the menu intercepts first). Its RPC serves only
|
||||
`getSettings`/`saveSettings`/`pickEditorApp`/`showAbout`. ⌘W reaches bun as
|
||||
the main window's `close-tab` menu action, so bun checks the Settings window's
|
||||
focus FIRST and closes it (see gotchas). Sidebar sections: Appearance,
|
||||
Transfers, Editor, File list (visible columns — the column-header right-click
|
||||
menu toggles the same setting), About (opens the native About box). The
|
||||
native title is `options.title`, retitled on a locale switch.
|
||||
the tab-bar gear and the open-settings key (⌘, / Ctrl+,, `openSettings` from
|
||||
main) and, on macOS, the app menu's "Settings…". Its RPC serves only
|
||||
`getSettings`/`saveSettings`/`pickEditorApp`/`showAbout`/`settingsClose`. Its
|
||||
page claims its own close-window key (`settingsClose`); a ⌘W it doesn't claim
|
||||
falls through to the app menu's Close Tab, and bun checks the Settings
|
||||
window's focus FIRST and closes it (see gotchas). Sidebar sections (↑ / ↓ move
|
||||
between them, focus starts on the current one, Tab is walked by `cycleFocus`):
|
||||
Appearance, Transfers, Editor, File list (visible columns — the column-header
|
||||
right-click menu toggles the same setting — and "Enter / double-click on a
|
||||
file": nothing | open local files in their default app), Keyboard, About
|
||||
(opens the native About box). The native title is `options.title`, retitled on
|
||||
a locale switch.
|
||||
|
||||
**Keyboard** is the remap editor over the shared keymap: rows by group with a
|
||||
search field (label or key text), a chip per combo (× removes), **+** records
|
||||
(the next non-modifier keydown, taken before every other key in the window so
|
||||
⌘W records instead of closing; Esc cancels; a click elsewhere or a blur ends
|
||||
it), ↺ resets a row, Reset all (inline confirm). Reserved combos are refused
|
||||
without `preventDefault` (⌘Q still quits); editor-scope combos must be chords
|
||||
or F-keys (the editor's text area always has focus); a combo owned by an
|
||||
overlapping-scope command offers **Replace** (moves it). Rows flag conflicts
|
||||
and plain character keys on file-list rows (they no longer start a filter).
|
||||
Fixed keys are listed read-only below. Every edit is `ShortcutsService.rebind`
|
||||
→ `settings.keybindings` (only rows that differ from the defaults) → the
|
||||
ordinary save + broadcast, so menus, tooltips and the macOS menu bar follow
|
||||
live.
|
||||
|
||||
## Bun modules
|
||||
|
||||
@@ -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
|
||||
spread-merge on load, so NEW SETTINGS KEYS need no backend change.
|
||||
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
|
||||
Connect submenu mirrors the store; "Settings…" carries ⌘,). ⌘W is intercepted
|
||||
by the menu, so bun routes `close-tab` to a focused Settings or editor window
|
||||
first. Labels go through
|
||||
Connect submenu mirrors the store), language switch and keyboard remap.
|
||||
**Accelerators come from the shared keymap** (`effectiveKeymap` +
|
||||
`toAccelerator`, the first combo the native parser can express faithfully:
|
||||
⌘/⌃ chords only — a bare key means ⌘+key to it — no ⌫ / ⌦ / Shift+punctuation);
|
||||
⌘Q stays fixed. The keybindings are read through a getter AFTER the
|
||||
connections load, so a remap saved meanwhile isn't lost. The page claims the
|
||||
keys it handles first, so the menu only runs what falls through; a
|
||||
`close-tab` that still arrives while a Settings or editor window is focused
|
||||
closes THAT window, and other main-targeted actions bring the main window
|
||||
forward first. Labels go through
|
||||
backend `i18n.t` and the menu is rebuilt on a locale change too.
|
||||
- `i18n.ts` — backend translator (native menus + thrown error messages, which
|
||||
no Angular service can reach). `setLocale(setting)` caches the resolved
|
||||
@@ -251,11 +297,22 @@ component; tokens → `ui/src/styles.css`.
|
||||
save too — the scale shortcuts — and the Settings window is nothing but
|
||||
saves); bun's `settingsSaved()` in index.ts runs the
|
||||
side effects once and rebroadcasts to every window **except the sender**, so
|
||||
there is no echo to fight a held Cmd+. `applyAll` never persists. New setting
|
||||
= five touches here + types.ts (backend merges automatically).
|
||||
there is no echo to fight a held Cmd+. `applyAll` never persists. Saves are
|
||||
whole-object, so **nothing persists until the first `getSettings` landed**
|
||||
(`loaded`): a ⌘+ pressed while a window opens would otherwise write the
|
||||
defaults over every remap. New setting = five touches here + types.ts
|
||||
(backend merges automatically; sanitize hand-editable shapes in
|
||||
`SettingsStore.load`).
|
||||
- `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the
|
||||
main window's panels listen. Exists so dialogs don't inject PanelsService
|
||||
(whose constructor spawns a workspace — fatal in secondary windows).
|
||||
main window's panels listen — but a panel only takes focus while NO dialog or
|
||||
menu is up (a run of overwrite questions reuses one dialog instance; focusing
|
||||
the list behind it handed Enter/Delete to the panel). Exists so dialogs don't
|
||||
inject PanelsService (whose constructor spawns a workspace — fatal in
|
||||
secondary windows).
|
||||
- `shortcuts.service.ts` — the keyboard layer over `src/shared/keymap.ts`; see
|
||||
"Keyboard" below.
|
||||
- `focus-cycle.ts` — `cycleFocus(root, event)`: Tab / ⇧Tab by hand (see
|
||||
"Keyboard").
|
||||
- `connections.service.ts` — store mirror + ssh-config hosts; reloads on the
|
||||
`connectionsChanged` push.
|
||||
- `i18n.service.ts` — runtime translator. `locale` signal + `t('ns.key', params)`
|
||||
@@ -267,20 +324,42 @@ component; tokens → `ui/src/styles.css`.
|
||||
/`speed()` so the file list reformats on switch. `SettingsService.applyLocale`
|
||||
resolves the `locale` setting (`system` → `navigator.language`) into it, on
|
||||
boot and on the `settingsChanged` broadcast (so editor/manager windows follow).
|
||||
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
|
||||
against an explicit panel; `menuTarget` remembers the right-clicked row.
|
||||
- `file-actions.service.ts` — **one file-command dispatcher**,
|
||||
`run(id, { side, panel, entry })`, for every entry point: list keys (the
|
||||
cursor row), row-menu picks (the right-clicked row, captured in the menu's
|
||||
`onPick` closure), the macOS menu bar's File/View items and the panel
|
||||
toolbar. Ids are keymap `FileCommand`s (`open`, `open-default`, `send`,
|
||||
`edit`, `rename`, `new-folder`, `new-file`, `chmod`, `delete`, `trash`,
|
||||
`copy-path`, `reveal`, `refresh`, `sort-*`). Selection if any, else the
|
||||
target row; `..` stands for the folder. `open` enters folders and opens a
|
||||
LOCAL file in its default app only when `openFilesOnEnter` is set (Settings
|
||||
→ File list); `open-default` (the menu's Open on a local file) always does.
|
||||
`trash` walks the selection one `moveToTrash` at a time. Main-window only
|
||||
(it injects TransfersService).
|
||||
- `context-menu.service.ts` + `skins/shadlike/context-menu.ts` — the panel and tab right-click
|
||||
menus, in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was
|
||||
retired on ALL platforms rather than kept as a second code path (see
|
||||
platform-notes.md). The service builds the item list (row / empty-space menu,
|
||||
and the header column picker) and holds the open request as a signal; the
|
||||
component renders it at the pointer with edge-flip clamping, arrow/Enter/Escape
|
||||
keys, and close-on-blur/resize/scroll. Picked items are emitted verbatim as
|
||||
`ctx-*` / `col:*` / `tab:*` strings through `RpcService.emitMenuAction`, so they
|
||||
land in the SAME `App.handleMenuAction` dispatcher the macOS app menubar uses —
|
||||
one handler, two sources. The three shell actions the webview cannot perform
|
||||
(reveal, open-with-default, copy path) go back to bun via the `shellItemAction`
|
||||
RPC, which is all that remains of the old native menu.
|
||||
the header column picker, and the panel **source picker** — Local, drives,
|
||||
connections under per-stack `header` items, `~/.ssh/config` hosts — which
|
||||
replaced a bespoke popover so it gets the same keys and look) and holds the
|
||||
open request as a signal; the component renders it at a point
|
||||
(`MenuPoint`: a MouseEvent, or a row rect for a keyboard-opened menu) with
|
||||
edge-flip clamping, a scrolling clamped box, and close-on-blur/resize/scroll.
|
||||
Items carry `shortcut` (a keymap id: its first key is the soft hint at 60%
|
||||
of the row colour, and pressing it while the menu is open picks the item)
|
||||
and `icon` (drawn in the check column; only the everyday verbs get one —
|
||||
Send, Edit, Rename, Delete, New Folder, New/Close Tab, the toolbar's own
|
||||
glyphs). Keys: ↑↓ Home End move the one highlight (mouse hover shares it),
|
||||
Enter/Space pick, Esc closes, items are not Tab stops; every handled key
|
||||
stops propagation so the window handler never runs it twice. A keyboard-opened
|
||||
menu starts with its first item highlighted (`initialIndex`). A request's
|
||||
`onPick` takes the pick (row menu → `FileActionsService.run`, source picker →
|
||||
`FilePanel`); otherwise `col:*` / `tab:*` strings go through
|
||||
`RpcService.emitMenuAction` to the SAME `App.handleMenuAction` dispatcher the
|
||||
macOS app menubar uses. The three shell actions the webview cannot perform
|
||||
(reveal, open-with-default, copy path) go back to bun via the
|
||||
`shellItemAction` RPC, which is all that remains of the old native menu.
|
||||
`tabItems()` builds the workspace-tab menu (`tab:new`, and
|
||||
`tab:close|close-others|close-right|disconnect:<id>`), served by
|
||||
`PanelsService.closeOtherWorkspaces` / `closeWorkspacesToRight` /
|
||||
@@ -293,14 +372,24 @@ component; tokens → `ui/src/styles.css`.
|
||||
walker check the flag themselves.
|
||||
- `file-icons.ts` — extension → category map driving glyphs, colors
|
||||
(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
|
||||
`provideIcons` in `app.config.ts`. Icon names are plain strings in templates and
|
||||
the build never checks them: an unregistered name renders nothing (console error
|
||||
only), so every name used anywhere must be listed here.
|
||||
- `icons.ts` — the one icon registry, passed to `provideIcons` in `app.config.ts`.
|
||||
A name is `<icon><px>`: the pixel grid its glyph is drawn for, which must match
|
||||
the size token where it renders (`server14` at `--icon-size-sm`, `server16` at
|
||||
`-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`,
|
||||
`EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`,
|
||||
`MOD_LABEL`/`ALT_LABEL`) + `blockNativeContextMenu` (kills WebKit's
|
||||
Reload/Inspect menu outside text fields — the app draws its own menu instead).
|
||||
`EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`) +
|
||||
`blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside text
|
||||
fields — the app draws its own menu instead) + `blockReloadKeys` (swallows
|
||||
unclaimed F5 / Ctrl+R / Ctrl+F5 / Ctrl+Shift+R on Windows, where WebView2
|
||||
would reload the page and drop every session; each window root calls it
|
||||
last).
|
||||
- `updater.service.ts` — auto-updater state for the tab-bar pill. Signals
|
||||
`state` (`idle→available→downloading→ready`|`error`), `version`, `progress`.
|
||||
Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on
|
||||
@@ -310,6 +399,79 @@ component; tokens → `ui/src/styles.css`.
|
||||
`src/bun/index.ts`; feed is `release.baseUrl` (`https://miniscp.com/dl`, the
|
||||
`/dl` host in `site/`). See build-and-release.md "Auto-updater".
|
||||
|
||||
## Keyboard
|
||||
|
||||
**One keymap** — `src/shared/keymap.ts`, shared so the webviews and bun (the
|
||||
macOS menu bar) read the same table. `BINDINGS` lists every command: `id`,
|
||||
`group` (Settings sections), `scopes`, and default combos per platform
|
||||
(`mac` / `other`, best first: the first is the hint menus and tooltips show).
|
||||
Combos are platform-neutral strings (`Mod+Shift+N`: Mod = ⌘ on macOS, Ctrl
|
||||
elsewhere; `Ctrl` a literal ⌃ only macOS tells apart). Letters match the
|
||||
layout's character (AZERTY's A is A) and fall back to the physical key under
|
||||
Alt (⌥Z types Ω) and on non-Latin layouts; digits match physically (AZERTY
|
||||
types "&" on 1); punctuation by character first (AZERTY's comma is on the M
|
||||
key), physical second (⇧] makes "}"). **Matching is exact**: ⌃F is not ⌘F,
|
||||
Ctrl+Tab is not Tab, and neither the Windows key nor AltGr is ever part of a
|
||||
binding. `effectiveKeymap(overrides)` applies `AppSettings.keybindings`
|
||||
(rows that differ from the defaults; `[]` unbinds) and drops anything
|
||||
malformed. `conflicts()` / `ownerOf()` work over overlapping scopes; the
|
||||
spec (`keymap.spec.ts`) proves the defaults conflict-free, round-trippable and
|
||||
unshadowed on both platforms.
|
||||
|
||||
**Scopes.** `panel` = the focused file list (navigation, file commands,
|
||||
sorting); `main` = anywhere in the main window (tabs, window commands); `editor`
|
||||
/ `manager` / `settings` per window; `any` = every window (interface scale).
|
||||
`panel` sits inside `main`, so they conflict-check together.
|
||||
|
||||
**`ShortcutsService`** resolves keydowns against the effective keymap
|
||||
(reactive: a remap applies live in every window via the settings broadcast) and
|
||||
formats hints — `label(id)` / `labels(id)` / `tip(text, id)` /
|
||||
`tipFixed(text, 'Shift+Enter')`, with **localized key names** off macOS
|
||||
(`keys.names.*`: German says Strg+Entf). `claim()` = resolve + `preventDefault`
|
||||
(so the macOS menu never runs it twice) + auto-repeat only for movement-like
|
||||
commands (holding ⌘W closes one tab). IME composition is never a shortcut.
|
||||
From a text field only `firesInTextField()` combos count: ⌘/Ctrl and Alt chords
|
||||
and F-keys, but not plain keys, not text-editing chords (⌘⌫, ⌥←, ⌘A) and not
|
||||
Ctrl+Alt off macOS (AltGr).
|
||||
|
||||
**Dispatch** (one place per window): an open context menu owns its keys; an
|
||||
open dialog owns Enter / Esc / Tab while the main window swallows window
|
||||
commands and the panel commands the macOS menu bar carries (an unclaimed ⌘T
|
||||
would otherwise reach the menu and open a tab behind the dialog); the focused
|
||||
list (`FilePanel.onKeydown`) runs, in order, the fixed filter-editing keys
|
||||
while a query is typed, the `panel` keymap (Shift + a movement key extends the
|
||||
selection over the rows passed — a fixed rule that follows remaps), lets
|
||||
`main` matches bubble, then type-to-filter, then Esc's fixed chain (clear the
|
||||
filter → cancel a connect → clear the selection); `App` runs `main`. Every root
|
||||
ends with `blockReloadKeys`. Keys that land on `<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
|
||||
|
||||
`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).
|
||||
`@theme inline` maps tokens to Tailwind utilities (`bg-primary`,
|
||||
`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
|
||||
`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
|
||||
|
||||
+91
-5
@@ -76,6 +76,31 @@ touching the adjacent code.
|
||||
moment the pseudo-element is styled, WKWebView draws classic always-visible
|
||||
scrollbars that take layout width. Deliberate here (square design); don't
|
||||
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)
|
||||
|
||||
@@ -136,11 +161,18 @@ touching the adjacent code.
|
||||
update-initiated) — a plain `Utils.quit()` there quits without installing.
|
||||
`Utils.quit()` runs the same approval, which is why the guard checks
|
||||
`quitConfirmed` first.
|
||||
- **⌘W in any secondary window arrives as the MAIN window's `close-tab`
|
||||
menu action** (macOS: the app menu intercepts it before the webview sees
|
||||
the key). bun must check the focused secondary window first — the Settings
|
||||
window, then editors — and close THAT; a new window kind that skips this
|
||||
check makes ⌘W close a main-window tab behind it.
|
||||
- **macOS ⌘-keys go to the PAGE first; only unclaimed ones reach the app
|
||||
menu.** WKWebView's `performKeyEquivalent` hands key equivalents to the page
|
||||
while the webview is first responder (WebKit `WebViewImpl.mm`: "lets
|
||||
webpages have a crack at intercepting key-modified keypresses"); a key the
|
||||
page `preventDefault`s never reaches the menu. Old comments here claimed the
|
||||
opposite ("the menu intercepts ⌘W first") — what was really happening is that
|
||||
windows which didn't claim ⌘W let it fall through to the menu's Close Tab.
|
||||
Two consequences: a handled shortcut MUST `preventDefault` (`claim()` does),
|
||||
or the menu runs it a second time; and a ⌘W a secondary window doesn't claim
|
||||
still arrives as the MAIN window's `close-tab` action — bun checks the
|
||||
focused Settings window, then editors, first and closes THAT, so a new
|
||||
window kind must either claim close-window or join that check.
|
||||
- **One rpc instance per window** — BrowserView binding steals the rpc's
|
||||
transport; a shared instance silently sends to the wrong webview.
|
||||
- **RPC before socket open was silently lost on 1.18.1.** 2.x Electroview queues
|
||||
@@ -270,6 +302,56 @@ touching the adjacent code.
|
||||
`libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib`
|
||||
(upstream electrobun's choice, not ours).
|
||||
|
||||
## Keyboard (`src/shared/keymap.ts`, `ui/src/app/core/shortcuts.service.ts`)
|
||||
|
||||
- **WKWebView skips buttons, checkboxes and radios on Tab** unless the user
|
||||
turned on System Settings → Keyboard → Keyboard navigation (or presses ⌥Tab).
|
||||
Dialogs, Settings and the manager were mouse-only on a default Mac. Tab is
|
||||
walked by hand: `cycleFocus(root, event)` (core/focus-cycle.ts) — use it in
|
||||
any new dialog or window.
|
||||
- **WebKit doesn't focus a clicked button — focus drops to `<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
|
||||
|
||||
- **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
|
||||
new keys auto-default, but the UI SettingsService reads field-by-field —
|
||||
a new setting silently stays at default until wired in all five spots.
|
||||
- **Saves are whole-object: a window must not save before its first
|
||||
`getSettings` landed.** A ⌘+ pressed while an editor opened wrote
|
||||
`DEFAULT_SETTINGS` (no keyboard remaps) over the user's file.
|
||||
`SettingsService.persist()` is a no-op until `applyAll` ran once.
|
||||
- **Every window's RPC must serve `saveSettings`.** The scale shortcuts
|
||||
shipped in editor and manager windows while only the main RPC served it:
|
||||
the request went unanswered, the change applied locally, and main's next
|
||||
|
||||
@@ -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 |
|
||||
| chmod/Permissions | context-menu.service.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full |
|
||||
| Shell wording | `ui context-menu.service.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
|
||||
| Shortcut labels | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ |
|
||||
| Shortcut labels | `src/shared/keymap.ts formatCombo` (+ `keys.names.*`) | ⌘⇧⌥⌃ glyphs: ⇧⌘N | Ctrl+Shift+N, localized key names (Strg+Entf) | as Windows |
|
||||
| Command modifier | `keymap.ts` `Mod` | ⌘ only (⌃ stays Cocoa's text keys) | Ctrl | Ctrl |
|
||||
| Key equivalents | `shortcuts.service.ts claim` | page first, unclaimed → app menu | page only (no app menu) | page only |
|
||||
| Tab focus | `core/focus-cycle.ts cycleFocus` | WKWebView skips buttons/checkboxes without Keyboard navigation → walked by hand | native order (same walk) | native order (same walk) |
|
||||
| Reload keys | `platform.ts blockReloadKeys` | — | WebView2 reloads on F5 / Ctrl+R → swallowed when unclaimed | — |
|
||||
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored (needs AppIndicator, see below) |
|
||||
| App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — Settings opens from the tab-bar gear (Ctrl+,), About from its About section | none (unsupported upstream) |
|
||||
| App menu | `src/bun/menu.ts` | full menu incl. About; accelerators follow the keymap (remaps included), ⌘Q fixed | none (early return) — Settings opens from the tab-bar gear (Ctrl+,), About from its About section | none (unsupported upstream) |
|
||||
| connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 |
|
||||
| App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config |
|
||||
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
|
||||
|
||||
+15
-7
@@ -108,7 +108,7 @@ const manager = new ManagerWindow({
|
||||
connectionsChanged: () => rpc.send.connectionsChanged({}),
|
||||
menuAction: (info) => rpc.send.menuAction(info),
|
||||
},
|
||||
rebuildMenu: () => void applyApplicationMenu(store),
|
||||
rebuildMenu: () => void applyApplicationMenu(store, () => currentSettings.keybindings),
|
||||
onSettingsSaved: (s) => settingsSaved(s, "manager"),
|
||||
});
|
||||
const settingsWin = new SettingsWindow({
|
||||
@@ -134,12 +134,14 @@ function settingsSaved(
|
||||
): void {
|
||||
engine.setConcurrency(effectiveConcurrency(s));
|
||||
const localeChanged = s.locale !== currentSettings.locale;
|
||||
const keysChanged = JSON.stringify(s.keybindings) !== JSON.stringify(currentSettings.keybindings);
|
||||
currentSettings = s; // future windows' preloads bake the fresh theme
|
||||
if (localeChanged) {
|
||||
setBackendLocale(s.locale);
|
||||
void applyApplicationMenu(store); // relabel the native menu in the new language
|
||||
settingsWin.retitle();
|
||||
}
|
||||
// relabel the native menu in the new language / give it the remapped keys
|
||||
if (localeChanged || keysChanged) void applyApplicationMenu(store, () => currentSettings.keybindings);
|
||||
if (sender !== "main") rpc.send.settingsChanged({ settings: s });
|
||||
if (sender !== "manager") manager.broadcastSettings(s);
|
||||
if (sender !== "settings") settingsWin.broadcastSettings(s);
|
||||
@@ -258,16 +260,19 @@ events.sessionClosed = (info) => {
|
||||
rpc.send.sessionClosed(info);
|
||||
editors.notifySessionClosed(info);
|
||||
};
|
||||
events.connectionsChanged = () => void applyApplicationMenu(store);
|
||||
events.connectionsChanged = () => void applyApplicationMenu(store, () => currentSettings.keybindings);
|
||||
engine.setConcurrency(effectiveConcurrency(currentSettings));
|
||||
|
||||
void applyApplicationMenu(store);
|
||||
void applyApplicationMenu(store, () => currentSettings.keybindings);
|
||||
ApplicationMenu.on("application-menu-clicked", (event) => {
|
||||
const action = (event as { data?: { action?: string } }).data?.action;
|
||||
if (!action) return;
|
||||
// ⌘W while the Settings window or an editor window is focused closes THAT
|
||||
// window (an editor with its dirty prompt), not a main-window tab. Other
|
||||
// menu actions deliberately still target the main window.
|
||||
// Their pages claim close-window (⌘W) themselves — macOS hands key
|
||||
// equivalents to the page first — so a close-tab that still arrives while
|
||||
// the Settings window or an editor is focused is a ⌘W the page let through
|
||||
// (or the menu item clicked): close THAT window (an editor with its dirty
|
||||
// prompt), never a main-window tab behind it. Other menu actions
|
||||
// deliberately still target the main window.
|
||||
if (action === "close-tab") {
|
||||
if (settingsWin.isFocused()) {
|
||||
settingsWin.close();
|
||||
@@ -299,6 +304,9 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
|
||||
}
|
||||
// the manager is modal to the main window — don't drive main behind it
|
||||
if (manager.isOpen()) return;
|
||||
// everything below acts on the main window: from a focused editor or the
|
||||
// Settings window (⌘T, ⌘N…), bring it forward so the effect is visible
|
||||
if (settingsWin.isFocused() || editors.focusedKey()) win.activate();
|
||||
rpc.send.menuAction({ action });
|
||||
});
|
||||
|
||||
|
||||
+33
-11
@@ -1,5 +1,6 @@
|
||||
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/main";
|
||||
import { BRANDING } from "../shared/branding";
|
||||
import { effectiveKeymap, type ShortcutId, toAccelerator } from "../shared/keymap";
|
||||
import type { SavedConnection } from "../shared/types";
|
||||
import { t } from "./i18n";
|
||||
import type { ConnectionStore } from "./store/connections";
|
||||
@@ -7,13 +8,30 @@ import type { ConnectionStore } from "./store/connections";
|
||||
/**
|
||||
* macOS application menu (electrobun menus are macOS-only). Standard roles plus
|
||||
* app actions; custom actions are forwarded to the webview as menuAction
|
||||
* messages. Rebuilt whenever saved connections change so the Connect submenu
|
||||
* stays current.
|
||||
* messages. Rebuilt whenever saved connections, the language or the keyboard
|
||||
* remaps change.
|
||||
*
|
||||
* Accelerators come from the shared keymap, so the menu bar shows — and
|
||||
* fires — the user's keys. The page sees ⌘-keys first and claims the ones it
|
||||
* handles; the menu only runs what falls through (see shortcuts.service.ts).
|
||||
* ⌘Q stays fixed: quitting must always work.
|
||||
*/
|
||||
export async function applyApplicationMenu(store: ConnectionStore): Promise<void> {
|
||||
export async function applyApplicationMenu(
|
||||
store: ConnectionStore,
|
||||
keybindings: () => Record<string, string[]>,
|
||||
): Promise<void> {
|
||||
if (process.platform !== "darwin") return;
|
||||
|
||||
const { stacks, connections } = await store.load();
|
||||
// read after the await: a remap saved meanwhile must not be lost
|
||||
const keymap = effectiveKeymap(keybindings(), true);
|
||||
const accelerator = (id: ShortcutId): string | undefined => {
|
||||
for (const combo of keymap.find((b) => b.id === id)?.combos ?? []) {
|
||||
const native = toAccelerator(combo);
|
||||
if (native) return native;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const item = (c: SavedConnection): ApplicationMenuItemConfig => ({
|
||||
label: c.name,
|
||||
action: `connect:${c.id}`,
|
||||
@@ -28,7 +46,11 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
|
||||
...looseItems,
|
||||
...stackMenus,
|
||||
...(looseItems.length || stackMenus.length ? [{ type: "separator" } as const] : []),
|
||||
{ label: t("menu.connectionManager"), action: "manage-connections" },
|
||||
{
|
||||
label: t("menu.connectionManager"),
|
||||
action: "manage-connections",
|
||||
accelerator: accelerator("connection-manager"),
|
||||
},
|
||||
];
|
||||
|
||||
ApplicationMenu.setApplicationMenu([
|
||||
@@ -36,7 +58,7 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
|
||||
submenu: [
|
||||
{ label: t("menu.about", { app: BRANDING.appName }), action: "about" },
|
||||
{ type: "separator" },
|
||||
{ label: t("menu.settings"), action: "settings", accelerator: "," },
|
||||
{ label: t("menu.settings"), action: "settings", accelerator: accelerator("open-settings") },
|
||||
{ type: "separator" },
|
||||
{ label: t("menu.checkForUpdates"), action: "check-for-updates" },
|
||||
{ type: "separator" },
|
||||
@@ -53,13 +75,13 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
|
||||
{
|
||||
label: t("menu.file"),
|
||||
submenu: [
|
||||
{ label: t("menu.newTab"), action: "new-tab", accelerator: "t" },
|
||||
{ label: t("menu.closeTab"), action: "close-tab", accelerator: "w" },
|
||||
{ label: t("menu.newTab"), action: "new-tab", accelerator: accelerator("new-tab") },
|
||||
{ label: t("menu.closeTab"), action: "close-tab", accelerator: accelerator("close-tab") },
|
||||
{ type: "separator" },
|
||||
{ label: t("menu.newFolder"), action: "new-folder" },
|
||||
{ label: t("menu.newFile"), action: "new-file" },
|
||||
{ label: t("menu.newFolder"), action: "new-folder", accelerator: accelerator("new-folder") },
|
||||
{ label: t("menu.newFile"), action: "new-file", accelerator: accelerator("new-file") },
|
||||
{ type: "separator" },
|
||||
{ label: t("menu.editFile"), action: "edit-file", accelerator: "e" },
|
||||
{ label: t("menu.editFile"), action: "edit-file", accelerator: accelerator("edit") },
|
||||
{ type: "separator" },
|
||||
{ label: t("menu.connect"), submenu: connectSubmenu },
|
||||
],
|
||||
@@ -81,7 +103,7 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
|
||||
{
|
||||
label: t("menu.view"),
|
||||
submenu: [
|
||||
{ label: t("menu.refresh"), action: "refresh-active", accelerator: "r" },
|
||||
{ label: t("menu.refresh"), action: "refresh-active", accelerator: accelerator("refresh") },
|
||||
{ type: "separator" },
|
||||
{ role: "toggleFullScreen" },
|
||||
],
|
||||
|
||||
@@ -467,6 +467,8 @@ export interface SettingsRpcHooks {
|
||||
/** Every edit here is a settings save — rebroadcast it to the other windows. */
|
||||
onSettingsSaved: (next: AppSettings) => void;
|
||||
showAbout: () => void;
|
||||
/** The window's own close-window key. */
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/** RPC for the single Settings window (per-window instance). */
|
||||
@@ -483,6 +485,11 @@ export function createSettingsRPC(deps: { settings: SettingsStore }, hooks: Sett
|
||||
hooks.showAbout();
|
||||
return {};
|
||||
}),
|
||||
settingsClose: () =>
|
||||
wrap(async () => {
|
||||
hooks.close();
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -23,9 +23,9 @@ export interface SettingsWindowDeps {
|
||||
/**
|
||||
* The single Settings window. Unlike the connection manager it is NOT modal:
|
||||
* every edit is an ordinary settings save, so the other windows follow live
|
||||
* while it stays open. On macOS ⌘W reaches bun as the main window's close-tab
|
||||
* menu action, so index.ts asks isFocused() first and closes this window
|
||||
* instead of a main-window tab.
|
||||
* while it stays open. Its page claims its own close-window key (settingsClose).
|
||||
* A ⌘W it doesn't claim (remapped away) falls through to the app menu's Close
|
||||
* Tab, which index.ts routes here while this window is focused.
|
||||
*/
|
||||
export class SettingsWindow {
|
||||
private win: BrowserWindow | null = null;
|
||||
@@ -49,6 +49,7 @@ export class SettingsWindow {
|
||||
{
|
||||
onSettingsSaved: (next) => this.deps.onSettingsSaved(next),
|
||||
showAbout: () => this.deps.showAbout(),
|
||||
close: () => this.close(),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { sanitizeKeybindings } from "../../shared/keymap";
|
||||
import { clampUiScale, DEFAULT_SETTINGS, type AppSettings } from "../../shared/types";
|
||||
import { appDataDir } from "./paths";
|
||||
|
||||
@@ -24,6 +25,9 @@ export class SettingsStore {
|
||||
// into every window's root font-size, so a hand-edited settings.json
|
||||
// (uiScale: 100, "2", NaN) must not be able to render the UI unusable
|
||||
settings.uiScale = clampUiScale(Number(settings.uiScale));
|
||||
// same reasoning: every window resolves keys through this map
|
||||
settings.keybindings = sanitizeKeybindings(data.keybindings);
|
||||
settings.openFilesOnEnter = data.openFilesOnEnter === true;
|
||||
return settings;
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
|
||||
+139
-14
@@ -21,8 +21,8 @@ export const de = {
|
||||
tabBar: {
|
||||
connections: "Verbindungen",
|
||||
manageConnections: "Verbindungen verwalten",
|
||||
newTab: "Neuer Tab ({mod}T)",
|
||||
closeTab: "Tab schließen ({mod}W)",
|
||||
newTab: "Neuer Tab",
|
||||
closeTab: "Tab schließen",
|
||||
options: "Einstellungen",
|
||||
update: "Update verfügbar",
|
||||
updating: "Aktualisiere",
|
||||
@@ -36,6 +36,7 @@ export const de = {
|
||||
transfers: "Übertragungen",
|
||||
editor: "Editor",
|
||||
fileList: "Dateiliste",
|
||||
keyboard: "Tastatur",
|
||||
about: "Über",
|
||||
},
|
||||
columns: "Sichtbare Spalten",
|
||||
@@ -51,6 +52,7 @@ export const de = {
|
||||
systemDefault: "Systemstandard",
|
||||
resetDefault: "Auf Standard zurücksetzen",
|
||||
finishedTransfers: { label: "Abgeschlossene Übertragungen", autoClear: "Auto-löschen", keep: "Behalten" },
|
||||
openFiles: { label: "Eingabe / Doppelklick auf eine Datei", nothing: "Nichts tun", open: "Öffnen", hint: "Lokale Dateien öffnen sich in ihrer Standard-App; Ordner öffnen sich immer." },
|
||||
},
|
||||
kinds: {
|
||||
folder: "Ordner",
|
||||
@@ -75,17 +77,16 @@ export const de = {
|
||||
panel: {
|
||||
choosePanel: "Wählen, was dieses Panel anzeigt",
|
||||
copy: "Senden",
|
||||
copyToRight: "An rechtes Panel senden (F5)",
|
||||
copyToLeft: "An linkes Panel senden (F5)",
|
||||
copyToRight: "An rechtes Panel senden",
|
||||
copyToLeft: "An linkes Panel senden",
|
||||
edit: "Bearbeiten",
|
||||
editFile: "Datei bearbeiten (F4)",
|
||||
rename: "Umbenennen (F2)",
|
||||
newFolder: "Neuer Ordner (F7)",
|
||||
editFile: "Datei bearbeiten",
|
||||
rename: "Umbenennen",
|
||||
newFolder: "Neuer Ordner",
|
||||
newFile: "Neue Datei",
|
||||
permissions: "Berechtigungen",
|
||||
delete: "Löschen",
|
||||
deleteHint: "Löschen (F8 / Entf)",
|
||||
parentDir: "Übergeordneter Ordner (Rücktaste)",
|
||||
parentDir: "Übergeordneter Ordner",
|
||||
refresh: "Aktualisieren",
|
||||
clearFilter: "Filter löschen (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
@@ -144,16 +145,16 @@ export const de = {
|
||||
noFileTitle: "Keine Datei zum Bearbeiten",
|
||||
noFileBody: "Dieses Fenster wurde ohne Editor-Kontext geöffnet. Öffne Dateien aus der App.",
|
||||
wrap: "Umbruch",
|
||||
wrapHint: "Lange Zeilen umbrechen — blendet Zeilennummern aus ({alt}Z)",
|
||||
findHint: "Suchen ({mod}F)",
|
||||
wrapHint: "Lange Zeilen umbrechen — blendet Zeilennummern aus",
|
||||
findHint: "Suchen",
|
||||
findPlaceholder: "In Datei suchen",
|
||||
prevMatch: "Vorheriger Treffer (⇧↵)",
|
||||
nextMatch: "Nächster Treffer (↵)",
|
||||
prevMatch: "Vorheriger Treffer",
|
||||
nextMatch: "Nächster Treffer",
|
||||
closeHint: "Schließen (Esc)",
|
||||
disconnected: "Verbindung zu {name} getrennt — Speichern schlägt fehl, aber dein Text bleibt in diesem Fenster erhalten.",
|
||||
cantOpen: "{name} kann nicht geöffnet werden",
|
||||
closeWindow: "Fenster schließen",
|
||||
shortcuts: "{mod}S speichern · {mod}F suchen · {mod}W schließen",
|
||||
verbs: { save: "speichern", find: "suchen", close: "schließen" },
|
||||
lines: { one: "{count} Zeile", other: "{count} Zeilen" },
|
||||
close: "Schließen",
|
||||
save: "Speichern",
|
||||
@@ -209,6 +210,7 @@ export const de = {
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '„{name}" in den Papierkorb verschoben',
|
||||
movedManyToTrash: { one: "{count} Objekt in den Papierkorb verschoben", other: "{count} Objekte in den Papierkorb verschoben" },
|
||||
editingExternal: "Bearbeite {name} — Speichern lädt automatisch hoch",
|
||||
nothingToCopy: "Nichts zu kopieren (symbolische Links werden übersprungen)",
|
||||
nothingToCopyAllSkipped: "Nichts zu kopieren — alle Konflikte wurden übersprungen",
|
||||
@@ -292,6 +294,129 @@ export const de = {
|
||||
tabDisconnect: "Trennen",
|
||||
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: {
|
||||
sshAgentUnavailable: "ssh-agent nicht verfügbar (SSH_AUTH_SOCK ist nicht gesetzt)",
|
||||
identityFileNotFound: "Identitätsdatei nicht gefunden: {path}",
|
||||
|
||||
+144
-14
@@ -25,8 +25,8 @@ export const en = {
|
||||
tabBar: {
|
||||
connections: "Connections",
|
||||
manageConnections: "Manage connections",
|
||||
newTab: "New tab ({mod}T)",
|
||||
closeTab: "Close tab ({mod}W)",
|
||||
newTab: "New tab",
|
||||
closeTab: "Close tab",
|
||||
options: "Settings",
|
||||
update: "Update available",
|
||||
updating: "Updating",
|
||||
@@ -40,6 +40,7 @@ export const en = {
|
||||
transfers: "Transfers",
|
||||
editor: "Editor",
|
||||
fileList: "File list",
|
||||
keyboard: "Keyboard",
|
||||
about: "About",
|
||||
},
|
||||
columns: "Visible columns",
|
||||
@@ -55,6 +56,7 @@ export const en = {
|
||||
systemDefault: "System default",
|
||||
resetDefault: "Reset to system default",
|
||||
finishedTransfers: { label: "Finished transfers", autoClear: "Auto-clear", keep: "Keep" },
|
||||
openFiles: { label: "Enter / double-click on a file", nothing: "Do nothing", open: "Open", hint: "Local files open in their default app; folders always open." },
|
||||
},
|
||||
kinds: {
|
||||
folder: "Folder",
|
||||
@@ -79,17 +81,16 @@ export const en = {
|
||||
panel: {
|
||||
choosePanel: "Choose what this panel shows",
|
||||
copy: "Send",
|
||||
copyToRight: "Send to right panel (F5)",
|
||||
copyToLeft: "Send to left panel (F5)",
|
||||
copyToRight: "Send to right panel",
|
||||
copyToLeft: "Send to left panel",
|
||||
edit: "Edit",
|
||||
editFile: "Edit file (F4)",
|
||||
rename: "Rename (F2)",
|
||||
newFolder: "New folder (F7)",
|
||||
editFile: "Edit file",
|
||||
rename: "Rename",
|
||||
newFolder: "New folder",
|
||||
newFile: "New file",
|
||||
permissions: "Permissions",
|
||||
delete: "Delete",
|
||||
deleteHint: "Delete (F8 / Del)",
|
||||
parentDir: "Parent directory (Backspace)",
|
||||
parentDir: "Parent directory",
|
||||
refresh: "Refresh",
|
||||
clearFilter: "Clear filter (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
@@ -148,16 +149,17 @@ export const en = {
|
||||
noFileTitle: "No file to edit",
|
||||
noFileBody: "This window was opened without an editor context. Launch files from the app.",
|
||||
wrap: "Wrap",
|
||||
wrapHint: "Wrap long lines — hides line numbers ({alt}Z)",
|
||||
findHint: "Find ({mod}F)",
|
||||
wrapHint: "Wrap long lines — hides line numbers",
|
||||
findHint: "Find",
|
||||
findPlaceholder: "Find in file",
|
||||
prevMatch: "Previous match (⇧↵)",
|
||||
nextMatch: "Next match (↵)",
|
||||
prevMatch: "Previous match",
|
||||
nextMatch: "Next match",
|
||||
closeHint: "Close (Esc)",
|
||||
disconnected: "Disconnected from {name} — saving will fail, but your text is preserved in this window.",
|
||||
cantOpen: "Can't open {name}",
|
||||
closeWindow: "Close Window",
|
||||
shortcuts: "{mod}S save · {mod}F find · {mod}W close",
|
||||
// footer hint: "⌘S save · ⌘F find · ⌘W close", keys from the keymap
|
||||
verbs: { save: "save", find: "find", close: "close" },
|
||||
lines: { one: "{count} line", other: "{count} lines" },
|
||||
close: "Close",
|
||||
save: "Save",
|
||||
@@ -213,6 +215,7 @@ export const en = {
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: 'Moved "{name}" to Trash',
|
||||
movedManyToTrash: { one: "Moved {count} item to Trash", other: "Moved {count} items to Trash" },
|
||||
editingExternal: "Editing {name} — saves upload automatically",
|
||||
nothingToCopy: "Nothing to copy (symlinks are skipped)",
|
||||
nothingToCopyAllSkipped: "Nothing to copy — all conflicting items were skipped",
|
||||
@@ -296,6 +299,133 @@ export const en = {
|
||||
tabDisconnect: "Disconnect",
|
||||
trashTooltip: "Finder's Restore is not available for items trashed from here",
|
||||
},
|
||||
// Keyboard commands — one label per src/shared/keymap.ts BINDINGS id
|
||||
keys: {
|
||||
"cursor-up": "Move up",
|
||||
"cursor-down": "Move down",
|
||||
"page-up": "Page up",
|
||||
"page-down": "Page down",
|
||||
"cursor-first": "First item",
|
||||
"cursor-last": "Last item",
|
||||
"toggle-select": "Select / deselect",
|
||||
"select-all": "Select all",
|
||||
open: "Open",
|
||||
parent: "Parent folder",
|
||||
"switch-panel": "Switch panel",
|
||||
"row-menu": "Show the item's menu",
|
||||
send: "Send to the other panel",
|
||||
edit: "Edit",
|
||||
rename: "Rename",
|
||||
"new-folder": "New folder",
|
||||
"new-file": "New file",
|
||||
chmod: "Permissions",
|
||||
delete: "Delete",
|
||||
trash: "Move to Trash",
|
||||
"copy-path": "Copy path",
|
||||
reveal: "Reveal in file manager",
|
||||
"open-default": "Open in default app",
|
||||
refresh: "Refresh",
|
||||
"focus-path": "Go to path",
|
||||
"source-picker": "Choose what the panel shows",
|
||||
"sort-name": "Sort by name",
|
||||
"sort-size": "Sort by size",
|
||||
"sort-mtime": "Sort by modified",
|
||||
"sort-kind": "Sort by kind",
|
||||
"sort-rights": "Sort by rights",
|
||||
"sort-owner": "Sort by owner",
|
||||
"sort-group": "Sort by group",
|
||||
"new-tab": "New tab",
|
||||
"close-tab": "Close tab",
|
||||
"close-other-tabs": "Close other tabs",
|
||||
"close-tabs-right": "Close tabs to the right",
|
||||
"next-tab": "Next tab",
|
||||
"prev-tab": "Previous tab",
|
||||
"go-to-tab": "Go to tab {n}",
|
||||
"go-to-last-tab": "Go to last tab",
|
||||
"move-tab-left": "Move tab left",
|
||||
"move-tab-right": "Move tab right",
|
||||
"disconnect-tab": "Disconnect tab",
|
||||
"open-settings": "Settings",
|
||||
"connection-manager": "Connection manager",
|
||||
"toggle-transfers": "Show / hide transfers",
|
||||
"install-update": "Install update",
|
||||
"scale-up": "Bigger interface",
|
||||
"scale-down": "Smaller interface",
|
||||
"scale-reset": "Interface at 100%",
|
||||
"close-window": "Close window",
|
||||
save: "Save",
|
||||
find: "Find",
|
||||
"find-next": "Find next",
|
||||
"find-prev": "Find previous",
|
||||
"toggle-wrap": "Wrap lines",
|
||||
"conn-new": "New connection",
|
||||
"conn-new-stack": "New stack",
|
||||
"conn-save": "Save connection",
|
||||
"conn-connect": "Connect / open stack",
|
||||
"conn-delete": "Delete connection or stack",
|
||||
"conn-rename-stack": "Rename stack",
|
||||
"conn-import": "Import from WinSCP",
|
||||
groups: {
|
||||
panels: "File list",
|
||||
files: "Files",
|
||||
sort: "Sorting",
|
||||
tabs: "Tabs",
|
||||
window: "Window",
|
||||
editor: "Editor",
|
||||
connections: "Connection manager",
|
||||
},
|
||||
// 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: {
|
||||
sshAgentUnavailable: "ssh-agent not available (SSH_AUTH_SOCK is not set)",
|
||||
identityFileNotFound: "identity file not found: {path}",
|
||||
|
||||
+139
-14
@@ -21,8 +21,8 @@ export const es = {
|
||||
tabBar: {
|
||||
connections: "Conexiones",
|
||||
manageConnections: "Gestionar conexiones",
|
||||
newTab: "Nueva pestaña ({mod}T)",
|
||||
closeTab: "Cerrar pestaña ({mod}W)",
|
||||
newTab: "Nueva pestaña",
|
||||
closeTab: "Cerrar pestaña",
|
||||
options: "Ajustes",
|
||||
update: "Actualización disponible",
|
||||
updating: "Actualizando",
|
||||
@@ -36,6 +36,7 @@ export const es = {
|
||||
transfers: "Transferencias",
|
||||
editor: "Editor",
|
||||
fileList: "Lista de archivos",
|
||||
keyboard: "Teclado",
|
||||
about: "Acerca de",
|
||||
},
|
||||
columns: "Columnas visibles",
|
||||
@@ -51,6 +52,7 @@ export const es = {
|
||||
systemDefault: "Predeterminado del sistema",
|
||||
resetDefault: "Restablecer al predeterminado",
|
||||
finishedTransfers: { label: "Transferencias completadas", autoClear: "Auto-limpiar", keep: "Mantener" },
|
||||
openFiles: { label: "Intro / doble clic en un archivo", nothing: "No hacer nada", open: "Abrir", hint: "Los archivos locales se abren con su app predeterminada; las carpetas siempre se abren." },
|
||||
},
|
||||
kinds: {
|
||||
folder: "Carpeta",
|
||||
@@ -75,17 +77,16 @@ export const es = {
|
||||
panel: {
|
||||
choosePanel: "Elige qué muestra este panel",
|
||||
copy: "Enviar",
|
||||
copyToRight: "Enviar al panel derecho (F5)",
|
||||
copyToLeft: "Enviar al panel izquierdo (F5)",
|
||||
copyToRight: "Enviar al panel derecho",
|
||||
copyToLeft: "Enviar al panel izquierdo",
|
||||
edit: "Editar",
|
||||
editFile: "Editar archivo (F4)",
|
||||
rename: "Renombrar (F2)",
|
||||
newFolder: "Nueva carpeta (F7)",
|
||||
editFile: "Editar archivo",
|
||||
rename: "Renombrar",
|
||||
newFolder: "Nueva carpeta",
|
||||
newFile: "Nuevo archivo",
|
||||
permissions: "Permisos",
|
||||
delete: "Eliminar",
|
||||
deleteHint: "Eliminar (F8 / Supr)",
|
||||
parentDir: "Carpeta superior (Retroceso)",
|
||||
parentDir: "Carpeta superior",
|
||||
refresh: "Actualizar",
|
||||
clearFilter: "Borrar filtro (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
@@ -144,16 +145,16 @@ export const es = {
|
||||
noFileTitle: "Ningún archivo que editar",
|
||||
noFileBody: "Esta ventana se abrió sin contexto de editor. Abre archivos desde la aplicación.",
|
||||
wrap: "Ajustar",
|
||||
wrapHint: "Ajustar líneas largas — oculta los números de línea ({alt}Z)",
|
||||
findHint: "Buscar ({mod}F)",
|
||||
wrapHint: "Ajustar líneas largas — oculta los números de línea",
|
||||
findHint: "Buscar",
|
||||
findPlaceholder: "Buscar en el archivo",
|
||||
prevMatch: "Coincidencia anterior (⇧↵)",
|
||||
nextMatch: "Coincidencia siguiente (↵)",
|
||||
prevMatch: "Coincidencia anterior",
|
||||
nextMatch: "Coincidencia siguiente",
|
||||
closeHint: "Cerrar (Esc)",
|
||||
disconnected: "Desconectado de {name} — no se podrá guardar, pero tu texto se conserva en esta ventana.",
|
||||
cantOpen: "No se puede abrir {name}",
|
||||
closeWindow: "Cerrar ventana",
|
||||
shortcuts: "{mod}S guardar · {mod}F buscar · {mod}W cerrar",
|
||||
verbs: { save: "guardar", find: "buscar", close: "cerrar" },
|
||||
lines: { one: "{count} línea", other: "{count} líneas" },
|
||||
close: "Cerrar",
|
||||
save: "Guardar",
|
||||
@@ -209,6 +210,7 @@ export const es = {
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: 'Se movió "{name}" a la papelera',
|
||||
movedManyToTrash: { one: "Se movió {count} elemento a la papelera", other: "Se movieron {count} elementos a la papelera" },
|
||||
editingExternal: "Editando {name} — los cambios se suben automáticamente",
|
||||
nothingToCopy: "Nada que copiar (los enlaces simbólicos se omiten)",
|
||||
nothingToCopyAllSkipped: "Nada que copiar — se omitieron todos los elementos en conflicto",
|
||||
@@ -292,6 +294,129 @@ export const es = {
|
||||
tabDisconnect: "Desconectar",
|
||||
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: {
|
||||
sshAgentUnavailable: "ssh-agent no disponible (SSH_AUTH_SOCK no está definido)",
|
||||
identityFileNotFound: "archivo de identidad no encontrado: {path}",
|
||||
|
||||
+139
-14
@@ -23,8 +23,8 @@ export const eu = {
|
||||
tabBar: {
|
||||
connections: "Konexioak",
|
||||
manageConnections: "Kudeatu konexioak",
|
||||
newTab: "Fitxa berria ({mod}T)",
|
||||
closeTab: "Itxi fitxa ({mod}W)",
|
||||
newTab: "Fitxa berria",
|
||||
closeTab: "Itxi fitxa",
|
||||
options: "Ezarpenak",
|
||||
update: "Eguneratzea eskuragarri",
|
||||
updating: "Eguneratzen",
|
||||
@@ -38,6 +38,7 @@ export const eu = {
|
||||
transfers: "Transferentziak",
|
||||
editor: "Editorea",
|
||||
fileList: "Fitxategi-zerrenda",
|
||||
keyboard: "Teklatua",
|
||||
about: "Honi buruz",
|
||||
},
|
||||
columns: "Zutabe ikusgaiak",
|
||||
@@ -53,6 +54,7 @@ export const eu = {
|
||||
systemDefault: "Sistemaren lehenetsia",
|
||||
resetDefault: "Berrezarri lehenetsira",
|
||||
finishedTransfers: { label: "Amaitutako transferentziak", autoClear: "Garbitu", keep: "Mantendu" },
|
||||
openFiles: { label: "Sartu / klik bikoitza fitxategi batean", nothing: "Ezer ez", open: "Ireki", hint: "Fitxategi lokalak beren aplikazio lehenetsiarekin irekitzen dira; karpetak beti irekitzen dira." },
|
||||
},
|
||||
kinds: {
|
||||
folder: "Karpeta",
|
||||
@@ -77,17 +79,16 @@ export const eu = {
|
||||
panel: {
|
||||
choosePanel: "Aukeratu panel honek zer erakusten duen",
|
||||
copy: "Bidali",
|
||||
copyToRight: "Bidali eskuineko panelera (F5)",
|
||||
copyToLeft: "Bidali ezkerreko panelera (F5)",
|
||||
copyToRight: "Bidali eskuineko panelera",
|
||||
copyToLeft: "Bidali ezkerreko panelera",
|
||||
edit: "Editatu",
|
||||
editFile: "Editatu fitxategia (F4)",
|
||||
rename: "Berrizendatu (F2)",
|
||||
newFolder: "Karpeta berria (F7)",
|
||||
editFile: "Editatu fitxategia",
|
||||
rename: "Berrizendatu",
|
||||
newFolder: "Karpeta berria",
|
||||
newFile: "Fitxategi berria",
|
||||
permissions: "Baimenak",
|
||||
delete: "Ezabatu",
|
||||
deleteHint: "Ezabatu (F8 / Del)",
|
||||
parentDir: "Karpeta gurasoa (Atzera-tekla)",
|
||||
parentDir: "Karpeta gurasoa",
|
||||
refresh: "Freskatu",
|
||||
clearFilter: "Garbitu iragazkia (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
@@ -146,16 +147,16 @@ export const eu = {
|
||||
noFileTitle: "Ez dago editatzeko fitxategirik",
|
||||
noFileBody: "Leiho hau editore-testuingururik gabe ireki da. Ireki fitxategiak aplikaziotik.",
|
||||
wrap: "Doitu",
|
||||
wrapHint: "Doitu lerro luzeak — lerro-zenbakiak ezkutatzen ditu ({alt}Z)",
|
||||
findHint: "Bilatu ({mod}F)",
|
||||
wrapHint: "Doitu lerro luzeak — lerro-zenbakiak ezkutatzen ditu",
|
||||
findHint: "Bilatu",
|
||||
findPlaceholder: "Bilatu fitxategian",
|
||||
prevMatch: "Aurreko bat-etortzea (⇧↵)",
|
||||
nextMatch: "Hurrengo bat-etortzea (↵)",
|
||||
prevMatch: "Aurreko bat-etortzea",
|
||||
nextMatch: "Hurrengo bat-etortzea",
|
||||
closeHint: "Itxi (Esc)",
|
||||
disconnected: "{name}(e)tik deskonektatuta — gordetzeak huts egingo du, baina zure testua leiho honetan gordeta dago.",
|
||||
cantOpen: "Ezin da {name} ireki",
|
||||
closeWindow: "Itxi leihoa",
|
||||
shortcuts: "{mod}S gorde · {mod}F bilatu · {mod}W itxi",
|
||||
verbs: { save: "gorde", find: "bilatu", close: "itxi" },
|
||||
lines: { one: "lerro {count}", other: "{count} lerro" },
|
||||
close: "Itxi",
|
||||
save: "Gorde",
|
||||
@@ -211,6 +212,7 @@ export const eu = {
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '"{name}" zakarrontzira eraman da',
|
||||
movedManyToTrash: { one: "{count} elementu zakarrontzira eraman da", other: "{count} elementu zakarrontzira eraman dira" },
|
||||
editingExternal: "{name} editatzen — gordetzean automatikoki igotzen da",
|
||||
nothingToCopy: "Ez dago kopiatzeko ezer (esteka sinbolikoak saltatu egiten dira)",
|
||||
nothingToCopyAllSkipped: "Ez dago kopiatzeko ezer — gatazkan zeuden elementu guztiak saltatu dira",
|
||||
@@ -294,6 +296,129 @@ export const eu = {
|
||||
tabDisconnect: "Deskonektatu",
|
||||
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: {
|
||||
sshAgentUnavailable: "ssh-agent ez dago erabilgarri (SSH_AUTH_SOCK ez dago ezarrita)",
|
||||
identityFileNotFound: "identitate-fitxategia ez da aurkitu: {path}",
|
||||
|
||||
+139
-14
@@ -21,8 +21,8 @@ export const fr = {
|
||||
tabBar: {
|
||||
connections: "Connexions",
|
||||
manageConnections: "Gérer les connexions",
|
||||
newTab: "Nouvel onglet ({mod}T)",
|
||||
closeTab: "Fermer l'onglet ({mod}W)",
|
||||
newTab: "Nouvel onglet",
|
||||
closeTab: "Fermer l'onglet",
|
||||
options: "Réglages",
|
||||
update: "Mise à jour disponible",
|
||||
updating: "Mise à jour",
|
||||
@@ -36,6 +36,7 @@ export const fr = {
|
||||
transfers: "Transferts",
|
||||
editor: "Éditeur",
|
||||
fileList: "Liste des fichiers",
|
||||
keyboard: "Clavier",
|
||||
about: "À propos",
|
||||
},
|
||||
columns: "Colonnes visibles",
|
||||
@@ -51,6 +52,7 @@ export const fr = {
|
||||
systemDefault: "Valeur par défaut du système",
|
||||
resetDefault: "Réinitialiser par défaut",
|
||||
finishedTransfers: { label: "Transferts terminés", autoClear: "Effacer auto.", keep: "Garder" },
|
||||
openFiles: { label: "Entrée / double-clic sur un fichier", nothing: "Ne rien faire", open: "Ouvrir", hint: "Les fichiers locaux s'ouvrent avec leur application par défaut ; les dossiers s'ouvrent toujours." },
|
||||
},
|
||||
kinds: {
|
||||
folder: "Dossier",
|
||||
@@ -75,17 +77,16 @@ export const fr = {
|
||||
panel: {
|
||||
choosePanel: "Choisir ce qu'affiche ce panneau",
|
||||
copy: "Envoyer",
|
||||
copyToRight: "Envoyer vers le panneau droit (F5)",
|
||||
copyToLeft: "Envoyer vers le panneau gauche (F5)",
|
||||
copyToRight: "Envoyer vers le panneau droit",
|
||||
copyToLeft: "Envoyer vers le panneau gauche",
|
||||
edit: "Éditer",
|
||||
editFile: "Éditer le fichier (F4)",
|
||||
rename: "Renommer (F2)",
|
||||
newFolder: "Nouveau dossier (F7)",
|
||||
editFile: "Éditer le fichier",
|
||||
rename: "Renommer",
|
||||
newFolder: "Nouveau dossier",
|
||||
newFile: "Nouveau fichier",
|
||||
permissions: "Autorisations",
|
||||
delete: "Supprimer",
|
||||
deleteHint: "Supprimer (F8 / Suppr)",
|
||||
parentDir: "Dossier parent (Retour arrière)",
|
||||
parentDir: "Dossier parent",
|
||||
refresh: "Actualiser",
|
||||
clearFilter: "Effacer le filtre (Échap)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
@@ -144,16 +145,16 @@ export const fr = {
|
||||
noFileTitle: "Aucun fichier à éditer",
|
||||
noFileBody: "Cette fenêtre a été ouverte sans contexte d'éditeur. Ouvrez des fichiers depuis l'application.",
|
||||
wrap: "Retour",
|
||||
wrapHint: "Retour à la ligne — masque les numéros de ligne ({alt}Z)",
|
||||
findHint: "Rechercher ({mod}F)",
|
||||
wrapHint: "Retour à la ligne — masque les numéros de ligne",
|
||||
findHint: "Rechercher",
|
||||
findPlaceholder: "Rechercher dans le fichier",
|
||||
prevMatch: "Résultat précédent (⇧↵)",
|
||||
nextMatch: "Résultat suivant (↵)",
|
||||
prevMatch: "Résultat précédent",
|
||||
nextMatch: "Résultat suivant",
|
||||
closeHint: "Fermer (Échap)",
|
||||
disconnected: "Déconnecté de {name} — l'enregistrement échouera, mais votre texte est conservé dans cette fenêtre.",
|
||||
cantOpen: "Impossible d'ouvrir {name}",
|
||||
closeWindow: "Fermer la fenêtre",
|
||||
shortcuts: "{mod}S enregistrer · {mod}F rechercher · {mod}W fermer",
|
||||
verbs: { save: "enregistrer", find: "rechercher", close: "fermer" },
|
||||
lines: { one: "{count} ligne", other: "{count} lignes" },
|
||||
close: "Fermer",
|
||||
save: "Enregistrer",
|
||||
@@ -209,6 +210,7 @@ export const fr = {
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '« {name} » mis à la corbeille',
|
||||
movedManyToTrash: { one: "{count} élément mis à la corbeille", other: "{count} éléments mis à la corbeille" },
|
||||
editingExternal: "Édition de {name} — les enregistrements sont envoyés automatiquement",
|
||||
nothingToCopy: "Rien à copier (les liens symboliques sont ignorés)",
|
||||
nothingToCopyAllSkipped: "Rien à copier — tous les éléments en conflit ont été ignorés",
|
||||
@@ -292,6 +294,129 @@ export const fr = {
|
||||
tabDisconnect: "Déconnecter",
|
||||
trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici",
|
||||
},
|
||||
keys: {
|
||||
"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: {
|
||||
sshAgentUnavailable: "ssh-agent indisponible (SSH_AUTH_SOCK non défini)",
|
||||
identityFileNotFound: "fichier d'identité introuvable : {path}",
|
||||
|
||||
+139
-14
@@ -22,8 +22,8 @@ export const ja = {
|
||||
tabBar: {
|
||||
connections: "接続",
|
||||
manageConnections: "接続を管理",
|
||||
newTab: "新しいタブ ({mod}T)",
|
||||
closeTab: "タブを閉じる ({mod}W)",
|
||||
newTab: "新しいタブ",
|
||||
closeTab: "タブを閉じる",
|
||||
options: "設定",
|
||||
update: "アップデートあり",
|
||||
updating: "更新中",
|
||||
@@ -37,6 +37,7 @@ export const ja = {
|
||||
transfers: "転送",
|
||||
editor: "エディタ",
|
||||
fileList: "ファイル一覧",
|
||||
keyboard: "キーボード",
|
||||
about: "情報",
|
||||
},
|
||||
columns: "表示する列",
|
||||
@@ -52,6 +53,7 @@ export const ja = {
|
||||
systemDefault: "システムの既定",
|
||||
resetDefault: "既定に戻す",
|
||||
finishedTransfers: { label: "完了した転送", autoClear: "自動クリア", keep: "保持" },
|
||||
openFiles: { label: "Enter / ファイルをダブルクリック", nothing: "何もしない", open: "開く", hint: "ローカルファイルは既定のアプリで開きます。フォルダは常に開きます。" },
|
||||
},
|
||||
kinds: {
|
||||
folder: "フォルダ",
|
||||
@@ -76,17 +78,16 @@ export const ja = {
|
||||
panel: {
|
||||
choosePanel: "このパネルに表示する内容を選択",
|
||||
copy: "送信",
|
||||
copyToRight: "右のパネルへ送信 (F5)",
|
||||
copyToLeft: "左のパネルへ送信 (F5)",
|
||||
copyToRight: "右のパネルへ送信",
|
||||
copyToLeft: "左のパネルへ送信",
|
||||
edit: "編集",
|
||||
editFile: "ファイルを編集 (F4)",
|
||||
rename: "名前を変更 (F2)",
|
||||
newFolder: "新規フォルダ (F7)",
|
||||
editFile: "ファイルを編集",
|
||||
rename: "名前を変更",
|
||||
newFolder: "新規フォルダ",
|
||||
newFile: "新規ファイル",
|
||||
permissions: "アクセス権",
|
||||
delete: "削除",
|
||||
deleteHint: "削除 (F8 / Del)",
|
||||
parentDir: "親フォルダ (Backspace)",
|
||||
parentDir: "親フォルダ",
|
||||
refresh: "更新",
|
||||
clearFilter: "フィルターをクリア (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
@@ -145,16 +146,16 @@ export const ja = {
|
||||
noFileTitle: "編集するファイルがありません",
|
||||
noFileBody: "このウィンドウはエディタコンテキストなしで開かれました。アプリからファイルを開いてください。",
|
||||
wrap: "折り返し",
|
||||
wrapHint: "長い行を折り返す — 行番号を非表示 ({alt}Z)",
|
||||
findHint: "検索 ({mod}F)",
|
||||
wrapHint: "長い行を折り返す — 行番号を非表示",
|
||||
findHint: "検索",
|
||||
findPlaceholder: "ファイル内を検索",
|
||||
prevMatch: "前の一致 (⇧↵)",
|
||||
nextMatch: "次の一致 (↵)",
|
||||
prevMatch: "前の一致",
|
||||
nextMatch: "次の一致",
|
||||
closeHint: "閉じる (Esc)",
|
||||
disconnected: "{name} から切断されました — 保存は失敗しますが、テキストはこのウィンドウに保持されます。",
|
||||
cantOpen: "{name} を開けません",
|
||||
closeWindow: "ウィンドウを閉じる",
|
||||
shortcuts: "{mod}S 保存 · {mod}F 検索 · {mod}W 閉じる",
|
||||
verbs: { save: "保存", find: "検索", close: "閉じる" },
|
||||
lines: { other: "{count} 行" },
|
||||
close: "閉じる",
|
||||
save: "保存",
|
||||
@@ -210,6 +211,7 @@ export const ja = {
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '"{name}" をゴミ箱に移動しました',
|
||||
movedManyToTrash: { other: "{count} 個の項目をゴミ箱に移動しました" },
|
||||
editingExternal: "{name} を編集中 — 保存すると自動でアップロードされます",
|
||||
nothingToCopy: "コピーする項目がありません(シンボリックリンクはスキップされます)",
|
||||
nothingToCopyAllSkipped: "コピーする項目がありません — 競合する項目はすべてスキップされました",
|
||||
@@ -292,6 +294,129 @@ export const ja = {
|
||||
tabDisconnect: "切断",
|
||||
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: {
|
||||
sshAgentUnavailable: "ssh-agent が利用できません(SSH_AUTH_SOCK が未設定)",
|
||||
identityFileNotFound: "認証鍵ファイルが見つかりません: {path}",
|
||||
|
||||
+139
-14
@@ -22,8 +22,8 @@ export const zh = {
|
||||
tabBar: {
|
||||
connections: "连接",
|
||||
manageConnections: "管理连接",
|
||||
newTab: "新建标签页 ({mod}T)",
|
||||
closeTab: "关闭标签页 ({mod}W)",
|
||||
newTab: "新建标签页",
|
||||
closeTab: "关闭标签页",
|
||||
options: "设置",
|
||||
update: "有可用更新",
|
||||
updating: "正在更新",
|
||||
@@ -37,6 +37,7 @@ export const zh = {
|
||||
transfers: "传输",
|
||||
editor: "编辑器",
|
||||
fileList: "文件列表",
|
||||
keyboard: "键盘",
|
||||
about: "关于",
|
||||
},
|
||||
columns: "显示的列",
|
||||
@@ -52,6 +53,7 @@ export const zh = {
|
||||
systemDefault: "系统默认",
|
||||
resetDefault: "恢复为系统默认",
|
||||
finishedTransfers: { label: "已完成传输", autoClear: "自动清除", keep: "保留" },
|
||||
openFiles: { label: "回车 / 双击文件", nothing: "不执行操作", open: "打开", hint: "本地文件用默认应用打开;文件夹始终打开。" },
|
||||
},
|
||||
kinds: {
|
||||
folder: "文件夹",
|
||||
@@ -76,17 +78,16 @@ export const zh = {
|
||||
panel: {
|
||||
choosePanel: "选择此面板显示的内容",
|
||||
copy: "发送",
|
||||
copyToRight: "发送到右侧面板 (F5)",
|
||||
copyToLeft: "发送到左侧面板 (F5)",
|
||||
copyToRight: "发送到右侧面板",
|
||||
copyToLeft: "发送到左侧面板",
|
||||
edit: "编辑",
|
||||
editFile: "编辑文件 (F4)",
|
||||
rename: "重命名 (F2)",
|
||||
newFolder: "新建文件夹 (F7)",
|
||||
editFile: "编辑文件",
|
||||
rename: "重命名",
|
||||
newFolder: "新建文件夹",
|
||||
newFile: "新建文件",
|
||||
permissions: "权限",
|
||||
delete: "删除",
|
||||
deleteHint: "删除 (F8 / Del)",
|
||||
parentDir: "上级目录 (退格)",
|
||||
parentDir: "上级目录",
|
||||
refresh: "刷新",
|
||||
clearFilter: "清除筛选 (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
@@ -145,16 +146,16 @@ export const zh = {
|
||||
noFileTitle: "没有要编辑的文件",
|
||||
noFileBody: "此窗口在没有编辑器上下文的情况下打开。请从应用中打开文件。",
|
||||
wrap: "换行",
|
||||
wrapHint: "长行自动换行 — 隐藏行号 ({alt}Z)",
|
||||
findHint: "查找 ({mod}F)",
|
||||
wrapHint: "长行自动换行 — 隐藏行号",
|
||||
findHint: "查找",
|
||||
findPlaceholder: "在文件中查找",
|
||||
prevMatch: "上一个匹配 (⇧↵)",
|
||||
nextMatch: "下一个匹配 (↵)",
|
||||
prevMatch: "上一个匹配",
|
||||
nextMatch: "下一个匹配",
|
||||
closeHint: "关闭 (Esc)",
|
||||
disconnected: "已从 {name} 断开 — 保存将失败,但你的文本会保留在此窗口中。",
|
||||
cantOpen: "无法打开 {name}",
|
||||
closeWindow: "关闭窗口",
|
||||
shortcuts: "{mod}S 保存 · {mod}F 查找 · {mod}W 关闭",
|
||||
verbs: { save: "保存", find: "查找", close: "关闭" },
|
||||
lines: { other: "{count} 行" },
|
||||
close: "关闭",
|
||||
save: "保存",
|
||||
@@ -210,6 +211,7 @@ export const zh = {
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '已将 "{name}" 移到废纸篓',
|
||||
movedManyToTrash: { other: "已将 {count} 个项目移到废纸篓" },
|
||||
editingExternal: "正在编辑 {name} — 保存后会自动上传",
|
||||
nothingToCopy: "没有可复制的内容(符号链接将被跳过)",
|
||||
nothingToCopyAllSkipped: "没有可复制的内容 — 所有冲突项都被跳过",
|
||||
@@ -292,6 +294,129 @@ export const zh = {
|
||||
tabDisconnect: "断开连接",
|
||||
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: {
|
||||
sshAgentUnavailable: "ssh-agent 不可用(未设置 SSH_AUTH_SOCK)",
|
||||
identityFileNotFound: "找不到身份文件:{path}",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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("+");
|
||||
}
|
||||
@@ -157,6 +157,8 @@ export type AppRPC = {
|
||||
// settings window
|
||||
/** Main window: open (or focus) the single Settings window. */
|
||||
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)
|
||||
/** Main window: re-run the update check on demand (menu item). */
|
||||
checkForUpdate: {
|
||||
|
||||
@@ -91,6 +91,14 @@ export interface AppSettings {
|
||||
editor: "builtin" | "custom";
|
||||
/** Full path to the external editor .app; empty = OS default application. */
|
||||
editorApp: string;
|
||||
/**
|
||||
* Keyboard remaps from Settings → Keyboard: command id → combo strings, only
|
||||
* for rows that differ from the defaults ([] unbinds). Format and sanitizing
|
||||
* live in keymap.ts — a hand-edited entry it can't parse is simply ignored.
|
||||
*/
|
||||
keybindings: Record<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
|
||||
// on for now; uncomment (+ the settings.service and tab-bar breadcrumbs, and
|
||||
// gate the poll/watch in panels.service) to expose a toggle later.
|
||||
@@ -109,6 +117,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||
autoClearCompleted: true,
|
||||
editor: "builtin",
|
||||
editorApp: "",
|
||||
keybindings: {},
|
||||
openFilesOnEnter: false,
|
||||
// autoRefresh: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,30 +1,51 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
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 { IS_MAC, IS_WINDOWS, MOD_LABEL } from './platform';
|
||||
import { stepIndex } from './menu-nav';
|
||||
import type { Side } from './panels.service';
|
||||
import { IS_MAC, IS_WINDOWS } from './platform';
|
||||
import { RpcService } from './rpc.service';
|
||||
import type { ShortcutId } from './shortcuts.service';
|
||||
|
||||
/** Optional list columns, in display order (Name is fixed). */
|
||||
export const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group'];
|
||||
|
||||
export interface MenuItem {
|
||||
type?: 'separator';
|
||||
/** separator: a rule; header: a non-pickable section title (the source picker). */
|
||||
type?: 'separator' | 'header';
|
||||
label?: string;
|
||||
/** Emitted verbatim to the action dispatcher (`ctx-*` / `col:*` / `tab:*`). */
|
||||
/** Handed to the request's onPick, else emitted to the action dispatcher
|
||||
* (`col:*` / `tab:*`). */
|
||||
action?: string;
|
||||
checked?: boolean;
|
||||
tooltip?: string;
|
||||
/** Shortcut hint, rendered right-aligned. */
|
||||
accelerator?: string;
|
||||
/** The keymap command the item runs: its first key shows as the soft hint,
|
||||
* and pressing that key while the menu is open picks the item. */
|
||||
shortcut?: ShortcutId;
|
||||
/** A 14px icon (core/icons.ts), drawn in the check column. */
|
||||
icon?: Icon14;
|
||||
/** Greyed and unclickable. Menus disable rather than hide, so the set of
|
||||
* commands stays in the same place from one right-click to the next. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** Anything with a viewport point: a MouseEvent, or a row's rect for a
|
||||
* keyboard-opened menu. */
|
||||
export interface MenuPoint {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
export interface MenuRequest {
|
||||
x: number;
|
||||
y: number;
|
||||
items: MenuItem[];
|
||||
/** Highlighted on open: the first item when the keyboard opened the menu. */
|
||||
initialIndex: number;
|
||||
/** Where a pick goes instead of the shared dispatcher (App.handleMenuAction). */
|
||||
onPick?: (action: string) => void;
|
||||
}
|
||||
|
||||
export interface TabMenuContext {
|
||||
@@ -38,25 +59,27 @@ export interface TabMenuContext {
|
||||
}
|
||||
|
||||
export interface RowMenuContext {
|
||||
path: string;
|
||||
side: Side;
|
||||
name: string;
|
||||
type: EntryType;
|
||||
isLocal: boolean;
|
||||
hasEntry: boolean; // false = right-click on empty panel space
|
||||
}
|
||||
|
||||
/** Items the highlight can land on. */
|
||||
export const isSelectable = (item: MenuItem): boolean => !item.type && !item.disabled;
|
||||
|
||||
// PORTABILITY: shell-integration wording per OS (resolved at build time for locale)
|
||||
const revealKey = (): string =>
|
||||
IS_MAC ? 'ctx.revealFinder' : IS_WINDOWS ? 'ctx.revealExplorer' : 'ctx.revealFileManager';
|
||||
const trashKey = (): string => (IS_WINDOWS ? 'ctx.moveToRecycleBin' : 'ctx.moveToTrash');
|
||||
|
||||
/**
|
||||
* Builds and opens the panel right-click menus. This used to be a native
|
||||
* Builds and opens the HTML context menus. This used to be a native
|
||||
* electrobun ContextMenu on the bun side, but `showContextMenu` is a documented
|
||||
* no-op on Linux (electrobun's own .hutch/devkit/api/sdks/main/proc/linux.md),
|
||||
* which left the whole right-click surface dead there. The menu is plain HTML
|
||||
* now — one code path on every platform — and still emits the exact same
|
||||
* action strings, so the dispatcher in app.ts is unchanged.
|
||||
* now — one code path on every platform.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ContextMenuService {
|
||||
@@ -65,76 +88,91 @@ export class ContextMenuService {
|
||||
|
||||
readonly request = signal<MenuRequest | null>(null);
|
||||
|
||||
open(event: MouseEvent, items: MenuItem[]): void {
|
||||
this.request.set({ x: event.clientX, y: event.clientY, items });
|
||||
open(
|
||||
point: MenuPoint,
|
||||
items: MenuItem[],
|
||||
opts: { keyboard?: boolean; onPick?: (action: string) => void } = {},
|
||||
): void {
|
||||
const initialIndex = opts.keyboard ? stepIndex(items.map(isSelectable), -1, 1) : -1;
|
||||
this.request.set({ x: point.clientX, y: point.clientY, items, initialIndex, onPick: opts.onPick });
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.request.set(null);
|
||||
}
|
||||
|
||||
/** A picked item goes through the same dispatcher the app menubar uses. */
|
||||
onPick(action: string): void {
|
||||
/** A pick without its own handler goes through the dispatcher the app
|
||||
* menubar uses. */
|
||||
emit(action: string): void {
|
||||
this.rpc.emitMenuAction(action);
|
||||
}
|
||||
|
||||
/** Row / empty-space menu for a file panel. */
|
||||
/**
|
||||
* Row / empty-space menu for a file panel. Actions are FileCommand ids,
|
||||
* picked through FileActionsService.run. Icons only on the everyday verbs —
|
||||
* the same glyphs as their toolbar buttons.
|
||||
*/
|
||||
rowItems(ctx: RowMenuContext): MenuItem[] {
|
||||
const t = (key: string) => this.i18n.t(key);
|
||||
const cmd = (id: FileCommand, label: string, extra: Partial<MenuItem> = {}): MenuItem => ({
|
||||
label,
|
||||
action: id,
|
||||
shortcut: id,
|
||||
...extra,
|
||||
});
|
||||
const items: MenuItem[] = [];
|
||||
|
||||
if (!ctx.hasEntry || ctx.name === '..') {
|
||||
// empty space (or the parent row): folder-level actions
|
||||
items.push({ label: t('ctx.newFolder'), action: 'ctx-new-folder' });
|
||||
items.push({ label: t('ctx.newFile'), action: 'ctx-new-file' });
|
||||
items.push(cmd('new-folder', t('ctx.newFolder'), { icon: 'folderAdd14' }));
|
||||
items.push(cmd('new-file', t('ctx.newFile')));
|
||||
items.push({ type: 'separator' });
|
||||
items.push({ label: t('ctx.refresh'), action: 'ctx-refresh' });
|
||||
items.push(cmd('refresh', t('ctx.refresh')));
|
||||
if (ctx.isLocal) {
|
||||
items.push({ type: 'separator' });
|
||||
items.push({ label: t(revealKey()), action: 'ctx-reveal' });
|
||||
items.push(cmd('reveal', t(revealKey())));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
items.push({ label: t('ctx.copyToOther'), action: 'ctx-copy' });
|
||||
// the arrow points at the other panel, as on the toolbar's Send button
|
||||
const sendIcon = ctx.side === 'left' ? 'arrowRight14' : 'arrowLeft14';
|
||||
items.push(cmd('send', t('ctx.copyToOther'), { icon: sendIcon }));
|
||||
if (ctx.type === 'dir') {
|
||||
items.push({ label: t('ctx.open'), action: 'ctx-open' });
|
||||
items.push(cmd('open', t('ctx.open')));
|
||||
} else {
|
||||
if (ctx.isLocal) {
|
||||
items.push({ label: t('ctx.open'), action: 'ctx-open-default', tooltip: t('ctx.openDefault') });
|
||||
items.push(cmd('open-default', t('ctx.open'), { tooltip: t('ctx.openDefault') }));
|
||||
}
|
||||
items.push({ label: t('ctx.edit'), action: 'ctx-edit', accelerator: 'F4' });
|
||||
items.push(cmd('edit', t('ctx.edit'), { icon: '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
|
||||
if (!(ctx.isLocal && IS_WINDOWS)) {
|
||||
items.push({ label: t('ctx.permissions'), action: 'ctx-chmod' });
|
||||
items.push(cmd('chmod', t('ctx.permissions')));
|
||||
}
|
||||
items.push({ type: 'separator' });
|
||||
items.push({ label: t('ctx.copyPath'), action: 'ctx-copy-path' });
|
||||
items.push(cmd('copy-path', t('ctx.copyPath')));
|
||||
if (ctx.isLocal) {
|
||||
items.push({ label: t(revealKey()), action: 'ctx-reveal' });
|
||||
items.push(cmd('reveal', t(revealKey())));
|
||||
}
|
||||
items.push({ type: 'separator' });
|
||||
if (ctx.isLocal) {
|
||||
items.push({
|
||||
label: t(trashKey()),
|
||||
action: 'ctx-trash',
|
||||
...(IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}),
|
||||
});
|
||||
items.push(cmd('trash', t(trashKey()), IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}));
|
||||
}
|
||||
items.push({ label: t('ctx.delete'), action: 'ctx-delete' });
|
||||
items.push(cmd('delete', t('ctx.delete'), { icon: 'delete14' }));
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace-tab menu. Actions carry the target tab's id, because the
|
||||
* right-clicked tab is not necessarily the active one — nothing here may
|
||||
* assume `activeWorkspace`.
|
||||
* assume `activeWorkspace`. (The hints name the keys, which act on the
|
||||
* ACTIVE tab; picking by key while this menu is open acts on THIS one.)
|
||||
*
|
||||
* Everything illegal is DISABLED, not omitted: a menu whose items move
|
||||
* between right-clicks is worse than a dead row, and "Close Tab" greyed out explains
|
||||
* why the last tab cannot be closed better than its absence does.
|
||||
* between right-clicks is worse than a dead row, and "Close Tab" greyed out
|
||||
* explains why the last tab cannot be closed better than its absence does.
|
||||
*
|
||||
* Duplicate/Rename/Save-session are deliberately absent until tabs have an
|
||||
* identity to duplicate (a stored connection id) — the sessions round.
|
||||
@@ -143,24 +181,32 @@ export class ContextMenuService {
|
||||
const t = (key: string) => this.i18n.t(key);
|
||||
const last = ctx.tabCount <= 1;
|
||||
return [
|
||||
{ label: t('ctx.tabNew'), action: 'tab:new', accelerator: `${MOD_LABEL}T` },
|
||||
{ label: t('ctx.tabNew'), action: 'tab:new', shortcut: 'new-tab', icon: 'add14' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: t('ctx.tabClose'),
|
||||
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,
|
||||
},
|
||||
{ label: t('ctx.tabCloseOthers'), action: `tab:close-others:${ctx.id}`, disabled: last },
|
||||
{
|
||||
label: t('ctx.tabCloseRight'),
|
||||
action: `tab:close-right:${ctx.id}`,
|
||||
shortcut: 'close-tabs-right',
|
||||
disabled: ctx.index >= ctx.tabCount - 1,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: t('ctx.tabDisconnect'),
|
||||
action: `tab:disconnect:${ctx.id}`,
|
||||
shortcut: 'disconnect-tab',
|
||||
disabled: !ctx.isRemote,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -47,8 +47,10 @@ export class DialogsService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Any dialog up — the overwrite question included. Reads signals, so an
|
||||
* effect or computed that asks re-runs when a dialog opens or closes. */
|
||||
get anyOpen(): boolean {
|
||||
return !!(this.prompt() || this.confirm() || this.chmod());
|
||||
return !!(this.prompt() || this.confirm() || this.chmod() || this.overwrite());
|
||||
}
|
||||
|
||||
closeAll(): void {
|
||||
|
||||
@@ -1,25 +1,50 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import type { FileEntry } from '@shared/types';
|
||||
import { ContextMenuService } from './context-menu.service';
|
||||
import { ContextMenuService, type MenuPoint } from './context-menu.service';
|
||||
import { DialogsService } from './dialogs.service';
|
||||
import { joinPath } from './format';
|
||||
import { type PanelState, type Side } from './panels.service';
|
||||
import { type PanelState, type Side, type SortKey } from './panels.service';
|
||||
import { IS_WINDOWS } from './platform';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
import type { ShortcutId } from './shortcuts.service';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { ToastService } from './toast.service';
|
||||
import { TransfersService } from './transfers.service';
|
||||
|
||||
/** The row (or empty space) the user last right-clicked; ctx-* actions apply here. */
|
||||
export interface MenuTarget {
|
||||
/** What a file command acts on: a panel, and the row it was invoked on (the
|
||||
* cursor row from the keyboard, the right-clicked row from a menu; null =
|
||||
* the folder itself). */
|
||||
export interface FileTarget {
|
||||
side: Side;
|
||||
panel: PanelState;
|
||||
entry: FileEntry | null;
|
||||
}
|
||||
|
||||
/** The keymap commands that act on files — everything the keyboard, the
|
||||
* right-click menu, the macOS menu bar and the panel toolbar run, all through
|
||||
* run() so the four entry points can't drift apart. */
|
||||
export type FileCommand = Extract<
|
||||
ShortcutId,
|
||||
| 'open'
|
||||
| 'open-default'
|
||||
| 'send'
|
||||
| 'edit'
|
||||
| 'rename'
|
||||
| 'new-folder'
|
||||
| 'new-file'
|
||||
| 'chmod'
|
||||
| 'delete'
|
||||
| 'trash'
|
||||
| 'copy-path'
|
||||
| 'reveal'
|
||||
| 'refresh'
|
||||
| `sort-${string}`
|
||||
>;
|
||||
|
||||
/**
|
||||
* File operations invoked from a panel's toolbar / keyboard. They act on the
|
||||
* GIVEN panel: on the selection if there is one, else on the cursor row.
|
||||
* File operations, always against an explicit panel: on its selection if there
|
||||
* is one, else on the target row.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FileActionsService {
|
||||
@@ -29,29 +54,101 @@ export class FileActionsService {
|
||||
private readonly dialogs = inject(DialogsService);
|
||||
private readonly menu = inject(ContextMenuService);
|
||||
private readonly settings = inject(SettingsService);
|
||||
private readonly transfers = inject(TransfersService);
|
||||
|
||||
menuTarget: MenuTarget | null = null;
|
||||
/** Run a file command. One switch for every entry point. */
|
||||
run(id: FileCommand, target: FileTarget): void {
|
||||
const { side, panel } = target;
|
||||
const local = panel.sessionId() === 'local';
|
||||
// the ".." row stands for the folder itself, as on the empty-space menu
|
||||
const entry = target.entry && target.entry.name !== '..' ? target.entry : null;
|
||||
switch (id) {
|
||||
case 'open':
|
||||
case 'open-default':
|
||||
// folders, symlinks and ".." navigate; a LOCAL file opens in its default
|
||||
// app — from the menu's Open always, from Enter / double-click only
|
||||
// when Settings → File list says so
|
||||
if (!target.entry) return;
|
||||
if (target.entry.type !== 'file') panel.open(target.entry);
|
||||
else if (local && (id === 'open-default' || this.settings.openFilesOnEnter())) {
|
||||
this.shell('open', target.entry.path);
|
||||
}
|
||||
return;
|
||||
case 'send':
|
||||
void this.transfers.copyToOtherPanel(side);
|
||||
return;
|
||||
case 'edit':
|
||||
void this.edit(panel);
|
||||
return;
|
||||
case 'rename':
|
||||
this.rename(panel);
|
||||
return;
|
||||
case 'new-folder':
|
||||
this.mkdir(panel);
|
||||
return;
|
||||
case 'new-file':
|
||||
this.createFile(panel);
|
||||
return;
|
||||
case 'chmod':
|
||||
this.chmod(panel);
|
||||
return;
|
||||
case 'delete':
|
||||
this.delete(panel);
|
||||
return;
|
||||
case 'trash':
|
||||
if (local) void this.trash(panel);
|
||||
return;
|
||||
case 'copy-path':
|
||||
this.shell('copyPath', entry?.path ?? panel.cwd());
|
||||
return;
|
||||
case 'reveal':
|
||||
if (local) this.shell('reveal', entry?.path ?? panel.cwd());
|
||||
return;
|
||||
case 'refresh':
|
||||
void panel.refresh();
|
||||
return;
|
||||
default:
|
||||
// sort-name, sort-size…: again = reverse
|
||||
if (id.startsWith('sort-')) panel.toggleSort(id.slice('sort-'.length) as SortKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** Right-click entry point: remember the target, then open the menu on it. */
|
||||
openContextMenu(event: MouseEvent, target: MenuTarget): void {
|
||||
this.menuTarget = target;
|
||||
const { panel, entry } = target;
|
||||
/** Open the row / empty-space menu on `target`; its picks run here. */
|
||||
openContextMenu(point: MenuPoint, target: FileTarget, keyboard = false): void {
|
||||
const { side, panel, entry } = target;
|
||||
this.menu.open(
|
||||
event,
|
||||
point,
|
||||
this.menu.rowItems({
|
||||
path: entry?.path ?? panel.cwd(),
|
||||
side,
|
||||
name: entry?.name ?? '',
|
||||
type: entry?.type ?? 'dir',
|
||||
isLocal: panel.sessionId() === 'local',
|
||||
hasEntry: !!entry,
|
||||
}),
|
||||
{ keyboard, onPick: (action) => this.run(action as FileCommand, target) },
|
||||
);
|
||||
}
|
||||
|
||||
async trash(panel: PanelState, entry: FileEntry): Promise<void> {
|
||||
const res = await this.rpc.request.moveToTrash({ path: entry.path });
|
||||
if (!res.ok) this.toast.error(res.error);
|
||||
else this.toast.info(this.i18n.t('toasts.movedToTrash', { name: entry.name }));
|
||||
/** Local shell integration — only bun can reach the OS shell / clipboard. */
|
||||
private shell(action: 'open' | 'reveal' | 'copyPath', path: string): void {
|
||||
void this.rpc.request.shellItemAction({ action, path });
|
||||
}
|
||||
|
||||
/** Move the selection (else the target row) to the Trash, one item at a time. */
|
||||
async trash(panel: PanelState): Promise<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();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// 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.
|
||||
import type { FileEntry } from '@shared/types';
|
||||
import type { Icon16 } from './icons';
|
||||
|
||||
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 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: {
|
||||
glyph: 'tdesignImage',
|
||||
glyph: 'image16',
|
||||
class: 'shrink-0 text-icon-image',
|
||||
extensions: 'png jpg jpeg gif webp avif svg ico icns bmp tiff tif heic heif raw'.split(' '),
|
||||
},
|
||||
audio: {
|
||||
glyph: 'tdesignMusic',
|
||||
glyph: 'music16',
|
||||
class: 'shrink-0 text-icon-media',
|
||||
extensions: 'mp3 wav flac aac ogg oga m4a opus wma aiff aif mid midi'.split(' '),
|
||||
},
|
||||
video: {
|
||||
glyph: 'tdesignVideo',
|
||||
glyph: 'video16',
|
||||
class: 'shrink-0 text-icon-media',
|
||||
extensions: 'mp4 mkv mov avi webm m4v mpg mpeg wmv flv 3gp'.split(' '),
|
||||
},
|
||||
archive: {
|
||||
glyph: 'tdesignFileZip',
|
||||
glyph: 'fileZip16',
|
||||
class: 'shrink-0 text-icon-archive',
|
||||
extensions: 'zip tar gz tgz bz2 xz zst 7z rar dmg iso jar deb rpm pkg apk'.split(' '),
|
||||
},
|
||||
code: {
|
||||
glyph: 'tdesignCode',
|
||||
glyph: 'code16',
|
||||
class: 'shrink-0 text-icon-code',
|
||||
// ".ts" is TypeScript here, not an MPEG transport stream (deliberate)
|
||||
extensions: (
|
||||
@@ -45,7 +46,7 @@ const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: s
|
||||
).split(' '),
|
||||
},
|
||||
doc: {
|
||||
glyph: 'tdesignFile1',
|
||||
glyph: 'fileText16',
|
||||
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(' '),
|
||||
},
|
||||
@@ -84,12 +85,12 @@ export function fileKindKey(entry: FileEntry): string {
|
||||
return category ? KIND_KEYS[category] : 'file';
|
||||
}
|
||||
|
||||
export function fileGlyph(entry: FileEntry): string {
|
||||
if (entry.name === '..') return 'tdesignArrowUp';
|
||||
if (entry.type === 'dir') return 'tdesignFolder';
|
||||
if (entry.type === 'symlink') return 'tdesignLink';
|
||||
export function fileGlyph(entry: FileEntry): Icon16 {
|
||||
if (entry.name === '..') return 'arrowUp16';
|
||||
if (entry.type === 'dir') return 'folder16';
|
||||
if (entry.type === 'symlink') return 'link16';
|
||||
const category = categoryOf(entry.name);
|
||||
return category ? CATEGORIES[category].glyph : 'tdesignFile';
|
||||
return category ? CATEGORIES[category].glyph : 'file16';
|
||||
}
|
||||
|
||||
export function fileIconClass(entry: FileEntry): string {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -1,73 +1,19 @@
|
||||
// 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
|
||||
// plain strings in templates — the build never checks them, so every name used
|
||||
// anywhere must be listed here (an unregistered one renders nothing).
|
||||
import {
|
||||
tdesignAdd,
|
||||
tdesignArrowLeft,
|
||||
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";
|
||||
// A name is <icon><px>: the pixel grid the glyph is drawn for, which must match
|
||||
// the size token where it renders (pixel-icons.ts). Templates can't type-check
|
||||
// names and an unregistered one renders nothing (a console warning only), so
|
||||
// icons.spec.ts checks every template name; TS code takes the Icon14/Icon16 types.
|
||||
import { tdesignCloseCircle, tdesignLoading } from "@ng-icons/tdesign-icons";
|
||||
import { PIXEL_ICONS } from "./pixel-icons";
|
||||
|
||||
export const APP_ICONS = {
|
||||
tdesignAdd,
|
||||
tdesignArrowLeft,
|
||||
tdesignArrowRight,
|
||||
tdesignArrowUp,
|
||||
tdesignCheck,
|
||||
tdesignCheckCircle,
|
||||
tdesignChevronDown,
|
||||
tdesignChevronRight,
|
||||
tdesignChevronUp,
|
||||
tdesignClose,
|
||||
tdesignCloseCircle,
|
||||
tdesignCode,
|
||||
tdesignDelete,
|
||||
tdesignDownload,
|
||||
tdesignEdit,
|
||||
tdesignFile,
|
||||
tdesignFile1,
|
||||
tdesignFileAdd,
|
||||
tdesignFileZip,
|
||||
tdesignFolder,
|
||||
tdesignFolderAdd,
|
||||
tdesignHardDrive,
|
||||
tdesignImage,
|
||||
tdesignLink,
|
||||
...PIXEL_ICONS,
|
||||
// TDesign's own 24px grid: as sharp as a circle and its diagonals can be
|
||||
closeCircle24: tdesignCloseCircle,
|
||||
// spins, so no pixel grid holds; any size
|
||||
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`>;
|
||||
|
||||
@@ -352,6 +352,19 @@ export class PanelsService {
|
||||
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 ----
|
||||
|
||||
newWorkspace(): void {
|
||||
|
||||
@@ -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')),
|
||||
};
|
||||
@@ -21,10 +21,6 @@ export const IS_WINDOWS = PLATFORM === 'win32';
|
||||
|
||||
export const IS_LINUX = PLATFORM === 'linux';
|
||||
|
||||
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
|
||||
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
|
||||
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
|
||||
|
||||
export const APP_MODE: 'main' | 'editor' | 'manager' | 'settings' =
|
||||
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
||||
|
||||
@@ -35,6 +31,18 @@ export const EDITOR_CONTEXT =
|
||||
* hiddenInset layout, see chromeOptions), which reserves `pl-24` for them. */
|
||||
export const IS_MAC_CHROME = IS_MAC;
|
||||
|
||||
/**
|
||||
* WebView2 (Windows) reloads the whole page on F5 / Ctrl+R / Ctrl+F5 /
|
||||
* Ctrl+Shift+R unless the page claims the key — dropping every session and any
|
||||
* unsaved editor text. Every window root calls this for keys none of its
|
||||
* bindings took. Known ceiling: unverified whether Electrobun already turns
|
||||
* WebView2's browser keys off; harmless if it does.
|
||||
*/
|
||||
export function blockReloadKeys(event: KeyboardEvent): void {
|
||||
if (!IS_WINDOWS || event.defaultPrevented) return;
|
||||
if (event.key === 'F5' || (event.ctrlKey && event.code === 'KeyR')) event.preventDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the webview's default right-click menu (Reload, Inspect Element…)
|
||||
* everywhere the app doesn't show its own native menu. Text fields keep the
|
||||
|
||||
@@ -17,6 +17,9 @@ export class SettingsService {
|
||||
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
|
||||
readonly editor = signal<AppSettings['editor']>(DEFAULT_SETTINGS.editor);
|
||||
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
|
||||
// user-controllable: uncomment the field in types.ts, then here add
|
||||
// readonly autoRefresh = signal(DEFAULT_SETTINGS.autoRefresh);
|
||||
@@ -25,6 +28,10 @@ export class SettingsService {
|
||||
|
||||
private readonly rpc = inject(RpcService);
|
||||
private readonly i18n = inject(I18nService);
|
||||
/** Saves are whole-object: one sent before the first getSettings lands would
|
||||
* write these defaults over the user's settings (a ⌘+ pressed as a window
|
||||
* opens would wipe every keyboard remap), so nothing persists until then. */
|
||||
private loaded = false;
|
||||
|
||||
/** Whether the app is actually rendering dark right now (drives the Tone row). */
|
||||
readonly effectiveDark = computed(
|
||||
@@ -45,6 +52,7 @@ export class SettingsService {
|
||||
}
|
||||
|
||||
private applyAll(settings: AppSettings): void {
|
||||
this.loaded = true;
|
||||
this.locale.set(settings.locale);
|
||||
this.theme.set(settings.theme);
|
||||
this.darkTone.set(settings.darkTone);
|
||||
@@ -56,6 +64,8 @@ export class SettingsService {
|
||||
this.autoClearCompleted.set(settings.autoClearCompleted);
|
||||
this.editor.set(settings.editor);
|
||||
this.editorApp.set(settings.editorApp);
|
||||
this.keybindings.set(settings.keybindings);
|
||||
this.openFilesOnEnter.set(settings.openFilesOnEnter);
|
||||
this.applyTheme();
|
||||
this.applyDarkTone();
|
||||
this.applyUiScale();
|
||||
@@ -123,6 +133,16 @@ export class SettingsService {
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
setKeybindings(keybindings: AppSettings['keybindings']): void {
|
||||
this.keybindings.set(keybindings);
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
setOpenFilesOnEnter(open: boolean): void {
|
||||
this.openFilesOnEnter.set(open);
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
private applyTheme(): void {
|
||||
const theme = this.theme();
|
||||
if (theme === 'system') {
|
||||
@@ -172,10 +192,13 @@ export class SettingsService {
|
||||
autoClearCompleted: this.autoClearCompleted(),
|
||||
editor: this.editor(),
|
||||
editorApp: this.editorApp(),
|
||||
keybindings: this.keybindings(),
|
||||
openFilesOnEnter: this.openFilesOnEnter(),
|
||||
};
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
if (!this.loaded) return;
|
||||
await this.rpc.request.saveSettings({ settings: this.settings() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* on these ids instead of key-matching inline, so the whole keymap is ONE
|
||||
* table — the precondition for the vim-style bindings planned next, which
|
||||
* will need a mode/leader state machine wrapped around exactly this point.
|
||||
*/
|
||||
export type ShortcutId =
|
||||
// main window (workspace/tab management)
|
||||
| 'new-tab'
|
||||
| 'close-tab'
|
||||
| 'cycle-tab'
|
||||
| 'cycle-tab-back'
|
||||
| 'go-to-tab' // 1-9; the digit rides in ShortcutMatch.digit
|
||||
| 'open-settings'
|
||||
// editor window
|
||||
| 'save'
|
||||
| 'find'
|
||||
| 'close-window'
|
||||
| 'toggle-wrap'
|
||||
// any window
|
||||
| 'scale-up'
|
||||
| 'scale-down'
|
||||
| 'scale-reset';
|
||||
|
||||
export interface ShortcutMatch {
|
||||
id: ShortcutId;
|
||||
/** Only for go-to-tab: the digit pressed, 1–9. */
|
||||
digit?: number;
|
||||
}
|
||||
|
||||
/** Which keymap a window consults. 'manager' and 'settings' answer only to the
|
||||
* any-window shortcuts (scale). Escape/Tab stay component-local: their meaning
|
||||
* is contextual state (find bar open? close prompt up? which pane?), not a
|
||||
* global binding. */
|
||||
export type ShortcutScope = 'main' | 'editor' | 'manager' | 'settings';
|
||||
|
||||
/**
|
||||
* The keyboard layer: modifier helpers + one declarative keymap.
|
||||
* The keyboard layer over the shared keymap (src/shared/keymap.ts): resolves
|
||||
* keydowns against the EFFECTIVE keymap — defaults plus the Settings → Keyboard
|
||||
* remaps, live in every window — and formats the hints menus and tooltips show.
|
||||
*
|
||||
* House rules encoded here rather than re-derived per component (they were
|
||||
* previously copy-pasted inline in six places, drifting apart):
|
||||
* - `mod` is metaKey OR ctrlKey — ⌘ on macOS, Ctrl elsewhere. The keyboard
|
||||
* does not change with the window chrome (platform.ts says the same about
|
||||
* MOD_LABEL).
|
||||
* - Layout-dependent keys match on `event.code`, not `event.key`: digits
|
||||
* (AZERTY's digit row shifts), +/- (key varies wildly across layouts), and
|
||||
* letter chords with Alt (macOS Option produces Ω for ⌥Z). Plain letter
|
||||
* chords match on `key` so they follow the user's layout.
|
||||
* - isTypingTarget guards shortcuts that would be hostile mid-typing. It is
|
||||
* deliberately NOT applied inside resolve(): the existing bindings rely on
|
||||
* the path bar's stopPropagation and the editor WANTS Mod+S while typing.
|
||||
* Callers opt in per shortcut (the scale keys do).
|
||||
* - Matching is exact: ⌃F is not ⌘F, Ctrl+Tab is not Tab. `Mod` is ⌘ on
|
||||
* macOS and Ctrl elsewhere.
|
||||
* - On macOS the PAGE sees ⌘-keys first: WKWebView hands key equivalents to the
|
||||
* page before the app menu (WebKit's WebViewImpl::performKeyEquivalent), so a
|
||||
* key a window claims here (claim() → preventDefault) never reaches the menu
|
||||
* bar, whose accelerators bun builds from the same table. Keys nobody claims
|
||||
* fall through to it.
|
||||
* - From a text field only firesInTextField() combos count (⌘S yes, Delete no).
|
||||
* - Escape, the arrows inside menus and dialog Enter stay component-local:
|
||||
* they are fixed, so nobody can remap themselves out of a dialog.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ShortcutsService {
|
||||
/** The platform command modifier: ⌘ on macOS, Ctrl elsewhere. Mouse events
|
||||
* qualify too — Mod+click toggle-select in the panels uses the same rule. */
|
||||
isMod(event: KeyboardEvent | MouseEvent): boolean {
|
||||
return event.metaKey || event.ctrlKey;
|
||||
}
|
||||
private readonly settings = inject(SettingsService);
|
||||
private readonly i18n = inject(I18nService);
|
||||
|
||||
/** True when the event originates from something the user types into. */
|
||||
isTypingTarget(event: KeyboardEvent): boolean {
|
||||
const target = event.target as HTMLElement | null;
|
||||
return !!target?.closest('input, textarea, select, [contenteditable="true"]');
|
||||
readonly keymap = computed(() => effectiveKeymap(this.settings.keybindings(), IS_MAC));
|
||||
private readonly byId = computed(() => new Map(this.keymap().map((b) => [b.id, b])));
|
||||
/** Localized key names for the non-mac spelling ("Strg+Entf"); reading t()
|
||||
* makes every hint follow a language switch. */
|
||||
private readonly names = computed<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
|
||||
* table does not claim — callers must then leave the event alone.
|
||||
* resolve() + take the key: a match is preventDefault-ed, so the macOS menu
|
||||
* never runs it a second time. A held key repeats only movement-like
|
||||
* commands; any other repeat is swallowed (holding ⌘W closes one tab).
|
||||
*/
|
||||
resolve(event: KeyboardEvent, scope: ShortcutScope): ShortcutMatch | null {
|
||||
const mod = this.isMod(event);
|
||||
const key = event.key.toLowerCase();
|
||||
claim(event: KeyboardEvent, scope: Scope): ShortcutId | null {
|
||||
const id = this.resolve(event, scope);
|
||||
if (!id) return null;
|
||||
event.preventDefault();
|
||||
return event.repeat && !isRepeatable(id) ? null : id;
|
||||
}
|
||||
|
||||
// ---- any window: interface scale ----
|
||||
// event.code, not key: '+' needs Shift on many layouts and '-' moves
|
||||
// around; the physical Equal/Minus keys (and the numpad pair) are stable.
|
||||
if (mod && !event.altKey) {
|
||||
if (event.code === 'Equal' || event.code === 'NumpadAdd') return { id: 'scale-up' };
|
||||
if (event.code === 'Minus' || event.code === 'NumpadSubtract') return { id: 'scale-down' };
|
||||
if (event.code === 'Digit0' || event.code === 'Numpad0') return { id: 'scale-reset' };
|
||||
/** Whether a keydown is one of `id`'s combos (an open menu matching its items). */
|
||||
matches(id: ShortcutId, event: KeyEventLike): boolean {
|
||||
return !!this.byId().get(id)?.combos.some((c) => matchesEvent(c, event, IS_MAC));
|
||||
}
|
||||
|
||||
binding(id: ShortcutId): ResolvedBinding | undefined {
|
||||
return this.byId().get(id);
|
||||
}
|
||||
|
||||
format(combo: Combo): string {
|
||||
return formatCombo(combo, IS_MAC, this.names());
|
||||
}
|
||||
|
||||
/** The hint menus show: the command's first key, or '' when unbound. */
|
||||
label(id: ShortcutId): string {
|
||||
const first = this.byId().get(id)?.combos[0];
|
||||
return first ? this.format(first) : '';
|
||||
}
|
||||
|
||||
labels(id: ShortcutId): string[] {
|
||||
return (this.byId().get(id)?.combos ?? []).map((c) => this.format(c));
|
||||
}
|
||||
|
||||
/** A tooltip with the command's keys: "Delete (F8 / Del)"; bare when unbound. */
|
||||
tip(text: string, id: ShortcutId): string {
|
||||
const keys = this.labels(id);
|
||||
return keys.length ? `${text} (${keys.join(' / ')})` : text;
|
||||
}
|
||||
|
||||
/** 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') {
|
||||
// on macOS the app menu's ⌘, intercepts first; this is the Windows/Linux path
|
||||
if (mod && key === ',') return { id: 'open-settings' };
|
||||
if (mod && key === 't') return { id: 'new-tab' };
|
||||
if (mod && key === 'w') return { id: 'close-tab' };
|
||||
// ctrlKey only, never meta: ⌘Tab is the macOS app switcher
|
||||
if (event.ctrlKey && event.key === 'Tab') {
|
||||
return { id: event.shiftKey ? 'cycle-tab-back' : 'cycle-tab' };
|
||||
}
|
||||
if (mod && !event.shiftKey && !event.altKey) {
|
||||
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1];
|
||||
if (digit) return { id: 'go-to-tab', digit: Number(digit) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/** The platform command modifier: ⌘ on macOS, Ctrl elsewhere — exactly one.
|
||||
* Mouse events too: Mod+click toggle-selects in the panels. */
|
||||
isMod(event: KeyboardEvent | MouseEvent): boolean {
|
||||
return IS_MAC ? event.metaKey : event.ctrlKey;
|
||||
}
|
||||
|
||||
// scope === 'editor'
|
||||
if (mod && key === 's') return { id: 'save' };
|
||||
if (mod && key === 'f') return { id: 'find' };
|
||||
// on macOS the app menu intercepts ⌘W before this ever fires; this arm is
|
||||
// the Windows/Linux path
|
||||
if (mod && key === 'w') return { id: 'close-window' };
|
||||
if (event.altKey && event.code === 'KeyZ') return { id: 'toggle-wrap' };
|
||||
return null;
|
||||
/** True when the event comes from something the user types into. */
|
||||
isTypingTarget(event: { target?: EventTarget | null }): boolean {
|
||||
const target = event.target as HTMLElement | null;
|
||||
return !!target?.closest?.(
|
||||
'textarea, select, [contenteditable="true"], input:not([type=checkbox]):not([type=radio]):not([type=button])',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,14 @@ export class UpdaterService {
|
||||
void this.rpc.request.applyUpdate({}); // quits + relaunches
|
||||
}
|
||||
|
||||
/** The pill's one action, also the "Install update" key: download when
|
||||
* available, apply (relaunch) once ready; nothing otherwise. */
|
||||
advance(): void {
|
||||
const state = this.state();
|
||||
if (state === 'available') void this.download();
|
||||
else if (state === 'ready') this.apply();
|
||||
}
|
||||
|
||||
/** Manual re-check (menu item). */
|
||||
async check(): Promise<void> {
|
||||
if (!this.rpc.available) return;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@if (!rpc.available) {
|
||||
<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">
|
||||
<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-1 text-xs text-muted-foreground">
|
||||
{{ i18n.t('app.notConnectedBody') }}
|
||||
@@ -28,9 +28,10 @@
|
||||
(mousedown)="focusManager()"
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
(click)="focusManager()"
|
||||
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') }}
|
||||
|
||||
@@ -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 { I18nService } from '@core/i18n.service';
|
||||
import { BRANDING } from '@shared/branding';
|
||||
import { NATIVE_MENU_IDS } from '@shared/keymap';
|
||||
import type { ColumnKey } from '@shared/types';
|
||||
import { ConnectionsService } from '@core/connections.service';
|
||||
import { ContextMenuService } from '@core/context-menu.service';
|
||||
import { ContextMenu } from './context-menu';
|
||||
import { ChmodDialog } from './dialogs/chmod-dialog';
|
||||
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
||||
import { OverwriteDialog } from './dialogs/overwrite-dialog';
|
||||
import { PromptDialog } from './dialogs/prompt-dialog';
|
||||
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 { PanelsService } from '@core/panels.service';
|
||||
import { blockNativeContextMenu } from '@core/platform';
|
||||
import { blockNativeContextMenu, blockReloadKeys } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.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 { TabBar } from './tab-bar/tab-bar';
|
||||
import { ToastService } from '@core/toast.service';
|
||||
@@ -44,6 +46,7 @@ import { UpdaterService } from '@core/updater.service';
|
||||
host: {
|
||||
'(window:keydown)': 'onWindowKeydown($event)',
|
||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||
'(mousedown)': 'keepListFocus($event)',
|
||||
},
|
||||
})
|
||||
export class App {
|
||||
@@ -58,6 +61,8 @@ export class App {
|
||||
private readonly settings = inject(SettingsService);
|
||||
private readonly shortcuts = inject(ShortcutsService);
|
||||
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. */
|
||||
readonly managerOpen = signal(false);
|
||||
@@ -105,10 +110,6 @@ export class App {
|
||||
|
||||
/** Native application-menu / manager-window actions forwarded from bun. */
|
||||
private async handleMenuAction(action: string): Promise<void> {
|
||||
if (action.startsWith('ctx-')) {
|
||||
this.handleContextAction(action);
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('connect:')) {
|
||||
const connection = await this.findConnection(action.slice('connect:'.length));
|
||||
if (connection) {
|
||||
@@ -137,22 +138,19 @@ export class App {
|
||||
}
|
||||
switch (action) {
|
||||
case 'new-tab':
|
||||
this.panels.newWorkspace();
|
||||
break;
|
||||
case 'close-tab':
|
||||
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
||||
this.runWindowCommand(action);
|
||||
break;
|
||||
// the macOS File / View items act on the active panel, exactly like their keys
|
||||
case 'new-folder':
|
||||
this.actions.mkdir(this.panels.active());
|
||||
break;
|
||||
case 'new-file':
|
||||
this.actions.createFile(this.panels.active());
|
||||
this.runFileCommand(action);
|
||||
break;
|
||||
case 'edit-file':
|
||||
void this.actions.edit(this.panels.active());
|
||||
this.runFileCommand('edit');
|
||||
break;
|
||||
case 'refresh-active':
|
||||
void this.panels.active().refresh();
|
||||
this.runFileCommand('refresh');
|
||||
break;
|
||||
case 'check-for-updates':
|
||||
void this.updater.check();
|
||||
@@ -160,37 +158,102 @@ export class App {
|
||||
}
|
||||
}
|
||||
|
||||
/** Window shortcuts — matching lives in ShortcutsService (one keymap for the
|
||||
* app); this switch only maps ids onto actions. Panel-level keys stay on
|
||||
* the panels themselves. */
|
||||
private runFileCommand(id: FileCommand): void {
|
||||
const panel = this.panels.active();
|
||||
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 {
|
||||
if (this.managerOpen()) return; // modal-blocked behind the manager
|
||||
const match = this.shortcuts.resolve(event, 'main');
|
||||
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
|
||||
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
|
||||
event.preventDefault();
|
||||
switch (match.id) {
|
||||
this.handleWindowKey(event);
|
||||
blockReloadKeys(event); // a no-op once something claimed the key
|
||||
}
|
||||
|
||||
private handleWindowKey(event: KeyboardEvent): void {
|
||||
// a component took it (the list, a menu, a dialog), or the connection
|
||||
// manager holds this window modal
|
||||
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':
|
||||
this.panels.newWorkspace();
|
||||
break;
|
||||
case 'close-tab':
|
||||
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
||||
this.panels.closeWorkspace(active);
|
||||
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);
|
||||
break;
|
||||
case 'cycle-tab-back':
|
||||
case 'prev-tab':
|
||||
this.panels.cycleWorkspace(-1);
|
||||
break;
|
||||
case 'go-to-tab':
|
||||
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox
|
||||
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1);
|
||||
case 'go-to-last-tab':
|
||||
this.panels.goToWorkspace(-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;
|
||||
case 'open-settings':
|
||||
void this.rpc.request.openSettings({});
|
||||
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':
|
||||
this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||
break;
|
||||
@@ -200,10 +263,48 @@ export class App {
|
||||
case 'scale-reset':
|
||||
this.settings.setUiScale(1);
|
||||
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
|
||||
* because the right-clicked tab is not necessarily the active one — nothing
|
||||
@@ -232,55 +333,4 @@ export class App {
|
||||
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.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>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="importWinScp()"
|
||||
[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"
|
||||
>
|
||||
@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 {
|
||||
<!-- <ng-icon name="tdesignFileZip" size="0.85rem" /> -->
|
||||
<!-- <ng-icon name="tdesignFileZip" size="var(--icon-size-sm)" /> -->
|
||||
}
|
||||
{{ i18n.t('connMgr.importWinscp') }}
|
||||
</button>
|
||||
@@ -33,21 +33,22 @@
|
||||
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<button
|
||||
(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"
|
||||
>
|
||||
<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
|
||||
(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"
|
||||
>
|
||||
<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>
|
||||
</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 -->
|
||||
@for (group of store.grouped().stacks; track group.stack.id) {
|
||||
<div
|
||||
@@ -59,50 +60,52 @@
|
||||
[class.ring-inset]="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
|
||||
data-row
|
||||
(click)="toggleStack(group.stack)"
|
||||
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')"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="group.stack.collapsed ? 'tdesignChevronRight' : 'tdesignChevronDown'"
|
||||
size="0.8rem"
|
||||
[name]="group.stack.collapsed ? 'chevronRight14' : 'chevronDown14'"
|
||||
size="var(--icon-size-sm)"
|
||||
/>
|
||||
</button>
|
||||
<span class="truncate text-[0.6875rem] font-medium text-muted-foreground uppercase">
|
||||
{{ group.stack.name }}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1"></div>
|
||||
<!-- count and actions swap on hover: they never share the space,
|
||||
so a long stack name can't collide with the buttons -->
|
||||
<!-- count and actions swap on hover — and when the keyboard is in
|
||||
the row: they never share the space, so a long stack name
|
||||
can't collide with the buttons -->
|
||||
<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 }}
|
||||
</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
|
||||
(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"
|
||||
>
|
||||
<ng-icon name="tdesignEdit" size="0.75rem" />
|
||||
<ng-icon name="edit14" size="var(--icon-size-sm)" />
|
||||
</button>
|
||||
<button
|
||||
(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"
|
||||
>
|
||||
<ng-icon name="tdesignDelete" size="0.75rem" />
|
||||
<ng-icon name="delete14" size="var(--icon-size-sm)" />
|
||||
</button>
|
||||
@if (group.connections.length) {
|
||||
<button
|
||||
(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"
|
||||
>
|
||||
<ng-icon name="tdesignArrowRight" size="0.8rem" />
|
||||
<ng-icon name="arrowRight14" size="var(--icon-size-sm)" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -110,6 +113,8 @@
|
||||
@if (!group.stack.collapsed) {
|
||||
@for (connection of group.connections; track connection.id) {
|
||||
<button
|
||||
data-row
|
||||
[attr.data-conn]="connection.id"
|
||||
(click)="edit(connection)"
|
||||
draggable="true"
|
||||
(dragstart)="onDragStart($event, connection)"
|
||||
@@ -118,7 +123,7 @@
|
||||
[class.bg-accent]="draft()?.id === 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>
|
||||
</button>
|
||||
} @empty {
|
||||
@@ -146,6 +151,8 @@
|
||||
}
|
||||
@for (connection of store.grouped().loose; track connection.id) {
|
||||
<button
|
||||
data-row
|
||||
[attr.data-conn]="connection.id"
|
||||
(click)="edit(connection)"
|
||||
draggable="true"
|
||||
(dragstart)="onDragStart($event, connection)"
|
||||
@@ -154,7 +161,7 @@
|
||||
[class.bg-accent]="draft()?.id === 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>
|
||||
</button>
|
||||
}
|
||||
@@ -174,10 +181,15 @@
|
||||
<!-- edit form -->
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
@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">
|
||||
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
|
||||
<input
|
||||
#nameBox
|
||||
[value]="d.name"
|
||||
(input)="patch('name', $any($event.target).value)"
|
||||
[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">
|
||||
<button
|
||||
(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"
|
||||
>
|
||||
<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
|
||||
(click)="moreOpen.set(!moreOpen())"
|
||||
@@ -341,6 +354,7 @@
|
||||
<button
|
||||
(click)="save()"
|
||||
[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"
|
||||
>
|
||||
{{ i18n.t('common.save') }}
|
||||
@@ -348,10 +362,11 @@
|
||||
<button
|
||||
(click)="saveAndConnect()"
|
||||
[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"
|
||||
>
|
||||
@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') }}
|
||||
</button>
|
||||
@@ -359,7 +374,7 @@
|
||||
} @else {
|
||||
<div class="grid flex-1 place-items-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">
|
||||
{{ i18n.t('connMgr.selectPrompt') }}
|
||||
</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 { I18nService } from '@core/i18n.service';
|
||||
import type { SavedConnection, Stack } from '@shared/types';
|
||||
@@ -6,10 +16,12 @@ import { ConnectionsService } from '@core/connections.service';
|
||||
import { ConfirmDialog } from '../dialogs/confirm-dialog';
|
||||
import { PromptDialog } from '../dialogs/prompt-dialog';
|
||||
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 { SettingsService } from '@core/settings.service';
|
||||
import { ShortcutsService } from '@core/shortcuts.service';
|
||||
import { ShortcutsService, type ShortcutId } from '@core/shortcuts.service';
|
||||
import { Toasts } from '../toasts/toasts';
|
||||
|
||||
interface Draft {
|
||||
@@ -76,8 +88,8 @@ function fromDraft(d: Draft): SavedConnection {
|
||||
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts],
|
||||
templateUrl: './connection-manager.html',
|
||||
host: {
|
||||
'(window:keydown.escape)': 'onEscape()',
|
||||
'(window:keydown)': 'onWindowKeydown($event)',
|
||||
'(window:focusin)': 'rememberFocus($event)',
|
||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||
},
|
||||
})
|
||||
@@ -90,7 +102,13 @@ export class ConnectionManager {
|
||||
// injected for its side effects: fetches settings and applies theme/accent/
|
||||
// text-size data attributes to this window's document root
|
||||
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 busy = signal(false);
|
||||
@@ -102,6 +120,27 @@ export class ConnectionManager {
|
||||
/** Highlighted drop target: a stack id, 'loose', or 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(() => {
|
||||
const d = this.draft();
|
||||
if (!d) return false;
|
||||
@@ -141,19 +180,174 @@ export class ConnectionManager {
|
||||
void this.rpc.request.managerClose({});
|
||||
}
|
||||
|
||||
onEscape(): void {
|
||||
// in-window dialogs (confirm/prompt) handle their own Escape first
|
||||
if (!this.dialogs.anyOpen) this.close();
|
||||
/**
|
||||
* This window's keys (the `manager` scope), then the fixed ones: Esc closes
|
||||
* 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. */
|
||||
onWindowKeydown(event: KeyboardEvent): void {
|
||||
const match = this.shortcuts.resolve(event, 'manager');
|
||||
if (!match || this.shortcuts.isTypingTarget(event)) return;
|
||||
private onEscape(event: KeyboardEvent): void {
|
||||
event.preventDefault();
|
||||
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
||||
else if (match.id === 'scale-reset') this.settings.setUiScale(1);
|
||||
if (this.shortcuts.isTypingTarget(event)) {
|
||||
(event.target as HTMLElement).blur();
|
||||
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 {
|
||||
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
viewChild,
|
||||
} from '@angular/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 { stepIndex } from '@core/menu-nav';
|
||||
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
|
||||
* 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. */
|
||||
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 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
|
||||
|
||||
/**
|
||||
* The panel right-click menu. Replaces electrobun's native ContextMenu, which
|
||||
* is a no-op on Linux — see ContextMenuService for the why. Emits the same
|
||||
* `ctx-*` / `col:*` action strings the native menu did.
|
||||
* The HTML context menu (rows, tabs, column headers, the panel source picker).
|
||||
* Replaces electrobun's native ContextMenu, which is a no-op on Linux — see
|
||||
* 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({
|
||||
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)"
|
||||
[style.left.px]="position().x"
|
||||
[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) {
|
||||
@if (item.type === 'separator') {
|
||||
<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 {
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
[attr.id]="'ctx-item-' + $index"
|
||||
[title]="item.tooltip ?? ''"
|
||||
[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)"
|
||||
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) {
|
||||
<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 class="flex-1 truncate">{{ item.label }}</span>
|
||||
@if (item.accelerator) {
|
||||
<span class="shrink-0 text-muted-foreground">{{ item.accelerator }}</span>
|
||||
@if (item.shortcut && shortcuts.label(item.shortcut); as hint) {
|
||||
<!-- 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>
|
||||
}
|
||||
@@ -86,30 +107,37 @@ const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
|
||||
})
|
||||
export class ContextMenu {
|
||||
readonly menu = inject(ContextMenuService);
|
||||
readonly shortcuts = inject(ShortcutsService);
|
||||
private readonly focus = inject(FocusService);
|
||||
private readonly menuEl = viewChild<ElementRef<HTMLDivElement>>('menuEl');
|
||||
|
||||
readonly activeIndex = signal(-1);
|
||||
|
||||
/** Click point, flipped/clamped so the menu never overflows the viewport.
|
||||
* All UI metrics convert through the live root font-size; window.inner* is
|
||||
* viewport CSS px, so both sides of every comparison share units. */
|
||||
* A menu taller than the viewport (a long source picker) scrolls inside a
|
||||
* clamped box. All UI metrics convert through the live root font-size;
|
||||
* window.inner* is viewport CSS px, so both sides of every comparison share
|
||||
* units. */
|
||||
readonly position = computed(() => {
|
||||
const req = this.menu.request();
|
||||
if (!req) return { x: 0, y: 0 };
|
||||
const itemH = remPx(ITEM_H_REM);
|
||||
const separatorH = remPx(SEPARATOR_H_REM);
|
||||
if (!req) return { x: 0, y: 0, maxHeight: 0 };
|
||||
const heights = {
|
||||
separator: remPx(SEPARATOR_H_REM),
|
||||
header: remPx(HEADER_H_REM),
|
||||
item: remPx(ITEM_H_REM),
|
||||
};
|
||||
const content =
|
||||
req.items.reduce((sum, i) => sum + heights[i.type ?? 'item'], 0) + remPx(0.5); // py-1 ×2
|
||||
const maxHeight = window.innerHeight - 2 * EDGE_GAP;
|
||||
const height = Math.min(content, maxHeight);
|
||||
const menuW = remPx(MENU_W_REM);
|
||||
const height =
|
||||
req.items.reduce((sum, i) => sum + (i.type === 'separator' ? separatorH : itemH), 0) +
|
||||
remPx(0.5); // the menu's py-1 (0.25rem x2)
|
||||
const x =
|
||||
req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : req.x;
|
||||
const y =
|
||||
req.y + height > window.innerHeight - EDGE_GAP
|
||||
? Math.max(EDGE_GAP, req.y - height)
|
||||
: req.y;
|
||||
return { x, y };
|
||||
return { x, y, maxHeight };
|
||||
});
|
||||
|
||||
constructor() {
|
||||
@@ -117,22 +145,25 @@ export class ContextMenu {
|
||||
// the viewChild signal, NOT on request(): under zoneless CD an effect can
|
||||
// run before the @if has rendered the element.
|
||||
effect(() => this.menuEl()?.nativeElement.focus());
|
||||
// a fresh menu starts with nothing highlighted — or, opened from the
|
||||
// keyboard, with its first item
|
||||
effect(() => this.activeIndex.set(this.menu.request()?.initialIndex ?? -1));
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (!this.menu.request()) return;
|
||||
this.menu.close();
|
||||
this.activeIndex.set(-1); // a fresh menu starts with nothing highlighted
|
||||
this.focus.bump();
|
||||
}
|
||||
|
||||
pick(item: MenuItem): void {
|
||||
// the keyboard path never consults the disabled attribute — guard here
|
||||
if (item.disabled) return;
|
||||
if (!item.action) return;
|
||||
if (item.disabled || !item.action) return;
|
||||
const action = item.action;
|
||||
const onPick = this.menu.request()?.onPick;
|
||||
this.close();
|
||||
this.menu.onPick(action);
|
||||
if (onPick) onPick(action);
|
||||
else this.menu.emit(action);
|
||||
}
|
||||
|
||||
/** Right-clicking the backdrop closes rather than stacking a second menu. */
|
||||
@@ -143,33 +174,48 @@ export class ContextMenu {
|
||||
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
const items = this.menu.request()?.items ?? [];
|
||||
const selectable = items.map(isSelectable);
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.close();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
this.step(items, 1);
|
||||
this.highlight(stepIndex(selectable, this.activeIndex(), 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
this.step(items, -1);
|
||||
this.highlight(stepIndex(selectable, this.activeIndex(), -1));
|
||||
break;
|
||||
case 'Home':
|
||||
this.highlight(stepIndex(selectable, -1, 1));
|
||||
break;
|
||||
case 'End':
|
||||
this.highlight(stepIndex(selectable, -1, -1));
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ': {
|
||||
event.preventDefault();
|
||||
const item = items[this.activeIndex()];
|
||||
if (item) this.pick(item);
|
||||
break;
|
||||
}
|
||||
case 'Tab':
|
||||
// focus stays on the menu: the items are not Tab stops
|
||||
break;
|
||||
default: {
|
||||
// an item's own shortcut picks it, as in a native menu
|
||||
const hit = items.find(
|
||||
(i) => isSelectable(i) && i.shortcut && this.shortcuts.matches(i.shortcut, event),
|
||||
);
|
||||
if (!hit) return; // not ours: let it bubble
|
||||
this.pick(hit);
|
||||
}
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
/** Move the highlight by one selectable item (see stepIndex). */
|
||||
private step(items: MenuItem[], delta: 1 | -1): void {
|
||||
const selectable = items.map((i) => i.type !== 'separator' && !i.disabled);
|
||||
this.activeIndex.set(stepIndex(selectable, this.activeIndex(), delta));
|
||||
/** Move the highlight and keep it in view (the menu may scroll). */
|
||||
private highlight(index: number): void {
|
||||
this.activeIndex.set(index);
|
||||
document.getElementById(`ctx-item-${index}`)?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Component, computed, inject, input, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
afterNextRender,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { DialogsService, type ChmodRequest } from '@core/dialogs.service';
|
||||
import { cycleFocus } from '@core/focus-cycle';
|
||||
import { FocusService } from '@core/focus.service';
|
||||
|
||||
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
|
||||
@@ -11,9 +21,12 @@ const PERMS = ['r', 'w', 'x'] as const;
|
||||
template: `
|
||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
||||
<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"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
(keydown.escape)="cancel()"
|
||||
(keydown)="onKeydown($event)"
|
||||
>
|
||||
<h2 class="text-sm font-semibold">{{ i18n.t('dialogs.chmodTitle') }}</h2>
|
||||
<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">
|
||||
<label class="text-xs text-muted-foreground">{{ i18n.t('dialogs.octal') }}</label>
|
||||
<input
|
||||
#octalBox
|
||||
[value]="octal()"
|
||||
(input)="setOctal($any($event.target).value)"
|
||||
maxlength="4"
|
||||
@@ -84,6 +98,32 @@ export class ChmodDialog {
|
||||
|
||||
private readonly dialogs = inject(DialogsService);
|
||||
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
|
||||
private readonly modeOverride = signal<number | null>(null);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { DialogsService, type ConfirmRequest } from '@core/dialogs.service';
|
||||
import { cycleFocus } from '@core/focus-cycle';
|
||||
import { FocusService } from '@core/focus.service';
|
||||
|
||||
@Component({
|
||||
@@ -8,9 +9,12 @@ import { FocusService } from '@core/focus.service';
|
||||
template: `
|
||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
||||
<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"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
(keydown.escape)="cancel()"
|
||||
(keydown)="onKeydown($event)"
|
||||
>
|
||||
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
|
||||
<p class="mt-2 text-xs text-muted-foreground">{{ request().body }}</p>
|
||||
@@ -49,6 +53,17 @@ export class ConfirmDialog {
|
||||
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 {
|
||||
const req = this.request();
|
||||
this.close();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
afterNextRender,
|
||||
afterRenderEffect,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service';
|
||||
import { cycleFocus } from '@core/focus-cycle';
|
||||
import { FocusService } from '@core/focus.service';
|
||||
|
||||
@Component({
|
||||
@@ -18,9 +19,11 @@ import { FocusService } from '@core/focus.service';
|
||||
template: `
|
||||
<div
|
||||
class="fixed inset-0 z-40 grid place-items-center bg-black/40"
|
||||
(keydown.escape)="pick('cancel')"
|
||||
(keydown)="onKeydown($event)"
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
class="w-[26rem] border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
@@ -60,8 +63,8 @@ import { FocusService } from '@core/focus.service';
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center gap-1.5 text-xs">
|
||||
<ng-icon
|
||||
[name]="isDir() ? 'tdesignFolder' : 'tdesignFile'"
|
||||
size="0.9rem"
|
||||
[name]="isDir() ? 'folder14' : 'file14'"
|
||||
size="var(--icon-size-sm)"
|
||||
class="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span class="font-mono tabular-nums">
|
||||
@@ -127,7 +130,23 @@ export class OverwriteDialog {
|
||||
private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn');
|
||||
|
||||
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');
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { DialogsService, type PromptRequest } from '@core/dialogs.service';
|
||||
import { cycleFocus } from '@core/focus-cycle';
|
||||
import { FocusService } from '@core/focus.service';
|
||||
|
||||
@Component({
|
||||
@@ -16,16 +17,19 @@ import { FocusService } from '@core/focus.service';
|
||||
template: `
|
||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
||||
<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"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
(keydown)="onKeydown($event)"
|
||||
>
|
||||
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
|
||||
<input
|
||||
#box
|
||||
[value]="value()"
|
||||
(input)="value.set($any($event.target).value)"
|
||||
(keydown.enter)="submit()"
|
||||
(keydown.escape)="cancel()"
|
||||
(keydown.enter)="onEnter($event)"
|
||||
[placeholder]="request().placeholder ?? ''"
|
||||
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"
|
||||
@@ -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 {
|
||||
const value = this.value().trim();
|
||||
if (!value) return;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@if (!ctx) {
|
||||
<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">
|
||||
<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-1 text-xs text-muted-foreground">
|
||||
{{ i18n.t('editor.noFileBody') }}
|
||||
@@ -20,7 +20,7 @@
|
||||
[class.h-9]="!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">
|
||||
{{ ctx.name }}{{ dirty() ? ' •' : '' }}
|
||||
</h1>
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(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.bg-accent]="wrap()"
|
||||
[class.text-foreground]="wrap()"
|
||||
@@ -38,16 +38,16 @@
|
||||
</button>
|
||||
<button
|
||||
(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"
|
||||
>
|
||||
<ng-icon name="tdesignSearch" size="0.85rem" />
|
||||
<ng-icon name="search14" size="var(--icon-size-sm)" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (findOpen()) {
|
||||
<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
|
||||
#findBox
|
||||
[value]="findQuery()"
|
||||
@@ -66,17 +66,17 @@
|
||||
</span>
|
||||
<button
|
||||
(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"
|
||||
>
|
||||
<ng-icon name="tdesignChevronUp" size="0.8rem" />
|
||||
<ng-icon name="chevronUp14" size="var(--icon-size-sm)" />
|
||||
</button>
|
||||
<button
|
||||
(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"
|
||||
>
|
||||
<ng-icon name="tdesignChevronDown" size="0.8rem" />
|
||||
<ng-icon name="chevronDown14" size="var(--icon-size-sm)" />
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
@@ -84,7 +84,7 @@
|
||||
[title]="i18n.t('editor.closeHint')"
|
||||
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>
|
||||
</div>
|
||||
}
|
||||
@@ -93,19 +93,19 @@
|
||||
<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"
|
||||
>
|
||||
<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 }) }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<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>
|
||||
} @else if (loadError(); as error) {
|
||||
<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">
|
||||
<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-1 text-xs text-muted-foreground">{{ error }}</p>
|
||||
<button
|
||||
@@ -141,7 +141,7 @@
|
||||
</div>
|
||||
|
||||
<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] tabular-nums text-muted-foreground">
|
||||
{{ 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"
|
||||
>
|
||||
@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') }}
|
||||
</button>
|
||||
@@ -171,6 +171,10 @@
|
||||
@if (closePrompt()) {
|
||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()">
|
||||
<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"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
>
|
||||
@@ -193,6 +197,7 @@
|
||||
{{ i18n.t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
#promptSave
|
||||
(click)="saveAndClose()"
|
||||
[disabled]="saving()"
|
||||
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,
|
||||
DOCUMENT,
|
||||
ElementRef,
|
||||
Injector,
|
||||
afterNextRender,
|
||||
afterRenderEffect,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
@@ -11,7 +13,8 @@ import {
|
||||
} from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
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 { SettingsService } from '@core/settings.service';
|
||||
import { ShortcutsService } from '@core/shortcuts.service';
|
||||
@@ -43,8 +46,6 @@ export class EditorWindow {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly ctx = EDITOR_CONTEXT;
|
||||
readonly isMacChrome = IS_MAC_CHROME;
|
||||
readonly modLabel = MOD_LABEL;
|
||||
readonly altLabel = ALT_LABEL;
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal<string | null>(null);
|
||||
@@ -92,6 +93,9 @@ export class EditorWindow {
|
||||
);
|
||||
|
||||
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 findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
|
||||
private cachedCharWidth = 0;
|
||||
@@ -102,7 +106,22 @@ export class EditorWindow {
|
||||
// injected for its side effects: fetches settings and applies theme/accent/
|
||||
// text-size data attributes to this window's document root
|
||||
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 lastSentDirty = false;
|
||||
@@ -122,6 +141,11 @@ export class EditorWindow {
|
||||
});
|
||||
|
||||
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> {
|
||||
@@ -134,7 +158,8 @@ export class EditorWindow {
|
||||
this.savedContent.set(res.content);
|
||||
this.text.set(res.content);
|
||||
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 {
|
||||
@@ -168,11 +193,14 @@ export class EditorWindow {
|
||||
}
|
||||
this.findOpen.set(true);
|
||||
this.activeMatch.set(0);
|
||||
queueMicrotask(() => {
|
||||
const input = this.findBox()?.nativeElement;
|
||||
input?.focus();
|
||||
input?.select();
|
||||
});
|
||||
afterNextRender(
|
||||
() => {
|
||||
const input = this.findBox()?.nativeElement;
|
||||
input?.focus();
|
||||
input?.select();
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
}
|
||||
|
||||
closeFind(): void {
|
||||
@@ -197,6 +225,29 @@ export class EditorWindow {
|
||||
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 {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
@@ -319,55 +370,78 @@ export class EditorWindow {
|
||||
// ---- keyboard ----
|
||||
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
// Chord shortcuts resolve through the shared keymap (ShortcutsService);
|
||||
// Escape and Tab stay below because their meaning is THIS window's state
|
||||
// (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
|
||||
// (save/find/wrap) are WANTED mid-typing, so the guard is per-shortcut
|
||||
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
|
||||
event.preventDefault();
|
||||
switch (match.id) {
|
||||
case 'save':
|
||||
void this.save();
|
||||
break;
|
||||
case 'find':
|
||||
this.openFind();
|
||||
break;
|
||||
case 'close-window':
|
||||
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W
|
||||
// and bun routes it back as editorCloseRequest
|
||||
this.requestClose();
|
||||
break;
|
||||
case 'toggle-wrap':
|
||||
this.toggleWrap();
|
||||
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;
|
||||
this.handleKey(event);
|
||||
blockReloadKeys(event); // F5 / Ctrl+R must not reload over unsaved text
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's keys come from the shared keymap (`editor` scope); they fire
|
||||
* mid-typing because they are all chords or F-keys. Escape and Tab stay here:
|
||||
* their meaning is this window's state (prompt up? find bar open?).
|
||||
*/
|
||||
private handleKey(event: KeyboardEvent): void {
|
||||
if (event.defaultPrevented) return;
|
||||
const id = this.shortcuts.claim(event, 'editor');
|
||||
if (this.closePrompt()) {
|
||||
// the unsaved-changes prompt owns the keyboard: ⌘S is its Save, Esc its
|
||||
// Cancel, Tab walks its buttons; other commands wait
|
||||
if (id === 'save') void this.saveAndClose();
|
||||
else if (id === 'scale-up' || id === 'scale-down' || id === 'scale-reset') this.scale(id);
|
||||
else if (!id && event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.cancelClose();
|
||||
} else if (!id) {
|
||||
const prompt = this.prompt()?.nativeElement;
|
||||
if (prompt) cycleFocus(prompt, event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape' && this.closePrompt()) {
|
||||
event.preventDefault();
|
||||
this.cancelClose();
|
||||
} else if (event.key === 'Escape' && this.findOpen()) {
|
||||
switch (id) {
|
||||
case 'save':
|
||||
void this.save();
|
||||
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();
|
||||
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
|
||||
event.preventDefault();
|
||||
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 {
|
||||
const el = this.box()!.nativeElement;
|
||||
// execCommand keeps the native undo stack intact where supported
|
||||
|
||||
@@ -4,24 +4,25 @@
|
||||
[style.box-shadow]="activeMarker()"
|
||||
>
|
||||
<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"
|
||||
[title]="i18n.t('panel.choosePanel')"
|
||||
[title]="shortcuts.tip(i18n.t('panel.choosePanel'), 'source-picker')"
|
||||
>
|
||||
@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 {
|
||||
<ng-icon
|
||||
[name]="isRemote() ? 'tdesignServer' : 'tdesignHardDrive'"
|
||||
[name]="isRemote() ? 'server14' : 'hardDrive14'"
|
||||
[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>
|
||||
<ng-icon
|
||||
name="tdesignChevronDown"
|
||||
name="chevronDown14"
|
||||
class="shrink-0 text-muted-foreground"
|
||||
size="calc(var(--action-icon) - 0.1rem)"
|
||||
size="var(--icon-size-sm)"
|
||||
/>
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
@@ -29,135 +30,61 @@
|
||||
<!-- panel actions: act on THIS panel. Frequent/destructive verbs grow text
|
||||
labels when the panel is wide enough (container query). -->
|
||||
<button
|
||||
(click)="copyToOther()"
|
||||
[title]="side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft')"
|
||||
(click)="run('send')"
|
||||
[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"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
<button
|
||||
(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"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
<button
|
||||
(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"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
<button
|
||||
(click)="run('mkdir')"
|
||||
[title]="i18n.t('panel.newFolder')"
|
||||
(click)="run('new-folder')"
|
||||
[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"
|
||||
>
|
||||
<ng-icon name="tdesignFolderAdd" size="var(--action-icon)" />
|
||||
<ng-icon name="folderAdd14" size="var(--icon-size-sm)" />
|
||||
</button>
|
||||
<button
|
||||
(click)="run('createFile')"
|
||||
[title]="i18n.t('panel.newFile')"
|
||||
(click)="run('new-file')"
|
||||
[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"
|
||||
>
|
||||
<ng-icon name="tdesignFileAdd" size="var(--action-icon)" />
|
||||
<ng-icon name="fileAdd14" size="var(--icon-size-sm)" />
|
||||
</button>
|
||||
@if (chmodAvailable()) {
|
||||
<button
|
||||
(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"
|
||||
>
|
||||
<ng-icon name="tdesignLockOn" size="var(--action-icon)" />
|
||||
<ng-icon name="lockOn14" size="var(--icon-size-sm)" />
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
(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"
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- path bar -->
|
||||
@@ -165,31 +92,34 @@
|
||||
<button
|
||||
(click)="panel().up()"
|
||||
[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"
|
||||
>
|
||||
<ng-icon name="tdesignArrowUp" size="1rem" />
|
||||
<ng-icon name="arrowUp16" size="var(--icon-size-md)" />
|
||||
</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
|
||||
#pathBox
|
||||
[value]="pathDraft()"
|
||||
(input)="pathDraft.set($any($event.target).value)"
|
||||
(keydown.enter)="commitPath()"
|
||||
(keydown.escape)="resetPath()"
|
||||
(keydown)="$event.stopPropagation()"
|
||||
[disabled]="blocked()"
|
||||
[title]="shortcuts.tip(i18n.t('keys.focus-path'), 'focus-path')"
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
(click)="panel().refresh()"
|
||||
(click)="run('refresh')"
|
||||
[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"
|
||||
>
|
||||
@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 {
|
||||
<ng-icon name="tdesignRefresh" size="1rem" />
|
||||
<ng-icon name="refresh16" size="var(--icon-size-md)" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
@@ -200,7 +130,11 @@
|
||||
[style.grid-template-columns]="gridTemplate()"
|
||||
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') }}
|
||||
</button>
|
||||
@for (col of visibleColumns(); track col.key) {
|
||||
@@ -208,6 +142,7 @@
|
||||
class="px-2 py-1 hover:text-foreground"
|
||||
[class.text-right]="col.key === 'size'"
|
||||
[class.text-left]="col.key !== 'size'"
|
||||
[title]="sortTip(col.key)"
|
||||
(click)="panel().toggleSort(col.key)"
|
||||
>
|
||||
{{ i18n.t('columns.' + col.key) }} {{ sortIndicator(col.key) }}
|
||||
@@ -218,7 +153,7 @@
|
||||
<!-- type-to-filter bar (appears while a query is active) -->
|
||||
@if (panel().filter(); as query) {
|
||||
<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="shrink-0 text-[0.6875rem] text-muted-foreground">
|
||||
{{ i18n.t('panel.matches', { count: filterMatches() }) }}
|
||||
@@ -228,7 +163,7 @@
|
||||
[title]="i18n.t('panel.clearFilter')"
|
||||
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>
|
||||
</div>
|
||||
}
|
||||
@@ -246,7 +181,7 @@
|
||||
@if (panel().connecting()) {
|
||||
<div class="panel-fade-in grid h-full place-items-center">
|
||||
<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">
|
||||
{{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }}
|
||||
</span>
|
||||
@@ -275,7 +210,7 @@
|
||||
[class.ring-ring]="i === panel().cursor() && isActive()"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
@for (col of visibleColumns(); track col.key) {
|
||||
@@ -325,7 +260,7 @@
|
||||
</div>
|
||||
@if (panel().slowLoad() && !panel().connecting()) {
|
||||
<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>
|
||||
|
||||
@@ -9,20 +9,22 @@ import {
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { isRepeatable } from '@shared/keymap';
|
||||
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 { ContextMenuService } from '@core/context-menu.service';
|
||||
import { ContextMenuService, type MenuItem } from '@core/context-menu.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 { 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 { IS_WINDOWS } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { ShortcutsService } from '@core/shortcuts.service';
|
||||
import { TransfersService } from '@core/transfers.service';
|
||||
import { ShortcutsService, type ShortcutId } from '@core/shortcuts.service';
|
||||
|
||||
/** Optional columns in fixed display order, with their track widths. */
|
||||
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' },
|
||||
];
|
||||
|
||||
/** 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({
|
||||
selector: 'app-file-panel',
|
||||
imports: [NgIcon],
|
||||
@@ -46,12 +75,11 @@ export class FilePanel {
|
||||
|
||||
readonly panels = inject(PanelsService);
|
||||
readonly actions = inject(FileActionsService);
|
||||
readonly transfers = inject(TransfersService);
|
||||
readonly connections = inject(ConnectionsService);
|
||||
readonly dialogs = inject(DialogsService);
|
||||
readonly shortcuts = inject(ShortcutsService);
|
||||
private readonly menu = inject(ContextMenuService);
|
||||
private readonly settings = inject(SettingsService);
|
||||
private readonly shortcuts = inject(ShortcutsService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
readonly panel = computed(() => this.panels.get(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. */
|
||||
readonly blocked = computed(() => this.panel().busy());
|
||||
/** 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 menuOpen = signal(false);
|
||||
/** Windows drive roots for the source picker; always empty elsewhere. */
|
||||
readonly drives = signal<string[]>([]);
|
||||
/** chmod on Windows local files only flips the read-only bit — hide it. */
|
||||
readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote()));
|
||||
|
||||
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;
|
||||
|
||||
// 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(' '),
|
||||
);
|
||||
|
||||
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() {
|
||||
if (IS_WINDOWS && this.rpc.available) {
|
||||
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
|
||||
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(() => {
|
||||
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 {
|
||||
@@ -129,38 +159,95 @@ export class FilePanel {
|
||||
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;
|
||||
this.activate();
|
||||
void this.transfers.copyToOtherPanel(this.side());
|
||||
this.actions.run(id, this.cursorTarget());
|
||||
}
|
||||
|
||||
run(action: 'edit' | 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void {
|
||||
if (this.blocked()) return;
|
||||
private cursorTarget(): FileTarget {
|
||||
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();
|
||||
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 {
|
||||
this.menuOpen.set(false);
|
||||
void this.panels.disconnectPanel(this.side());
|
||||
private sourceItems(): MenuItem[] {
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
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 {
|
||||
this.menuOpen.set(false);
|
||||
private pickSource(action: string): void {
|
||||
const [kind, ...rest] = action.split(':');
|
||||
const value = rest.join(':'); // a drive root ("C:\") has a colon of its own
|
||||
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 {
|
||||
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();
|
||||
private connectSshHost(host: SshConfigHost): void {
|
||||
void this.panels.connectPanel(
|
||||
this.side(),
|
||||
{
|
||||
@@ -181,7 +268,7 @@ export class FilePanel {
|
||||
return `${host.username}@${host.hostname}:${host.port}`;
|
||||
}
|
||||
|
||||
iconFor(entry: FileEntry): string {
|
||||
iconFor(entry: FileEntry): Icon16 {
|
||||
return fileGlyph(entry);
|
||||
}
|
||||
|
||||
@@ -209,6 +296,12 @@ export class FilePanel {
|
||||
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 {
|
||||
if (this.blocked()) return;
|
||||
const value = this.pathDraft().trim();
|
||||
@@ -221,6 +314,12 @@ export class FilePanel {
|
||||
this.listEl().nativeElement.focus();
|
||||
}
|
||||
|
||||
private focusPath(): void {
|
||||
const el = this.pathBox().nativeElement;
|
||||
el.focus();
|
||||
el.select();
|
||||
}
|
||||
|
||||
rowClick(event: MouseEvent, index: number): void {
|
||||
if (this.blocked()) return;
|
||||
this.activate();
|
||||
@@ -257,105 +356,145 @@ export class FilePanel {
|
||||
this.actions.openContextMenu(event, { side: this.side(), panel: this.panel(), entry: null });
|
||||
}
|
||||
|
||||
openAt(index: number): void {
|
||||
if (this.blocked()) return;
|
||||
const entry = this.panel().sorted()[index];
|
||||
if (entry) this.panel().open(entry);
|
||||
/** ⇧F10 / Menu / ⌃↩: the cursor row's menu, under the row. The selection is
|
||||
* kept — the menu's commands act on it, like the F-keys do. */
|
||||
private openKeyboardMenu(): void {
|
||||
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;
|
||||
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();
|
||||
this.panels.setActiveSide(this.panels.otherSide(this.side()));
|
||||
/**
|
||||
* The list's keys, in order:
|
||||
* 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;
|
||||
}
|
||||
if (this.shortcuts.isMod(event) && key.toLowerCase() === 'a') {
|
||||
event.preventDefault();
|
||||
panel.selectAll();
|
||||
return;
|
||||
const plain = !event.metaKey && !event.ctrlKey && !event.altKey;
|
||||
const printable = plain && event.key.length === 1 && !event.isComposing;
|
||||
|
||||
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
|
||||
// query exists — an empty-filter Space keeps its toggle-select meaning)
|
||||
if (
|
||||
key.length === 1 &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
(key !== ' ' || panel.filter() !== '')
|
||||
) {
|
||||
const command = this.panelCommand(event);
|
||||
if (command) {
|
||||
event.preventDefault();
|
||||
panel.setFilter(panel.filter() + key);
|
||||
if (event.repeat && !isRepeatable(command.id)) return;
|
||||
this.runKey(command.id, command.extend);
|
||||
this.scrollCursorIntoView();
|
||||
return;
|
||||
}
|
||||
if (key === 'Backspace' && panel.filter() !== '') {
|
||||
event.preventDefault();
|
||||
panel.setFilter(panel.filter().slice(0, -1));
|
||||
return;
|
||||
}
|
||||
if (key === 'Escape' && panel.filter() !== '') {
|
||||
event.preventDefault();
|
||||
panel.setFilter('');
|
||||
return;
|
||||
}
|
||||
if (this.shortcuts.resolve(event, 'main')) return;
|
||||
|
||||
switch (key) {
|
||||
case 'ArrowDown':
|
||||
panel.moveCursor(1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
if (printable && event.key !== ' ') return this.setFilter(event, event.key);
|
||||
if (event.key === 'Escape' && plain && panel.selected().size) {
|
||||
event.preventDefault();
|
||||
panel.clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
break;
|
||||
case 'PageDown':
|
||||
panel.moveCursor(20);
|
||||
case 'cursor-down':
|
||||
panel.moveCursor(1);
|
||||
break;
|
||||
case 'PageUp':
|
||||
panel.moveCursor(-20);
|
||||
case 'page-up':
|
||||
panel.moveCursor(-PAGE);
|
||||
break;
|
||||
case 'Home':
|
||||
case 'page-down':
|
||||
panel.moveCursor(PAGE);
|
||||
break;
|
||||
case 'cursor-first':
|
||||
panel.cursor.set(0);
|
||||
break;
|
||||
case 'End':
|
||||
case 'cursor-last':
|
||||
panel.cursor.set(Math.max(0, panel.sorted().length - 1));
|
||||
break;
|
||||
case ' ':
|
||||
{
|
||||
const entry = panel.sorted()[panel.cursor()];
|
||||
if (entry) panel.toggleSelect(entry.name);
|
||||
panel.moveCursor(1);
|
||||
}
|
||||
break;
|
||||
case 'Enter':
|
||||
this.openAt(panel.cursor());
|
||||
return void event.preventDefault();
|
||||
case 'Backspace':
|
||||
case 'toggle-select': {
|
||||
const entry = panel.sorted()[from];
|
||||
if (entry) panel.toggleSelect(entry.name);
|
||||
panel.moveCursor(1);
|
||||
return;
|
||||
}
|
||||
case 'select-all':
|
||||
panel.selectAll();
|
||||
return;
|
||||
case 'parent':
|
||||
panel.up();
|
||||
break;
|
||||
case 'F2':
|
||||
this.actions.rename(panel);
|
||||
break;
|
||||
case 'F4':
|
||||
void this.actions.edit(panel);
|
||||
break;
|
||||
case 'F5':
|
||||
void this.transfers.copyToOtherPanel(this.side());
|
||||
break;
|
||||
case 'F7':
|
||||
this.actions.mkdir(panel);
|
||||
break;
|
||||
case 'F8':
|
||||
case 'Delete':
|
||||
this.actions.delete(panel);
|
||||
break;
|
||||
return;
|
||||
case 'switch-panel':
|
||||
this.panels.setActiveSide(this.panels.otherSide(this.side()));
|
||||
return;
|
||||
case 'row-menu':
|
||||
this.openKeyboardMenu();
|
||||
return;
|
||||
case 'focus-path':
|
||||
this.focusPath();
|
||||
return;
|
||||
case 'source-picker':
|
||||
void this.openSourcePicker(true);
|
||||
return;
|
||||
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();
|
||||
this.panel().setFilter(query);
|
||||
this.scrollCursorIntoView();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,16 +9,22 @@
|
||||
[class.h-9]="!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>
|
||||
</header>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<!-- section list -->
|
||||
<nav class="flex w-44 shrink-0 flex-col gap-0.5 border-r border-border bg-chrome p-2">
|
||||
<nav
|
||||
#nav
|
||||
(keydown)="onNavKeydown($event)"
|
||||
class="flex w-44 shrink-0 flex-col gap-0.5 border-r border-border bg-chrome p-2"
|
||||
>
|
||||
@for (s of sections; track s) {
|
||||
<button
|
||||
(click)="section.set(s)"
|
||||
[attr.data-section]="s"
|
||||
[attr.aria-current]="section() === s ? 'page' : null"
|
||||
class="px-2.5 py-1.5 text-left text-xs hover:bg-accent hover:text-foreground"
|
||||
[class.bg-accent]="section() === s"
|
||||
[class.font-medium]="section() === s"
|
||||
@@ -244,7 +250,7 @@
|
||||
[title]="i18n.t('options.resetDefault')"
|
||||
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>
|
||||
}
|
||||
</div>
|
||||
@@ -266,6 +272,171 @@
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Enter / double-click on a file: nothing (folders only) or open it -->
|
||||
<div class="mt-2 flex items-center justify-between gap-2">
|
||||
<span class="text-xs">{{ i18n.t('options.openFiles.label') }}</span>
|
||||
<div class="flex border border-border p-0.5">
|
||||
<button
|
||||
(click)="settings.setOpenFilesOnEnter(false)"
|
||||
class="px-2 py-0.5 text-[0.6875rem]"
|
||||
[class.bg-accent]="!settings.openFilesOnEnter()"
|
||||
[class.text-foreground]="!settings.openFilesOnEnter()"
|
||||
[class.text-muted-foreground]="settings.openFilesOnEnter()"
|
||||
>
|
||||
{{ i18n.t('options.openFiles.nothing') }}
|
||||
</button>
|
||||
<button
|
||||
(click)="settings.setOpenFilesOnEnter(true)"
|
||||
class="px-2 py-0.5 text-[0.6875rem]"
|
||||
[class.bg-accent]="settings.openFilesOnEnter()"
|
||||
[class.text-foreground]="settings.openFilesOnEnter()"
|
||||
[class.text-muted-foreground]="!settings.openFilesOnEnter()"
|
||||
>
|
||||
{{ i18n.t('options.openFiles.open') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="-mt-1 pl-3 text-[0.6875rem] text-muted-foreground">{{ i18n.t('options.openFiles.hint') }}</p>
|
||||
}
|
||||
|
||||
@case ('keyboard') {
|
||||
<!-- search + reset all -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<ng-icon
|
||||
name="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') {
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
import { Component, ElementRef, afterNextRender, computed, inject, signal, viewChild } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { BRANDING } from '@shared/branding';
|
||||
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
||||
import {
|
||||
type Combo,
|
||||
comboFromEvent,
|
||||
conflicts,
|
||||
firesInTextField,
|
||||
GROUPS,
|
||||
isReserved,
|
||||
ownerOf,
|
||||
type ResolvedBinding,
|
||||
sameCombo,
|
||||
type ShortcutId,
|
||||
typesCharacter,
|
||||
} from '@shared/keymap';
|
||||
import { COLUMN_KEYS } from '@core/context-menu.service';
|
||||
import { cycleFocus } from '@core/focus-cycle';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform';
|
||||
import { blockNativeContextMenu, blockReloadKeys, IS_MAC, IS_MAC_CHROME } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { ShortcutsService } from '@core/shortcuts.service';
|
||||
|
||||
const SECTIONS = ['appearance', 'transfers', 'editor', 'fileList', 'about'] as const;
|
||||
const SECTIONS = ['appearance', 'transfers', 'editor', 'fileList', 'keyboard', 'about'] as const;
|
||||
type Section = (typeof SECTIONS)[number];
|
||||
|
||||
const THEMES = ['system', 'light', 'dark'] as const;
|
||||
@@ -21,10 +35,23 @@ const SPEED_UNITS = [
|
||||
const TRANSFER_MODES = ['single', 'multi'] as const;
|
||||
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
|
||||
/** The keys no remap moves, listed read-only under the editable ones. */
|
||||
const FIXED_LINES = ['escape', 'escList', 'dialogs', 'menus', 'filter', 'shift', 'find', '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
|
||||
* SettingsService setter: the save goes to bun, which rebroadcasts it, so the
|
||||
* other windows follow live while this one stays open.
|
||||
* other windows follow live while this one stays open — keyboard remaps
|
||||
* included (menu hints, tooltips and the macOS menu bar follow at once).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -33,13 +60,15 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
host: {
|
||||
'(window:keydown)': 'onWindowKeydown($event)',
|
||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||
'(window:mousedown)': 'onWindowMousedown($event)',
|
||||
'(window:blur)': 'capturing.set(null)',
|
||||
},
|
||||
})
|
||||
export class SettingsWindow {
|
||||
readonly settings = inject(SettingsService);
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly shortcuts = inject(ShortcutsService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
private readonly shortcuts = inject(ShortcutsService);
|
||||
|
||||
readonly isMacChrome = IS_MAC_CHROME;
|
||||
readonly sections = SECTIONS;
|
||||
@@ -52,11 +81,50 @@ export class SettingsWindow {
|
||||
readonly transferModes = TRANSFER_MODES;
|
||||
readonly concurrencyChoices = CONCURRENCY_CHOICES;
|
||||
readonly columnKeys = COLUMN_KEYS;
|
||||
readonly fixedLines = FIXED_LINES;
|
||||
readonly appName = BRANDING.appName;
|
||||
readonly buildLabel = BRANDING.buildLabel;
|
||||
/** "100%" for the stepper readout; the value itself lives in settings. */
|
||||
readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`);
|
||||
|
||||
private readonly nav = viewChild.required<ElementRef<HTMLElement>>('nav');
|
||||
|
||||
// ---- Settings → Keyboard ----
|
||||
readonly keyFilter = signal('');
|
||||
/** The command being recorded, if any. */
|
||||
readonly capturing = signal<ShortcutId | null>(null);
|
||||
readonly note = signal<CaptureNote | null>(null);
|
||||
readonly confirmResetAll = signal(false);
|
||||
readonly anyCustom = computed(() => this.shortcuts.keymap().some((b) => b.custom));
|
||||
private readonly conflictMap = computed(() => conflicts(this.shortcuts.keymap()));
|
||||
|
||||
/** The editable rows by group, narrowed by the search field (label or keys). */
|
||||
readonly keyGroups = computed(() => {
|
||||
const query = this.keyFilter().trim().toLowerCase();
|
||||
const keymap = this.shortcuts.keymap();
|
||||
return GROUPS.map((group) => ({
|
||||
id: group,
|
||||
rows: keymap.filter((b) => b.group === group && (!query || this.rowText(b).includes(query))),
|
||||
})).filter((group) => group.rows.length);
|
||||
});
|
||||
|
||||
/** The fixed keys, in this platform's spelling, for the read-only lines. */
|
||||
readonly fixedKeys = computed(() => ({
|
||||
esc: this.shortcuts.fixedKey('Escape'),
|
||||
enter: this.shortcuts.fixedKey('Enter'),
|
||||
shiftEnter: this.shortcuts.fixedKey('Shift+Enter'),
|
||||
tab: this.shortcuts.fixedKey('Tab'),
|
||||
shiftTab: this.shortcuts.fixedKey('Shift+Tab'),
|
||||
backspace: this.shortcuts.fixedKey('Backspace'),
|
||||
home: this.shortcuts.fixedKey('Home'),
|
||||
end: this.shortcuts.fixedKey('End'),
|
||||
}));
|
||||
|
||||
constructor() {
|
||||
// the keyboard starts on the current section
|
||||
afterNextRender(() => this.sectionButton(this.section())?.focus());
|
||||
}
|
||||
|
||||
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
|
||||
editorAppName(): string {
|
||||
const path = this.settings.editorApp();
|
||||
@@ -74,14 +142,151 @@ export class SettingsWindow {
|
||||
void this.rpc.request.showAbout({});
|
||||
}
|
||||
|
||||
/** Any-window shortcuts (interface scale); ⌘W arrives as a menu action bun-side. */
|
||||
onWindowKeydown(event: KeyboardEvent): void {
|
||||
const match = this.shortcuts.resolve(event, 'settings');
|
||||
if (!match || this.shortcuts.isTypingTarget(event)) return;
|
||||
// ---- sections ----
|
||||
|
||||
/** ↑ / ↓ in the sidebar move between sections (and focus follows). */
|
||||
onNavKeydown(event: KeyboardEvent): void {
|
||||
const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
|
||||
if (!step) return;
|
||||
event.preventDefault();
|
||||
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
||||
else if (match.id === 'scale-reset') this.settings.setUiScale(1);
|
||||
const next = SECTIONS[(SECTIONS.indexOf(this.section()) + step + SECTIONS.length) % SECTIONS.length];
|
||||
this.section.set(next);
|
||||
this.sectionButton(next)?.focus();
|
||||
}
|
||||
|
||||
private sectionButton(section: Section): HTMLElement | null {
|
||||
return this.nav().nativeElement.querySelector<HTMLElement>(`[data-section="${section}"]`);
|
||||
}
|
||||
|
||||
// ---- keyboard remaps ----
|
||||
|
||||
rowLabel(binding: ResolvedBinding): string {
|
||||
return binding.n
|
||||
? this.i18n.t('keys.go-to-tab', { n: binding.n })
|
||||
: this.i18n.t(`keys.${binding.id}`);
|
||||
}
|
||||
|
||||
private labelOf(id: ShortcutId): string {
|
||||
const binding = this.shortcuts.binding(id);
|
||||
return binding ? this.rowLabel(binding) : id;
|
||||
}
|
||||
|
||||
private rowText(binding: ResolvedBinding): string {
|
||||
return [this.rowLabel(binding), ...binding.combos.map((c) => this.shortcuts.format(c))]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/** "Rename, Edit" when this row shares a key with others that can collide. */
|
||||
conflictWith(binding: ResolvedBinding): string {
|
||||
return (this.conflictMap().get(binding.id) ?? []).map((id) => this.labelOf(id)).join(', ');
|
||||
}
|
||||
|
||||
/** A plain character key on a file-list row: typing it no longer filters. */
|
||||
filterKey(binding: ResolvedBinding): string {
|
||||
if (!binding.scopes.some((s) => s === 'panel' || s === 'main')) return '';
|
||||
const plain = binding.combos.find(typesCharacter);
|
||||
return plain ? this.shortcuts.format(plain) : '';
|
||||
}
|
||||
|
||||
noteText(note: CaptureNote): string {
|
||||
const key = this.shortcuts.format(note.combo);
|
||||
if (note.kind === 'reserved') return this.i18n.t('keys.ui.reserved', { key });
|
||||
if (note.kind === 'needsModifier') return this.i18n.t('keys.ui.needsModifier', { key });
|
||||
return this.i18n.t('keys.ui.usedBy', { key, action: note.owner ? this.labelOf(note.owner) : '' });
|
||||
}
|
||||
|
||||
startCapture(id: ShortcutId): void {
|
||||
this.note.set(null);
|
||||
this.capturing.set(this.capturing() === id ? null : id);
|
||||
}
|
||||
|
||||
/**
|
||||
* One keydown while recording, taken before every other key in this window
|
||||
* (⌘W records instead of closing). A bare modifier keeps waiting; Esc on its
|
||||
* own cancels. Reserved combos are refused WITHOUT preventDefault, so ⌘Q
|
||||
* still quits and ⌘C still copies.
|
||||
*/
|
||||
private record(event: KeyboardEvent, id: ShortcutId): void {
|
||||
const combo = comboFromEvent(event, IS_MAC);
|
||||
if (combo && isReserved(combo, IS_MAC)) {
|
||||
this.capturing.set(null);
|
||||
this.note.set({ id, kind: 'reserved', combo });
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.repeat || !combo) return;
|
||||
const bare = !combo.mod && !combo.ctrl && !combo.alt && !combo.shift;
|
||||
this.capturing.set(null);
|
||||
if (bare && combo.key === 'Escape') return;
|
||||
const binding = this.shortcuts.binding(id);
|
||||
if (!binding || binding.combos.some((c) => sameCombo(c, combo))) return;
|
||||
// the editor's text area always has focus: a plain key could never fire there
|
||||
if (binding.scopes.includes('editor') && !firesInTextField(combo, IS_MAC)) {
|
||||
this.note.set({ id, kind: 'needsModifier', combo });
|
||||
return;
|
||||
}
|
||||
const owner = ownerOf(this.shortcuts.keymap(), combo, binding.scopes, id);
|
||||
if (owner) {
|
||||
this.note.set({ id, kind: 'conflict', combo, owner: owner.id });
|
||||
return;
|
||||
}
|
||||
this.shortcuts.rebind({ [id]: [...binding.combos, combo] });
|
||||
}
|
||||
|
||||
/** "Replace": the key moves from the command that had it to this one. */
|
||||
replaceConflict(): void {
|
||||
const note = this.note();
|
||||
const binding = note && this.shortcuts.binding(note.id);
|
||||
const owner = note?.owner && this.shortcuts.binding(note.owner);
|
||||
if (!note || !binding || !owner) return;
|
||||
this.shortcuts.rebind({
|
||||
[note.id]: [...binding.combos, note.combo],
|
||||
[owner.id]: owner.combos.filter((c) => !sameCombo(c, note.combo)),
|
||||
});
|
||||
this.note.set(null);
|
||||
}
|
||||
|
||||
removeCombo(binding: ResolvedBinding, index: number): void {
|
||||
this.shortcuts.rebind({ [binding.id]: binding.combos.filter((_, i) => i !== index) });
|
||||
}
|
||||
|
||||
resetRow(binding: ResolvedBinding): void {
|
||||
this.shortcuts.rebind({ [binding.id]: binding.defaults });
|
||||
if (this.note()?.id === binding.id) this.note.set(null);
|
||||
}
|
||||
|
||||
resetAll(): void {
|
||||
this.shortcuts.resetAll();
|
||||
this.confirmResetAll.set(false);
|
||||
this.note.set(null);
|
||||
}
|
||||
|
||||
// ---- window ----
|
||||
|
||||
/** Recording first; then this window's keys (close-window, the scale) and
|
||||
* Tab, walked by hand — WKWebView skips buttons on Tab otherwise. */
|
||||
onWindowKeydown(event: KeyboardEvent): void {
|
||||
const recording = this.capturing();
|
||||
if (recording) {
|
||||
this.record(event, recording);
|
||||
return;
|
||||
}
|
||||
const id = this.shortcuts.claim(event, 'settings');
|
||||
if (id === 'close-window') void this.rpc.request.settingsClose({});
|
||||
else if (id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||
else if (id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
||||
else if (id === 'scale-reset') this.settings.setUiScale(1);
|
||||
else if (!id) cycleFocus(document.body, event);
|
||||
blockReloadKeys(event);
|
||||
}
|
||||
|
||||
/** A click anywhere but a record button ends a recording. */
|
||||
onWindowMousedown(event: MouseEvent): void {
|
||||
if (this.capturing() && !(event.target as Element).closest('[data-record]')) {
|
||||
this.capturing.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
blockContextMenu(event: Event): void {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { TransferSnapshot } from '@shared/types';
|
||||
import { formatSpeed } from '@core/format';
|
||||
import { PanelsService } from '@core/panels.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { ShortcutsService } from '@core/shortcuts.service';
|
||||
import { TransfersService } from '@core/transfers.service';
|
||||
|
||||
@Component({
|
||||
@@ -18,10 +19,10 @@ import { TransfersService } from '@core/transfers.service';
|
||||
<button
|
||||
(click)="transfers.expanded.set(!transfers.expanded())"
|
||||
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) {
|
||||
<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">
|
||||
{{ i18n.t('transfer.copying', { name: job.name, percent: percent(job), speed: speedSuffix(job) }) }}
|
||||
@if (transfers.queuedCount(); as queued) {
|
||||
@@ -29,12 +30,12 @@ import { TransfersService } from '@core/transfers.service';
|
||||
}
|
||||
</span>
|
||||
} @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">
|
||||
{{ i18n.t('transfer.failed', { count: failed }) }}
|
||||
</span>
|
||||
} @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>
|
||||
} @else {
|
||||
<span>{{ i18n.t('transfer.ready') }}</span>
|
||||
@@ -44,8 +45,8 @@ import { TransfersService } from '@core/transfers.service';
|
||||
<!-- active panel info -->
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<ng-icon
|
||||
[name]="activePanel().sessionId() === 'local' ? 'tdesignHardDrive' : 'tdesignServer'"
|
||||
size="0.8rem"
|
||||
[name]="activePanel().sessionId() === 'local' ? 'hardDrive14' : 'server14'"
|
||||
size="var(--icon-size-sm)"
|
||||
[class.text-primary]="activePanel().sessionId() !== 'local'"
|
||||
/>
|
||||
<span class="max-w-56 truncate">
|
||||
@@ -62,6 +63,7 @@ import { TransfersService } from '@core/transfers.service';
|
||||
export class StatusBar {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly transfers = inject(TransfersService);
|
||||
readonly shortcuts = inject(ShortcutsService);
|
||||
readonly panels = inject(PanelsService);
|
||||
readonly settings = inject(SettingsService);
|
||||
readonly activePanel = computed(() => this.panels.get(this.panels.activeSide()));
|
||||
|
||||
@@ -3,8 +3,9 @@ import { NgIcon } from '@ng-icons/core';
|
||||
import { ContextMenuService } from '@core/context-menu.service';
|
||||
import { I18nService } from '@core/i18n.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 { ShortcutsService } from '@core/shortcuts.service';
|
||||
import { UpdaterService } from '@core/updater.service';
|
||||
|
||||
@Component({
|
||||
@@ -51,8 +52,8 @@ import { UpdaterService } from '@core/updater.service';
|
||||
[title]="panels.workspaceName(ws)"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="panels.workspaceIsRemote(ws) ? 'tdesignServer' : 'tdesignHardDrive'"
|
||||
size="0.85rem"
|
||||
[name]="panels.workspaceIsRemote(ws) ? 'server14' : 'hardDrive14'"
|
||||
size="var(--icon-size-sm)"
|
||||
class="shrink-0"
|
||||
[class.text-primary]="panels.workspaceIsRemote(ws)"
|
||||
/>
|
||||
@@ -63,10 +64,10 @@ import { UpdaterService } from '@core/updater.service';
|
||||
(click)="close($event, ws.id)"
|
||||
tabindex="-1"
|
||||
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"
|
||||
>
|
||||
<ng-icon name="tdesignClose" size="0.7rem" />
|
||||
<ng-icon name="close14" size="var(--icon-size-sm)" />
|
||||
</button>
|
||||
} @else {
|
||||
<span class="mr-1 size-4 shrink-0"></span>
|
||||
@@ -79,10 +80,10 @@ import { UpdaterService } from '@core/updater.service';
|
||||
</div>
|
||||
<button
|
||||
(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"
|
||||
>
|
||||
<ng-icon name="tdesignAdd" size=".8rem" />
|
||||
<ng-icon name="add14" size="var(--icon-size-sm)" />
|
||||
</button>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
@@ -105,13 +106,13 @@ import { UpdaterService } from '@core/updater.service';
|
||||
<span class="relative flex items-center gap-1.5">
|
||||
@switch (updater.state()) {
|
||||
@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') {
|
||||
<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') {
|
||||
<ng-icon name="tdesignRefresh" size=".8rem" />{{ i18n.t('tabBar.restartToUpdate') }}
|
||||
<ng-icon name="refresh14" size="var(--icon-size-sm)" />{{ i18n.t('tabBar.restartToUpdate') }}
|
||||
}
|
||||
}
|
||||
</span>
|
||||
@@ -121,20 +122,20 @@ import { UpdaterService } from '@core/updater.service';
|
||||
<!-- connection manager (own native window) -->
|
||||
<button
|
||||
(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"
|
||||
>
|
||||
<!-- <ng-icon name="tdesignSetting" size="0.9rem" /> -->
|
||||
<!-- <ng-icon name="setting14" size="var(--icon-size-sm)" /> -->
|
||||
{{ i18n.t('tabBar.connections') }}
|
||||
</button>
|
||||
|
||||
<!-- settings (own native window) -->
|
||||
<button
|
||||
(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"
|
||||
>
|
||||
<ng-icon name="tdesignSetting" size="0.9rem" />
|
||||
<ng-icon name="setting14" size="var(--icon-size-sm)" />
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
@@ -145,8 +146,8 @@ export class TabBar {
|
||||
readonly updater = inject(UpdaterService);
|
||||
private readonly contextMenu = inject(ContextMenuService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
readonly shortcuts = inject(ShortcutsService);
|
||||
readonly isMacChrome = IS_MAC_CHROME;
|
||||
readonly modLabel = MOD_LABEL;
|
||||
|
||||
// drag & drop reordering
|
||||
readonly draggedId = signal<number | null>(null);
|
||||
@@ -213,9 +214,7 @@ export class TabBar {
|
||||
|
||||
/** Update pill: first click downloads, second click (when ready) applies + relaunches. */
|
||||
onUpdateClick(): void {
|
||||
const state = this.updater.state();
|
||||
if (state === 'available') void this.updater.download();
|
||||
else if (state === 'ready') this.updater.apply();
|
||||
this.updater.advance();
|
||||
}
|
||||
|
||||
/** Insertion line at the drop position (inset shadow: no layout shift). */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Component, inject } from '@angular/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';
|
||||
|
||||
/** Toast stack overlay; shared by the main window and editor windows. */
|
||||
@@ -7,7 +9,7 @@ import { ToastService } from '@core/toast.service';
|
||||
selector: 'app-toasts',
|
||||
imports: [NgIcon],
|
||||
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) {
|
||||
<div
|
||||
(mouseenter)="toast.pause(t.id)"
|
||||
@@ -16,15 +18,16 @@ import { ToastService } from '@core/toast.service';
|
||||
>
|
||||
<ng-icon
|
||||
[name]="toastIcon(t.kind)"
|
||||
size="1rem"
|
||||
size="var(--icon-size-sm)"
|
||||
[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>
|
||||
<button
|
||||
(click)="toast.dismiss(t.id)"
|
||||
[attr.aria-label]="i18n.t('transfer.dismiss')"
|
||||
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>
|
||||
<!-- auto-dismiss countdown; freezes with the timer while hovered -->
|
||||
<div
|
||||
@@ -41,10 +44,11 @@ import { ToastService } from '@core/toast.service';
|
||||
})
|
||||
export class Toasts {
|
||||
readonly toast = inject(ToastService);
|
||||
readonly i18n = inject(I18nService);
|
||||
|
||||
toastIcon(kind: 'error' | 'info' | 'success'): string {
|
||||
if (kind === 'error') return 'tdesignCloseCircle';
|
||||
if (kind === 'success') return 'tdesignCheckCircle';
|
||||
return 'tdesignCheck';
|
||||
toastIcon(kind: 'error' | 'info' | 'success'): Icon14 {
|
||||
if (kind === 'error') return 'closeCircle14';
|
||||
if (kind === 'success') return 'checkCircle14';
|
||||
return 'check14';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
[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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div class="max-h-40 overflow-y-auto border-t border-border bg-chrome">
|
||||
@for (job of transfers.jobs(); track job.id) {
|
||||
<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>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[0.6875rem] text-muted-foreground">{{ progressLabel(job) }}</div>
|
||||
@@ -40,7 +40,7 @@
|
||||
[title]="i18n.t('transfer.cancel')"
|
||||
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>
|
||||
} @else {
|
||||
<button
|
||||
@@ -48,7 +48,7 @@
|
||||
[title]="i18n.t('transfer.dismiss')"
|
||||
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>
|
||||
}
|
||||
</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 type { TransferSnapshot } from '@shared/types';
|
||||
import { cycleFocus } from '@core/focus-cycle';
|
||||
import { formatEta } from '@core/format';
|
||||
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 { TransfersService } from '@core/transfers.service';
|
||||
|
||||
@@ -10,11 +13,35 @@ import { TransfersService } from '@core/transfers.service';
|
||||
selector: 'app-transfer-bar',
|
||||
imports: [NgIcon],
|
||||
templateUrl: './transfer-bar.html',
|
||||
host: { '(keydown)': 'onKeydown($event)' },
|
||||
})
|
||||
export class TransferBar {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly transfers = inject(TransfersService);
|
||||
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);
|
||||
|
||||
/** "14.2 MB/s · 0:42 left" while a job is moving bytes. */
|
||||
@@ -30,13 +57,13 @@ export class TransferBar {
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
stateIcon(job: TransferSnapshot): string {
|
||||
stateIcon(job: TransferSnapshot): Icon14 | 'tdesignLoading' {
|
||||
switch (job.state) {
|
||||
case 'completed':
|
||||
return 'tdesignCheckCircle';
|
||||
return 'checkCircle14';
|
||||
case 'failed':
|
||||
case 'cancelled':
|
||||
return 'tdesignCloseCircle';
|
||||
return 'closeCircle14';
|
||||
default:
|
||||
return 'tdesignLoading';
|
||||
}
|
||||
|
||||
+23
-1
@@ -197,7 +197,21 @@
|
||||
--path-ctl-h: 1.75rem;
|
||||
--action-h: 1.5rem;
|
||||
--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). */
|
||||
@@ -285,6 +299,14 @@ body {
|
||||
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
|
||||
theme (a data-URI svg can't). The opened option list stays native — macOS
|
||||
draws that popup itself. */
|
||||
|
||||
Reference in New Issue
Block a user