The manager had only Esc and the scale keys: its list took one Tab stop per row with no arrows, a stack's rename / delete / open-in-tabs buttons existed only on hover, and no action had a key. - Commands (the manager keymap scope, remappable like the rest): new connection with focus in Name, new stack, save, connect — or open the focused stack in tabs — delete the focused row, rename the focused stack, import from WinSCP, close the window. - The list: up/down/Home/End move focus between rows (focus only: loading a connection would discard a half-edited form), Enter opens the focused connection, left/right collapse and expand a stack, and a stack's actions show while the keyboard is in its row. - Enter in a form field connects; Esc leaves a field before it closes the window; Tab is walked by hand (WKWebView skips buttons and radios); focus comes back after a dialog and never stays lost on <body>. - Tooltips show the keys; the Settings → Keyboard fixed-key list gains the manager's. Co-Authored-By: Claude <noreply@anthropic.com>
519 lines
33 KiB
Markdown
519 lines
33 KiB
Markdown
# miniSCP architecture
|
||
|
||
Dense reference for development and debugging. Companion files:
|
||
[build-and-release.md](build-and-release.md), [platform-notes.md](platform-notes.md),
|
||
[gotchas.md](gotchas.md).
|
||
|
||
## Process model
|
||
|
||
- **Bun main process** (`src/bun/`): the native launcher runs electrobun's
|
||
`main.js` on the Bun the 2.x devkit pins (1.4.0, shipped in the bundle —
|
||
`build.mainProcess: "bun"`), which starts our bundle in a Worker. Imports the
|
||
SDK as `electrobun/main` (projected into `.hutch/devkit/`). Owns every
|
||
privileged capability: filesystem, SSH, window management, native
|
||
menus/dialogs/tray, the connection/settings stores.
|
||
- **Webviews** (`ui/` Angular): one per window, system WebKit on macOS /
|
||
WebView2 on Windows / WebKitGTK on Linux. Zero privileged access — everything
|
||
goes through RPC.
|
||
- **RPC transport**: one WebSocket server in the bun process, multiplexed by
|
||
`webviewId` (each webview auto-connects using natively injected globals;
|
||
packets AES-GCM-encrypted per webview). 2.x Electroview queues sends while
|
||
its `hostSocket` connects and falls back to a native bridge when the socket
|
||
is unavailable. Requests are typed by the `AppRPC` schema in
|
||
`src/shared/rpc.ts`.
|
||
|
||
### The one-schema rule
|
||
|
||
`AppRPC` is a single superset schema shared by all window kinds. This works
|
||
because electrobun allows **partial handler sets** (a side only implements
|
||
what it serves) and **silently drops messages nobody listens to**. Every
|
||
response crosses the bridge as `Result<T>` (`{ok:true,…}|{ok:false,error}`) —
|
||
exceptions never cross; `wrap()` in `src/bun/rpc-handlers.ts` enforces it.
|
||
|
||
### Per-window RPC instances (critical)
|
||
|
||
Each `BrowserWindow` MUST receive its own `defineRPC(...)` instance: binding a
|
||
BrowserView calls `rpc.setTransport(...)`, so a shared instance would have its
|
||
transport stolen by the most recent window. `createAppRPC` (main),
|
||
`createEditorRPC` (per editor window, hooks close over that window's record),
|
||
`createManagerRPC` (the single manager window), `createSettingsRPC` (the single
|
||
Settings window). `rpc.send.*` on an instance targets exactly its own webview.
|
||
|
||
### The `events` late-binding pattern
|
||
|
||
`createAppRPC` needs to trigger things that need `rpc.send` (which doesn't
|
||
exist until after construction). `AppDeps.events` is a mutable object of
|
||
no-op functions, reassigned in `src/bun/index.ts` after everything exists:
|
||
`sessionClosed`, `connectionsChanged`, `openEditor`, `settingsChanged`,
|
||
`openConnectionManager`, `openSettings`, `showAbout`, `applyUpdate` (the
|
||
update restart lives in index.ts beside the before-quit guard it must get past —
|
||
see build-and-release.md "Auto-updater").
|
||
|
||
## Window kinds
|
||
|
||
`src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects
|
||
`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager|settings`), `__APP_CTX`
|
||
before any page script, plus the persisted theme attributes and interface
|
||
scale so the first paint is already right. `ui/src/main.ts` picks the root
|
||
component from `APP_MODE`; all four roots use selector `app-root` (index.html
|
||
hosts only that tag). `chromeOptions()` is the ONLY place the native frame is
|
||
decided, spread by the main window and `openSecondaryWindow`: macOS `hiddenInset` +
|
||
`trafficLightOffset {x:12,y:4}` (2.x semantics — see gotchas; the UI renders an
|
||
`h-10 pl-24` drag-region header via `IS_MAC_CHROME`); other platforms keep the
|
||
native titlebar and a compact `h-9` non-drag header.
|
||
|
||
`WindowRegistry<Rec>` (same file): keyed window map with dedupe/activate,
|
||
cascade placement off an anchor frame, focus tracking. electrobun's emitter
|
||
has **no `off()`** — `track()` closures capture only `(this, key)` so closed
|
||
records can be garbage collected. A future "window follows window" grouping
|
||
belongs here (`anchor.on('move')` + `setPosition`).
|
||
|
||
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/skins/shadlike/editor-window/`)
|
||
|
||
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
|
||
through a concrete session, and `local` is one shared session anyway).
|
||
Re-opening focuses the existing window. The webview fetches its own content
|
||
(`readFile`) — `openEditor` just creates the window with ctx in the preload.
|
||
|
||
**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 (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)
|
||
parks the draft via `EditManager.rescueDraft` into the edits-leftovers dir,
|
||
offered back at next boot. Known limit: the last ≤400 ms of keystrokes can
|
||
be lost on an instant type-then-close.
|
||
3. ⌘Q → the app-level `before-quit` guard (cancellable) counts dirty editors
|
||
plus running transfers.
|
||
|
||
`editorSave` is a dedicated request (not generic `writeFile`) so one handler
|
||
atomically writes + cleans the bun record + retitles + pushes `pathRefresh`
|
||
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). 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`)
|
||
|
||
Single instance, centered over main, **modal to main only** (editors stay
|
||
usable). No native modality exists (re-checked on 2.0.2: no parent/owner/sheet
|
||
option or input lock in any SDK or the core's exports; `styleMask.DocModalWindow`
|
||
is only the cosmetic NSWindow bit), so three layers approximate it: bun
|
||
bounces main's `focus` event back to the manager; the main webview renders a
|
||
click-swallowing overlay (`managerState` message); main drops its keyboard
|
||
shortcuts and bun drops main-targeted menu actions while open. Connect /
|
||
open-stack execute in the MAIN window via the `connect:<id>` /
|
||
`open-stack:<id>` menuAction channel (the manager closes right after — a
|
||
failed connect surfaces in main). Store edits push `connectionsChanged` so
|
||
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 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
|
||
|
||
- `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops +
|
||
path helpers + streams + **`fsKey`** (see self-copy below).
|
||
- `fs/local.ts` — node:fs, `lstat`-based, `fsKey="local"`. Owner/group via
|
||
cached `id -nu` / `dscacheutil|getent` spawns (win32 → undefined).
|
||
`listLocalDrives()` for the Windows drive picker (`fsutil`).
|
||
- `fs/sftp.ts` — ssh2 under Bun (pure JS; native accelerators are build
|
||
`external`s). POSIX paths always. `fsKey = resolvedIP:port` via `dns.lookup`
|
||
at connect. Owner/group parsed from the ls-style readdir `longname`.
|
||
Keepalive 15 s; `closedByUs` flag so `dispose()` doesn't fire the onClose
|
||
callback. mtime seconds→ms; mode masked `& 0o7777`.
|
||
- `fs/registry.ts` — sessionId → provider map; `"local"` is built-in;
|
||
`add()` mints `sftp-<n>`; `drop()` (session died) vs `remove()` (clean).
|
||
- `fs/watcher.ts` — `LocalDirWatcher`: `fs.watch` handles keyed by dir,
|
||
debounced ~150 ms (watch fires bursts). Drives local auto-refresh: the main
|
||
webview sends `setWatchedDirs` (the active tab's local panel cwds); a change
|
||
fires `onChange` → `rpc.send.pathRefresh({sessionId:"local", dir})`.
|
||
`onChange` is late-bound in index.ts (the `events` pattern). SFTP has no
|
||
watch, so remote panels are polled UI-side instead (see panels.service).
|
||
- `transfers/engine.ts` — FIFO job queue. `Semaphore` with live resize
|
||
(`wake()` reserves slots synchronously — an async waiter increment was a
|
||
race). Per-job worker pool (`MAX_WORKERS_PER_JOB=8`) draws from the shared
|
||
slot budget, so in-folder parallelism and cross-job fairness share one knob
|
||
(`setConcurrency` from settings; `single` mode = 1). `activeStreams` set
|
||
enables hard cancel. `emit` throttled 100 ms. `failSession` fails that
|
||
session's jobs. Probe files: `.{appname}-probe-*`.
|
||
- `transfers/walk.ts` — recursive tree walk (segment arrays, skips symlinks)
|
||
+ `removeRecursive`.
|
||
- **Self-copy protection** (data-loss class): copying a file onto itself
|
||
truncates it (`createWriteStream` O_TRUNCs before the read). Guards compare
|
||
**fsKey, never sessionId** — two sessions to one server are distinct
|
||
sessions on one filesystem. When fsKeys differ but paths collide,
|
||
`probeSameFilesystem` writes a token via dst and reads it via src to unmask
|
||
hidden aliases (SFTP has no dev/inode). Fails open (permissions produce
|
||
their own error). Residual risk (documented in README): same filesystem
|
||
mounted at different paths.
|
||
- `edits.ts` — `readFileText`/`writeFileText` (2 MB cap) for the built-in
|
||
editor; `EditManager` for the external-editor flow: temp-download under
|
||
`appDataDir()/edits/<id>/` + `meta.json`, directory watch (survives atomic
|
||
renames), 400 ms debounce → engine upload. Watchers die with their session;
|
||
leftover dirs are offered for cleanup at boot. `launchEditor` is the
|
||
platform seam (`open` / `cmd /c start` / `xdg-open`).
|
||
- `store/paths.ts` — per-OS `appDataDir()` + `migrateLegacyDataDir()` (renames
|
||
a `BRANDING.legacyNames` dir into place on boot; list currently empty).
|
||
- `store/connections.ts` — plaintext-passwords JSON (deliberate v1 choice),
|
||
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), 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
|
||
locale (from `settings.locale`, `system` via `Intl…resolvedOptions().locale`);
|
||
`t(key, params)` shares the same `src/shared/i18n` dictionaries as the UI.
|
||
Wired in index.ts on boot and on `settingsChanged` (relocalizes + rebuilds
|
||
the app menu). The context menu no longer needs this — it is Angular and reads
|
||
`I18nService` directly.
|
||
- `tray.ts` — static icon, click = activate main. macOS template image;
|
||
Windows colored icon. No `setImage` calls ever (they lose the template
|
||
treatment) and no menu (clicks arrive directly).
|
||
- `index.ts` — composition root: wiring order matters (events assigned after
|
||
rpc; manager/editors constructed after win; quit guard; focus tracking for
|
||
notification suppression across all windows).
|
||
|
||
## UI layout
|
||
|
||
```
|
||
ui/src/app/
|
||
core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts,
|
||
metrics.ts and every *.service.ts — the non-visual layer
|
||
skins/shadlike/ the component tree: four window roots (SHAD_ROOTS barrel,
|
||
picked by main.ts), dialogs, context menu
|
||
```
|
||
|
||
Components reach `core/` through the **`@core/*`** tsconfig alias (alongside
|
||
`@shared/*` → `src/shared`), which is deliberate: components sit at varying
|
||
depths and an alias keeps their imports identical wherever a file moves. Do not
|
||
replace it with relative paths.
|
||
|
||
**Where a new rule goes**: behaviour → `core/`; markup and appearance → the
|
||
component; tokens → `ui/src/styles.css`.
|
||
|
||
## UI services (`ui/src/app/core/`)
|
||
|
||
- `rpc.service.ts` — wraps `Electroview.defineRPC`. **Push messages fan out
|
||
through plain callback registries, never signals**: a signal consumed from
|
||
an effect that also writes signals re-triggers itself (this froze the whole
|
||
app once), and signal coalescing drops progress snapshots. All requests are
|
||
gated on the socket-open promise (`electroview.hostSocket`) — on 1.18.1 calls
|
||
fired during boot were silently lost; 2.x Electroview queues them itself, so
|
||
the gate is belt-and-braces now. `request` and `send` are ready-gated Proxies.
|
||
- `panels.service.ts` — `PanelState` (per panel): sessionId, fsKey, cwd,
|
||
entries, sort, cursor, selection, type-to-filter. `sorted` computed:
|
||
filter → dirs-first sort (name/size/mtime numeric, kind/rights/owner/group
|
||
via display-string compare) → synthetic `'..'` row unshifted unless
|
||
`isRootPath(cwd)`. `touched` marks user navigation (connect flows only
|
||
auto-load `localDir` into an untouched local panel). `connectToken`
|
||
supersedes stale connects. `Workspace` = tab = left/right pair + activeSide.
|
||
Remote goes RIGHT on connect/open-stack (commander convention); per-panel
|
||
dropdown connects target their own panel. **Auto-refresh** (always on):
|
||
`refresh({silent})` re-lists without the loading/busy flags and touches no
|
||
DOM (scroll/cursor/selection/filter preserved), snapshotting sessionId+cwd
|
||
to discard results if the user navigated mid-flight. Only the active tab is
|
||
live: its local panels are fs.watched (backend `setWatchedDirs` effect +
|
||
`pathRefresh` push → silent refresh), its remote panels polled every 3s
|
||
(`REMOTE_POLL_MS`); switching tabs silently refreshes the newly-active pair.
|
||
A future on/off setting is stubbed in commented breadcrumbs (types.ts,
|
||
settings.service, tab-bar).
|
||
- `transfers.service.ts` — jobMap from `transferUpdate` callbacks; EMA speed
|
||
(`SPEED_SMOOTHING=0.7`); `copyToOtherPanel` guards same-fs+same-dir before
|
||
enqueue and runs the collision flow (same-path auto-skip, type-mismatch
|
||
skip, overwrite dialog with all/none batch modes).
|
||
- `settings.service.ts` — one signal per setting; **imperative apply pattern**
|
||
(`applyTheme/DarkTone` set `data-*` attributes on `<html>`;
|
||
`applyUiScale` sets its inline `font-size`); `applyAll` also serves the
|
||
`settingsChanged` broadcast, so every window follows a save made in any
|
||
other. Every window RPC serves `saveSettings` (editor and manager windows
|
||
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. 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 — 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)`
|
||
(dotted lookup, `{param}` interpolation, `{one,other}` plural maps via
|
||
`Intl.PluralRules`); missing keys fall back to English then the raw key.
|
||
**Reactive by design**: `t()` reads the `locale` signal, so every template
|
||
binding that calls it re-renders live on a language switch (call-stack
|
||
tracking, see gotchas.md) — no pipe. Also exposes locale-aware `date()`/`size()`
|
||
/`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` — **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,
|
||
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` /
|
||
`disconnectWorkspace`. **The target id travels inside the action string**: the
|
||
right-clicked tab is not necessarily the active one, so nothing on that path
|
||
may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not
|
||
omitted — a menu whose items move between right-clicks is worse than one with
|
||
a dead row. The disabled styling (`pointer-events: none`) only stops the
|
||
pointer; the keyboard path never consults it — so `pick()` and the arrow-key
|
||
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.
|
||
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
|
||
`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
|
||
launch+6h, `onStatusChange` progress); `ready` is derived when the
|
||
`downloadUpdate` request resolves ok (not a pushed phase). `download()` /
|
||
`apply()` (relaunch) / `check()` (menu). Backend wiring lives in
|
||
`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**
|
||
(docs.oxide.computer: a cool neutral scale at hue 260 with almost no chroma,
|
||
one green accent, opaque strokes; the comments carry Oxide's step names
|
||
n0…n1300 / green-800). Dark values are duplicated in `:root[data-theme='dark']`
|
||
AND a `prefers-color-scheme` media block (system mode) — every dark token
|
||
change goes in BOTH; retone neutrals along the one scale, or warm and cool
|
||
surfaces clash. **There is no accent setting**: `--primary` is Oxide green per
|
||
theme (green-800 dark, green-500 light), so selection (`bg-primary/15`), the
|
||
cursor row (`--cursor-tint`, a `color-mix` of `--primary`), focus rings
|
||
(green-600) and `--success` all read green. **Surface layers**, each its own
|
||
shade: `--chrome` (frame — tab/transfer/status bars, the editor/manager/settings
|
||
header+footer strips and sidebars), the file panel's header strip **stepping
|
||
toward the list** — session strip `--toolbar`, path bar `--toolbar-2`, column
|
||
headers `--toolbar-3` (`color-mix` steps declared once in `:root`, so every
|
||
theme follows) — then `--background` (file lists, editor text, forms;
|
||
`--row-alt` stripes odd file rows, skipped on the cursor/selected row so two
|
||
`bg-*` utilities never fight) and `--card` (popovers, dialogs, toasts). The
|
||
right-click menu is the exception: the darkest surface (`bg-chrome`) behind a
|
||
faint `border-border/60`, and its highlighted item takes Oxide's selected style —
|
||
`--primary-surface` fill (Oxide surface-accent: green-200 dark, green-1200
|
||
light) with `text-primary`; mouse and keyboard share that one highlight
|
||
(`activeIndex`). The
|
||
ACTIVE panel is marked by a 0.125rem `--primary` line along its top edge (an
|
||
inset box-shadow on the session strip, so switching panels never shifts
|
||
layout). Dark: frame 0.162 < list 0.195 < header 0.275 → 0.248 → 0.221 < card
|
||
0.29; light steps greys out from near-white content: frame 0.925 < header 0.95
|
||
→ 0.965 → 0.98 < list = card 0.995. **`--accent` is translucent** (n0 at 5%
|
||
light, white 7% dark): hover/selected states sit on every layer, and an opaque
|
||
hover vanishes on a surface of its own lightness. Strokes are opaque
|
||
(`--border` n300 dark / n900 light, `--input` one step stronger). The
|
||
"darker" tone is true-black AMOLED: chrome, background, toolbar (+ its steps)
|
||
and row-alt are all `#000` (the layers collapse on purpose); elevation
|
||
survives only via `--card`/`--muted`/`--accent` (opaque there). **Square
|
||
design**: no `rounded-*` utilities and no radius tokens; an `@layer base`
|
||
block restyles native controls to match — checkboxes/radios (`appearance:
|
||
none`, a drawn check / square dot), selects (a `currentColor` caret; use
|
||
`pl-*`, never `px-*`, on a select), hidden number spinners, and square
|
||
`::-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`,
|
||
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
|
||
whole UI scales, chrome included.
|
||
Driven by the Settings window's stepper, Ctrl/Cmd +/−/0, and preload-baked for a
|
||
flash-free first paint. It replaced the 3-tier `data-text` axis; the store
|
||
migrates old `textSize` values. TS that measures rem-sized CSS converts through
|
||
`@core/metrics` `remPx()` — never an assumed 16.
|