Every surface was one flat shade. The window now reads in layers: a darker frame (tab, transfer and status bars), a lighter header strip per file panel (session strip, path bar, column headers), the file list, and popovers above them all. The active panel's header carries a tint of the accent, and odd file rows get a faint zebra stripe (per row, so it stays aligned at any interface scale; the cursor and selection replace it). Hover and selected states become a translucent overlay: an opaque hover vanished on a surface of its own lightness (light's header strip). Dark cards and badges step up so popovers never read darker than the strip they cover. AMOLED is unchanged: every new layer collapses to black there. Co-Authored-By: Claude <noreply@anthropic.com>
20 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/): electrobun runs it inside a WebWorker of its native launcher. 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). 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). 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, showAbout.
Window kinds
src/bun/windows.ts — buildPreload(mode, settings, ctx?) injects
__APP_PLATFORM, __APP_MODE (main|editor|manager), __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 three 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:13} (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 (electrobun window close is NOT cancellable — verified, no
allow response like before-quit has):
- ⌘W (routed via the app menu →
editorCloseRequest) / Close button → styled in-window Save/Don't Save/Cancel; 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).
Connection-manager window (src/bun/manager-window.ts)
Single instance, centered over main, modal to main only (editors stay
usable). No native modality exists, 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).
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). ⌘W is intercepted by the menu, so bun routesclose-tabto the focused 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: three 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 — calls fired during boot would otherwise be silently lost.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/DarkTone/Accentsetdata-*attributes on<html>;applyUiScalesets its inlinefont-size);applyAllalso serves thesettingsChangedbroadcast, so every window follows a save made in any other. All three window RPCs servesaveSettings(editor and manager windows save too — the scale shortcuts); 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. New setting = five touches here + types.ts (backend merges automatically).focus.service.ts— dependency-freetick/bump(); dialogs bump it, the main window's panels listen. Exists so dialogs don't inject PanelsService (whose constructor spawns a workspace — fatal in secondary windows).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— toolbar/keyboard/context-menu file ops, always against an explicit panel;menuTargetremembers the right-clicked row.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, and the header column picker) and holds the open request as a signal; the component renders it at the pointer with edge-flip clamping, arrow/Enter/Escape keys, and close-on-blur/resize/scroll. Picked items are emitted verbatim asctx-*/col:*/tab:*strings throughRpcService.emitMenuAction, so they land in the SAMEApp.handleMenuActiondispatcher the macOS app menubar uses — one handler, two sources. The three shell actions the webview cannot perform (reveal, open-with-default, copy path) go back to bun via 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, never accent-tinted) 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,MOD_LABEL/ALT_LABEL) +blockNativeContextMenu(kills WebKit's Reload/Inspect menu outside text fields — the app draws its own menu instead).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".
Theming
ui/src/styles.css: shadcn-style OKLCH tokens; 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. Light is the zinc family; the
dark neutrals are a warm gray family (hue 107) anchored on #1a1a19
(--background) — retoning one dark neutral means retoning them all, or warm
and cool surfaces clash. Surface layers, each its own shade: --chrome
(frame — tab/transfer/status bars, editor/manager header+footer strips, the
manager's connection sidebar), --toolbar (each file panel's header strip —
session strip, path bar, column headers — and the editor's search bar; the
ACTIVE panel's strip is --toolbar-active, a color-mix of --primary into
--toolbar, so it follows the accent), --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). Dark climbs frame 0.165 < list 0.218 < header 0.265 < card 0.285;
light steps greys out from white content: frame 0.935 < header 0.965 < list =
card 1. --accent is translucent (6% zinc-900 light, 9% white dark):
hover/selected states sit on every layer, and an opaque hover vanishes on a
surface of its own lightness. The "darker" tone is true-black AMOLED: chrome,
background, toolbar (+active) and row-alt are all #000 (the layers collapse
on purpose), elevation survives only via --card/--accent (opaque there),
and --border/--input go to 16%/18% white because those lines then carry the
whole layout. Accent tones: one
--tone-h/--tone-c line per tone + three formula blocks derive --primary,
--ring, --cursor-tint; graphite = attribute removed = pristine zinc.
@theme inline maps tokens to Tailwind utilities (bg-primary,
text-icon-folder, bg-cursor-tint). Selected rows are bg-primary/15
(follow the accent); the cursor row uses --cursor-tint so graphite stays
pixel-identical to the old neutral hover. 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 tab-bar 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.