The keymap was scattered over ShortcutsService if-chains, the file panel's key switch, macOS-only menu accelerators and hand-typed hints in 16 tooltips x 7 locales, and large parts of the app had no keyboard path at all. src/shared/keymap.ts is now the one table: scopes, per-platform defaults, exact matching, formatting with localized key names, conflicts, reserved combos and native-menu accelerators, proven by keymap.spec.ts. - ShortcutsService resolves against the effective keymap: claim() takes the key (preventDefault, held keys repeat only movement, never mid-IME), and from a text field only chords and F-keys fire. The file panel, App, editor, manager and Settings windows all dispatch through it. - FileActionsService.run is the one file-command dispatcher for the list keys, the row menu, the macOS menu bar and the toolbar. - New keys: the row menu (Shift+F10 / Menu / Ctrl+Return), the source picker (Mod+K), the path bar (Mod+L), transfers (Mod+J), sorting (Alt+1-7), Shift+movement selection, close other tabs, move and disconnect tabs, refresh and edit on Windows/Linux, permissions, copy path, reveal, trash, open in default app, editor find next/previous. - Right-click menus: soft shortcut hints, icons on the everyday verbs (the toolbar's glyphs), section headers, scrolling, item keys while open, a highlighted first item when opened from the keyboard. The panel source picker now renders through the same menu. - Dialogs: role=dialog, a Tab trap via cycleFocus (WKWebView skips buttons on Tab without Keyboard navigation), initial focus, Esc that stops propagating; the chmod dialog takes focus and applies on Enter. - Fixed: Ctrl+Tab also flipped the active panel; Esc in a manager dialog closed the manager; Ctrl-F/T fired Cmd shortcuts on macOS; shortcuts fired under dialogs; keys died after any button click on macOS; the editor's save prompt and find box never got focus; WebView2 reload keys; a window could save default settings before its first load. - Settings closes on its own close-window key (new settingsClose request). Co-Authored-By: Claude <noreply@anthropic.com>
30 KiB
miniSCP architecture
Dense reference for development and debugging. Companion files: build-and-release.md, platform-notes.md, gotchas.md.
Process model
- Bun main process (
src/bun/): the native launcher runs electrobun'smain.json 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 aselectrobun/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 itshostSocketconnects and falls back to a native bridge when the socket is unavailable. Requests are typed by theAppRPCschema insrc/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):
- ⌘W (the page's own
close-windowkey; a ⌘W it doesn't claim falls through to the app menu, which bun routes back aseditorCloseRequest) / 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 callseditorClosewhich setssuppressPromptbeforewin.close(). - 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 viaEditManager.rescueDraftinto 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. - ⌘Q → the app-level
before-quitguard (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).
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: 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.
Bun modules
fs/provider.ts—FileSystemProviderinterface: uniform local/SFTP ops + path helpers + streams +fsKey(see self-copy below).fs/local.ts— node:fs,lstat-based,fsKey="local". Owner/group via cachedid -nu/dscacheutil|getentspawns (win32 → undefined).listLocalDrives()for the Windows drive picker (fsutil).fs/sftp.ts— ssh2 under Bun (pure JS; native accelerators are buildexternals). POSIX paths always.fsKey = resolvedIP:portviadns.lookupat connect. Owner/group parsed from the ls-style readdirlongname. Keepalive 15 s;closedByUsflag sodispose()doesn't fire the onClose callback. mtime seconds→ms; mode masked& 0o7777.fs/registry.ts— sessionId → provider map;"local"is built-in;add()mintssftp-<n>;drop()(session died) vsremove()(clean).fs/watcher.ts—LocalDirWatcher:fs.watchhandles keyed by dir, debounced ~150 ms (watch fires bursts). Drives local auto-refresh: the main webview sendssetWatchedDirs(the active tab's local panel cwds); a change firesonChange→rpc.send.pathRefresh({sessionId:"local", dir}).onChangeis late-bound in index.ts (theeventspattern). SFTP has no watch, so remote panels are polled UI-side instead (see panels.service).transfers/engine.ts— FIFO job queue.Semaphorewith 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 (setConcurrencyfrom settings;singlemode = 1).activeStreamsset enables hard cancel.emitthrottled 100 ms.failSessionfails 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 (
createWriteStreamO_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,probeSameFilesystemwrites 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;EditManagerfor the external-editor flow: temp-download underappDataDir()/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.launchEditoris the platform seam (open/cmd /c start/xdg-open).store/paths.ts— per-OSappDataDir()+migrateLegacyDataDir()(renames aBRANDING.legacyNamesdir into place on boot; list currently empty).store/connections.ts— plaintext-passwords JSON (deliberate v1 choice), atomic tmp+rename, 0600 + win32icaclsACL.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 routesclose-tabto a focused Settings or editor window first. Labels go through backendi18n.tand 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 (fromsettings.locale,systemviaIntl…resolvedOptions().locale);t(key, params)shares the samesrc/shared/i18ndictionaries as the UI. Wired in index.ts on boot and onsettingsChanged(relocalizes + rebuilds the app menu). The context menu no longer needs this — it is Angular and readsI18nServicedirectly.tray.ts— static icon, click = activate main. macOS template image; Windows colored icon. NosetImagecalls 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— wrapsElectroview.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.requestandsendare ready-gated Proxies.panels.service.ts—PanelState(per panel): sessionId, fsKey, cwd, entries, sort, cursor, selection, type-to-filter.sortedcomputed: filter → dirs-first sort (name/size/mtime numeric, kind/rights/owner/group via display-string compare) → synthetic'..'row unshifted unlessisRootPath(cwd).touchedmarks user navigation (connect flows only auto-loadlocalDirinto an untouched local panel).connectTokensupersedes 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 (backendsetWatchedDirseffect +pathRefreshpush → 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 fromtransferUpdatecallbacks; EMA speed (SPEED_SMOOTHING=0.7);copyToOtherPanelguards 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/DarkTonesetdata-*attributes on<html>;applyUiScalesets its inlinefont-size);applyAllalso serves thesettingsChangedbroadcast, so every window follows a save made in any other. Every window RPC servessaveSettings(editor and manager windows save too — the scale shortcuts — and the Settings window is nothing but saves); bun'ssettingsSaved()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+.applyAllnever persists. Saves are whole-object, so nothing persists until the firstgetSettingslanded (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 inSettingsStore.load).focus.service.ts— dependency-freetick/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 oversrc/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 theconnectionsChangedpush.i18n.service.ts— runtime translator.localesignal +t('ns.key', params)(dotted lookup,{param}interpolation,{one,other}plural maps viaIntl.PluralRules); missing keys fall back to English then the raw key. Reactive by design:t()reads thelocalesignal, 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-awaredate()/size()/speed()so the file list reformats on switch.SettingsService.applyLocaleresolves thelocalesetting (system→navigator.language) into it, on boot and on thesettingsChangedbroadcast (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'sonPickclosure), the macOS menu bar's File/View items and the panel toolbar. Ids are keymapFileCommands (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.openenters folders and opens a LOCAL file in its default app only whenopenFilesOnEnteris set (Settings → File list);open-default(the menu's Open on a local file) always does.trashwalks the selection onemoveToTrashat 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 nativeshowContextMenuis 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-stackheaderitems,~/.ssh/confighosts — 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 carryshortcut(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) andicon(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'sonPicktakes the pick (row menu →FileActionsService.run, source picker →FilePanel); otherwisecol:*/tab:*strings go throughRpcService.emitMenuActionto the SAMEApp.handleMenuActiondispatcher the macOS app menubar uses. The three shell actions the webview cannot perform (reveal, open-with-default, copy path) go back to bun via theshellItemActionRPC, which is all that remains of the old native menu.tabItems()builds the workspace-tab menu (tab:new, andtab:close|close-others|close-right|disconnect:<id>), served byPanelsService.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 toactiveWorkspaceId. Illegal entries aredisabled, 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 — sopick()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 toprovideIconsinapp.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. Signalsstate(idle→available→downloading→ready|error),version,progress. Subscribes theupdateStatuspush (backendUpdater.checkForUpdateon launch+6h,onStatusChangeprogress);readyis derived when thedownloadUpdaterequest resolves ok (not a pushed phase).download()/apply()(relaunch) /check()(menu). Backend wiring lives insrc/bun/index.ts; feed isrelease.baseUrl(https://miniscp.com/dl, the/dlhost insite/). 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.
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-scrollbars (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.