On a 1x screen TDesign's glyphs smeared at 14 and 16px: their 24-unit
strokes land between pixels, so every ~1px line covered two at half ink.
All 37 icon/size pairs are now TDesign's shapes redrawn on their own grid
(viewBox 0 0 N N, so one unit is one CSS pixel):
- Straight lines are runs of whole pixels that end on pixel edges, with
no caps or joins. WebKit drops square caps on a path of several
subpaths, and Skia strokes 1px lines as hairlines that leave a joined
corner pixel three-quarters inked.
- Slants and curves are 1.25px so they read as heavy as the crisp runs.
Their ends, vertices and circle extremes sit on pixel centres.
- server24 keeps TDesign's 24px geometry; only its dots move onto whole
pixels.
Only closeCircle24 (TDesign's own 24px grid) and the spinner still come
from @ng-icons/tdesign-icons. This also fixes add14 from 5d23809, whose
square-capped ends rendered half-inked in WebKit. icons.spec.ts now also
checks that every grid name's glyph uses that grid's viewBox.
Checked at 1x and 2x in WebKit (the app's macOS engine) and in Chrome
(Skia, as in WebView2 and WebKitGTK); straight runs fill whole pixels in
both. In WebKit at 1x, a copy of the tab bar, toolbar, file rows, menu
and shortcut-row markup rendered every straight-line glyph with zero
blur, including icons laid out on fractional pixels.
Co-Authored-By: Claude <noreply@anthropic.com>
532 lines
34 KiB
Markdown
532 lines
34 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, 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`) +
|
||
`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). **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
|
||
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.
|