Files
miniSCP/docs/gotchas.md
T
jgamboaandClaude 8f8f230711 Remap any shortcut in Settings → Keyboard; the macOS menu bar follows
A Keyboard section in the Settings window edits the shared keymap: rows
by group with a search field, a chip per key (x removes), + to record,
reset per row and Reset all. Recording takes the keydown before every
other key in the window (Cmd+W records instead of closing), refuses
reserved combos without preventDefault (Cmd+Q still quits), requires a
chord or F-key for editor commands, and offers Replace when another
command in an overlapping scope owns the key. Rows flag conflicts and
plain character keys that would stop starting a type-to-filter query.
Fixed keys are listed read-only, in the platform's spelling.

Remaps are ordinary settings (only rows that differ from the defaults),
so every window, the menu hints and the tooltips follow live — and the
macOS menu bar: its accelerators are built from the keymap (only the
combos Electrobun's parser expresses faithfully) and rebuilt on a remap.
A main-window menu action fired from the editor or Settings window
brings the main window forward first.

Also: the "Enter / double-click on a file" setting under File list (do
nothing / open local files in their default app), arrow keys in the
Settings sidebar with focus on open and a hand-walked Tab, translations
for all seven languages (the spec now checks every language labels every
command), and a README shortcut table generated from the keymap.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-05 14:57:27 +02:00

407 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Gotchas — landmines this codebase has already stepped on
Every one of these caused a real bug or debugging spiral. Read before
touching the adjacent code.
## Angular (v22, zoneless, signals, OnPush)
- **Never consume push streams through signal+effect.** An effect that reads
a signal and (directly or transitively) writes signals re-triggers itself —
this froze the entire app on the first transfer. Push messages
(transferUpdate etc.) go through plain callback registries in RpcService.
Signal coalescing would also silently drop intermediate progress snapshots.
- **No signal writes during template render** (NG0600). Anything that loads
panels from code paths reachable during render defers with
`queueMicrotask` (see `PanelsService.newWorkspace`).
- **`[value]` on `<select>` is applied before `@for` options render** and
silently selects nothing. Use per-option `[selected]` bindings (connection
manager stack picker).
- Template bindings with special characters: `[class.bg-primary/15]` works;
`[class.shadow-[...]]` (brackets) does NOT parse — use `[style.box-shadow]`.
Static class attributes may use arbitrary values freely.
- Functions called from templates ARE reactive: signals read inside them
register on the view's consumer (call-stack-based tracking). That's why a
template calling `i18n.t()` re-renders on a language switch — `t()` reads
the `locale` signal.
- Dedicated tools > shell: IDE diagnostics between paired template/class
edits are often stale mid-sequence — trust `bun run build:ui` (full AOT
type-check), not the first red squiggle.
- Tailwind v4 canonical class names: `wrap-break-word` not `break-words`,
`accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`.
Every color used in a template must resolve through `@theme inline`.
- Stylesheet `url()` must resolve at BUILD time: assets referenced from
`styles.css` live under `ui/src/` (esbuild emits them hashed into `media/`
and rewrites the url), NOT `ui/public/` — a public path fails the build with
"Could not resolve".
- **Two `class` attributes on one element: the second is silently dropped.**
No warning, no build error — the layout classes simply never apply. This
collapsed the tab strip and two window headers at once (`fe028a9`), because
a scripted edit appended `class="…"` to elements that already had one.
When removing a conditional binding, check what the branch *contained*: the
bindings there were genuinely dead, but the layout classes inside them were
not, and nothing replaced them.
- **A component `template:` is a TS template literal, so no backticks inside
it** — not even in an HTML comment. A backtick in a comment terminates the
literal and the errors point at the prose, not at the quoting.
- **Reach `core/` through the `@core/*` alias, never a relative path.**
Components sit at varying depths and get moved; the alias is what let 67
imports survive two relocations untouched. A `../../core/...` path is a
future merge conflict.
- **The build DOES type-check components nothing imports.** ngtsc compiles the
whole program, so an unreferenced component still fails `bun run build:ui` on
a type or template error (verified by injecting an error into an
unreferenced file).
- **`overflow-x: auto` also clips vertically.** CSS refuses to pair `visible`
with a non-visible axis, so the computed `overflow-y` becomes `auto` too:
anything poking out of a horizontally scrolling strip (a raised tab, an
underline at `-bottom-px`) is silently clipped. Reserve padding instead.
## Theming (`ui/src/styles.css`)
- **A new surface token needs an AMOLED line, or AMOLED stops being
all-black.** The two `[data-dark-tone='darker']` blocks re-declare every
surface (`--chrome`, `--toolbar`, `--background`, `--row-alt`, …) as `#000`;
a token added only to the dark blocks shows through in AMOLED. Tokens derived
in `:root` by `color-mix` (`--toolbar-2/-3`) follow on their own.
- **Hover/selected color is a translucent overlay (`--accent`).** An opaque
hover vanishes on a surface of its own lightness — light's old 0.967 hover
on the 0.965 header strip did exactly that. Keep `--accent` alpha-based when
retoning surfaces.
- **A native control ignores `border-radius` and background until
`appearance: none`.** WebKit draws checkboxes, radios and selects itself;
the square restyle in the `@layer base` block depends on it. Put `pl-*`,
never `px-*`, on a `<select>`: utilities outrank the base layer, so a `px`
utility would erase the padding-right the drawn caret lives in.
- **A custom `::-webkit-scrollbar` turns off macOS overlay scrollbars.** The
moment the pseudo-element is styled, WKWebView draws classic always-visible
scrollbars that take layout width. Deliberate here (square design); don't
reach for it to "tint" scrollbars without accepting that.
## The interface scale (root font-size lever)
- **electrobun's native page zoom is a silent no-op on Linux — do not reach for
it.** `webviewSetPageZoom` in the shipped `libNativeWrapper.so` (v1.18.1, and
the `_cef.so`, and the copy in our own build output) is a compiled
empty-body stub: the disassembly has no `call` instruction, and
`webkit_web_view_set_zoom_level` is not even in the library's imports.
`webviewGetPageZoom` returns a hardcoded `1.0` from `.rodata`, so the failure
is unobservable through the API — set 2.0, read back "1.0 = success". The
JSDoc's "(WebKit only)" means macOS WKWebView. This is why the scale is CSS.
(Disassembled on 1.18.1; 2.0.2's zoom fixes are CEF-only — re-check the
native Linux wrapper before trusting it.)
- **Everything the UI sizes with must be rem.** The scale works by setting the
root font-size, so a px value freezes while everything around it grows. The
px reservoirs were the panel-chrome tokens (`--row-h` etc.) and 93
`text-[11px]` arbitraries. 1px hairlines and focus rects stay px deliberately
(crisp at any scale).
- **px constants in TS that mirror rem-sized CSS are latent scale bugs.** The
context menu's `MENU_W = 224` was a hardcoded mirror of `w-56`
(14rem): box grew, math didn't, flip fired at the wrong edge. Convert through
`@core/metrics` `remPx()` at use time — it reads the LIVE root font-size,
and per call, since the scale changes mid-session. The editor's canvas-based
`charWidth` is cached keyed on the computed font string for the same reason.
## Electrobun
- **The `electrobun` npm package (2.x) is a bootstrap whose every export is a
throwing stub** ("Electrobun 2.x APIs come from the Hutch devkit, not
node_modules"). Every `electrobun/*` import must resolve through the
projected `.hutch/devkit/`: the root tsconfig extends
`.hutch/devkit/tsconfig.json`, and `ui/tsconfig.json` maps `electrobun/view`
to `../.hutch/devkit/api/browser/index.ts` (the exact export-map target). Let
an import fall through to `node_modules/electrobun` and the webview throws at
load. tsc and the UI build fail on unresolved `electrobun/*` imports until
`bunx electrobun prepare` has run (postinstall and every dev/build script do
it). Never point anything into `~/.hutch` — that store is Hutch-owned.
- **`build.mainProcess` defaults to Cottontail in 2.x**, and Cottontail ignores
the whole `build.bun` block — the ssh2 externals included, silently (verified
with `bunx electrobun config --env=stable`). `mainProcess: "bun"` in
`electrobun.config.ts` is load-bearing.
- **The browser SDK is raw TS compiled inside the Angular program**, so the UI's
strictness flags apply to upstream code: 2.0.2's `createTransport().send`
returns on one path only, which is why `ui/tsconfig.json` has
`noImplicitReturns: false`. Its `import "./global.d.ts"` also prints "File
'…/global.d.ts' not found in TypeScript compilation" on every build — benign,
and adding the file to the program does not silence it.
- **Window `close` is not cancellable, but 2.x added `will-close`.** The `close`
event's response type is `{}`; `will-close` (fired by the titlebar/traffic-light
close and `win.requestClose()`) honors `event.response = {allow:false}`, and
`win.close()` skips it. The editor close safety predates this: all
unsaved-state guards still intercept app-controlled close paths (⌘W, buttons)
or survive the close (editor draft mirroring) — a `will-close` prompt is a
possible follow-up.
- **`Updater.applyUpdate()` asks the `before-quit` handlers first (2.x)**, so the
transfers/unsaved guard can veto an update restart. Its "Quit Anyway" must
re-run `Updater.applyUpdate()` (`events.applyUpdate` flags the quit as
update-initiated) — a plain `Utils.quit()` there quits without installing.
`Utils.quit()` runs the same approval, which is why the guard checks
`quitConfirmed` first.
- **macOS ⌘-keys go to the PAGE first; only unclaimed ones reach the app
menu.** WKWebView's `performKeyEquivalent` hands key equivalents to the page
while the webview is first responder (WebKit `WebViewImpl.mm`: "lets
webpages have a crack at intercepting key-modified keypresses"); a key the
page `preventDefault`s never reaches the menu. Old comments here claimed the
opposite ("the menu intercepts ⌘W first") — what was really happening is that
windows which didn't claim ⌘W let it fall through to the menu's Close Tab.
Two consequences: a handled shortcut MUST `preventDefault` (`claim()` does),
or the menu runs it a second time; and a ⌘W a secondary window doesn't claim
still arrives as the MAIN window's `close-tab` action — bun checks the
focused Settings window, then editors, first and closes THAT, so a new
window kind must either claim close-window or join that check.
- **One rpc instance per window** — BrowserView binding steals the rpc's
transport; a shared instance silently sends to the wrong webview.
- **RPC before socket open was silently lost on 1.18.1.** 2.x Electroview queues
sends while its `hostSocket` (v1: `bunSocket`) is CONNECTING and falls back to
the native bridge; RpcService still gates everything on the socket-open
promise (`ready`) as belt-and-braces.
- The emitter has no `off()`. Window event closures must not capture records
you want garbage collected.
- `electrobun build` defaults to the **dev** channel; release builds need
`--env=stable`. The stable app name differs from the dev one
(`miniSCP` vs `miniSCP-dev`), including the app-data dir implications when
testing.
- Tray: never call `setImage` after construction on macOS — the template
treatment is lost and the icon renders black on dark menu bars.
- `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are
single-character only.
- **`ContextMenu.showContextMenu` is a silent no-op on Linux** (electrobun's own
`.hutch/devkit/api/sdks/main/proc/linux.md` says so, still in 2.0.2; the app
also prints "Application menus are not supported on Linux" at boot). Because
the UI suppresses the webview's own right-click menu, that left EVERY panel
context menu dead on Linux — no error, nothing happens. The menu is plain
HTML now (`ui/src/app/skins/shadlike/context-menu.ts`) on every platform;
don't reintroduce the native one for "nativeness" on mac/win without keeping
a Linux path.
- **Window-open flash prevention is three-layer** — remove any one and the
white/inverted blink returns: (1) windows are created `hidden: true` and
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
fallback), (2) the bun preload bakes the persisted `data-theme`/tone
attributes and the interface scale before first paint (`buildPreload`),
(3) `ui/src/index.html` carries an inline `<style>` with the html background
per theme — its oklch values MUST stay in sync with the `--background` tokens
in styles.css. (The win7 branch once replaced (3) with a transparent rule and
the modern skin silently lost it — the other two layers do not cover it.)
- Dev vs prod URL: `getMainViewUrl()` resolves the ng-serve dev server only
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the
server answers a HEAD request; secondary windows must reuse the resolved
URL, never hardcode `views://`.
- Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`):
rare segfault under long sessions; upstream electrobun×Bun issue, restart
recovers, nothing app-side to fix. Seen on Bun 1.3.13 (Electrobun 1.18.1);
2.0.2 ships Bun 1.4.0 — unconfirmed whether that fixed it.
- **Preloads and frames are construction-time only.** The preload is a
persistent `WebKitUserScript` at DOCUMENT_START, frozen at webview
construction — `webview.loadURL()` re-runs the OLD one
(`updatePreloadScriptToWebView` is only reachable from electrobun's internal
`<electrobun-webview>` handler). `titleBarStyle`/`transparent` have no
setters. Anything `buildPreload` bakes is a first-paint snapshot the UI must
correct live over RPC. Recreating the main window is not an escape hatch:
`exitOnLastWindowClosed`, push closures and the tray capture it, and there
is no `off()`.
- **`trafficLightOffset` changed meaning in 2.x.** 1.18.1 nudged the buttons
by the offset but clamped them inside the stock titlebar, so `{12, 13}` really
meant "as low as the titlebar allows" (left 21 / top 18 on macOS 26). 2.x adds
the full offset to macOS's default position (9/9 in the 32pt Tahoe titlebar)
and grows the titlebar to fit — the same numbers dropped the lights 4pt.
`chromeOptions()` now passes `{12, 4}`: left 21 / top 13, the 14pt buttons
vertically centred in the 40px `h-10` header (1.18.1's clamped spot, top 18,
was 5pt low). The default shifts with macOS's titlebar metrics (28pt / 7
before macOS 26), so re-check after an OS release. `win.setWindowButtonPosition(x, top)` is the absolute
alternative (re-applied on resize/fullscreen) if per-version drift matters.
- **Drag regions ignore `preventDefault`.** The preload starts a window move
from a bubble-phase `mousedown` on `document` and never reads
`defaultPrevented`; a `pointerdown` handler doesn't stop it either (the
compat `mousedown` still fires). `isAppRegionDragTarget` lets a `no-drag`
region win — the `.electrobun-webkit-app-region-no-drag` class (or, since
2.x, `app-region: no-drag` CSS) on the element is the only reliable
suppressor (`.hutch/devkit/api/preload/dragRegions.ts`; re-checked on 2.0.2).
- **No resize, maximize-event or double-click-maximize primitives (1.18.1, still
absent from 2.0.2's `BrowserWindow`).**
`titleBarStyle: "hidden"` drops the whole frame (Cocoa `Titled` — the only
way to remove the traffic lights; `gtk_window_set_decorated(false)` on
Linux) and with it the WM resize borders; nothing restores them (no
`startWindowResize`, no `gtk_window_begin_resize_drag`, no
`setResizable`/min-size options — only `setFrame`/`getFrame`). No maximize
event exists (derive it from `resize` + `isMaximized()`), and drag regions
don't double-click-maximize. A custom-chrome window has to build all three.
## Linux
- **`ERR_DLOPEN_FAILED` on `bun run start`** means a missing system library, not
broken app code. `libNativeWrapper.so` links `libayatana-appindicator3.so.1`
*hard* (for the tray), so the app cannot start at all without it — even though
the tray is a minor feature. Diagnose with
`ldd build/dev-linux-x64/*/bin/libNativeWrapper.so | grep 'not found'`; the
`libasar.so => not found` line in that output is a red herring (it ships beside
the wrapper and resolves at load). Install list per distro is in
[platform-notes.md](platform-notes.md).
- **Black window on NVIDIA + `Failed to create GBM buffer of size WxH: Invalid
argument`** (usually with `X11 Error: GLXBadWindow (code 170)`). The app boots,
logs "miniSCP started", reports no error — and paints nothing. WebKitGTK's
DMABUF renderer can't allocate buffers on the NVIDIA proprietary driver;
`WEBKIT_DISABLE_DMABUF_RENDERER=1` is the upstream workaround.
- **The app cannot apply this to itself.** WebKit reads the variable when it
forks its web process, and **Bun's `process.env` writes do not propagate to
child processes** (verified: a spawned child sees it unset). Setting it in
`src/bun/index.ts` — even in a module imported before `electrobun/main` —
does nothing. It must be in the environment before the app process starts.
- Only the dev launch paths inject it: `scripts/linux-gpu.ts` (`launchEnv()`)
for `bun run dev` / `bun run start`. **A new dev launch path must call
`launchEnv()` or it will render black on NVIDIA.** The release is the
official 2.x Setup installer now (the v1 portable tarball and its
`miniSCP.sh` wrapper are gone), so an installed build on NVIDIA needs the
user to launch it with `WEBKIT_DISABLE_DMABUF_RENDERER=1` (e.g. in the
desktop entry's `Exec`).
- Gated on `/proc/driver/nvidia` so Intel/AMD keep hardware compositing, and
an explicit `WEBKIT_DISABLE_DMABUF_RENDERER` from the user always wins.
- A "does it boot?" log check does NOT catch this — grep for the GBM line, or
look at the window.
- **`bun run start` leaves the old instance running, and
`import -window miniSCP` grabs the OLDEST match.** Instances pile up across
rebuilds, so every screenshot silently comes from a stale build and the fix
you just made looks like it did nothing. Cost three wrong readings in one
session, including a "the CSS is in the bundle but does not apply" dead end.
Kill first (`pkill -f dev-linux-x64`), confirm with
`pgrep -cf 'dev-linux-x64/miniSCP-dev/bin/launcher'`, then launch and capture.
Note `pkill -f 'electrobun dev'` also matches the shell running it and kills
your own command (exit 144) — match on the build path instead. That is only
half the trap: `pkill -f dev-linux-x64` does the same thing, because the
pattern appears in the killing command's OWN argv. Break the literal with a
character class — `pkill -f 'dev-linux-x[6]4'` — the way `ps | grep [x]yz`
has always worked.
- **Launching the app steals focus — ask first.** `bun run start` opens a real
window that takes focus, which interrupts whatever the user is doing; there
is no Xvfb/xvfb-run on this machine and Wayland offers no
launch-without-focus. Batch every live check into a single launch.
- Benign startup noise on Linux — non-fatal, the app runs normally:
`libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib`
(upstream electrobun's choice, not ours).
## Keyboard (`src/shared/keymap.ts`, `ui/src/app/core/shortcuts.service.ts`)
- **WKWebView skips buttons, checkboxes and radios on Tab** unless the user
turned on System Settings → Keyboard → Keyboard navigation (or presses ⌥Tab).
Dialogs, Settings and the manager were mouse-only on a default Mac. Tab is
walked by hand: `cycleFocus(root, event)` (core/focus-cycle.ts) — use it in
any new dialog or window.
- **WebKit doesn't focus a clicked button — focus drops to `<body>`** (Windows
parks it on the button, where Space clicks it again). Every list key died
until the list was clicked. `App` `(mousedown)` keeps focus on chrome-button
clicks and forwards `<body>` keys to the active list; keep both.
- **Match modifiers exactly.** The old panel switch caught every Tab, so
Ctrl+Tab flipped the active panel AND switched tabs; `isMod = metaKey ||
ctrlKey` made ⌃F (Cocoa's forward-one-character) open the editor's find.
`matchesEvent` compares all four modifiers, platform-exact.
- **AltGr is Ctrl+Alt on Windows/Linux.** A Ctrl+Alt+letter binding steals
"@", "€", "ł"… — never a default there, never fired from a text field, and
an event with `getModifierState('AltGraph')` matches nothing.
- **Type-to-filter takes every printable key.** A plain-letter binding (vim
j/k) must resolve BEFORE it — and then that letter no longer starts a query
(Settings warns). While a query is being typed, printable keys (Space too)
always extend it.
- **A dialog's Esc must `stopPropagation`.** The connection manager closes on
a window-level Escape; the dialog cleared its own signal first, so the
window handler saw "no dialog open" and closed the whole window.
- **An unclaimed key behind a dialog reaches the macOS menu** — ⌘T opened a
tab behind the rename prompt. The main window swallows window commands and
the menu bar's panel commands while a dialog or menu is up.
- **WebView2 reloads the page on unclaimed F5 / Ctrl+R** (default browser
accelerators), dropping every session and the editor's unsaved text. Every
window root ends with `blockReloadKeys`; a blanket `stopPropagation` (the old
path bar) hides keys from it — don't add one.
- **Hold-to-repeat** fired ⌘W once per repeat — every tab closed. `claim()`
repeats only movement-like commands (`isRepeatable`).
- **Electrobun's native menu accelerator turns a combo with no modifier token
into ⌘+key** ("f7" shows and fires ⌘F7), its `backspace` / `delete` tokens
are characters AppKit matches unreliably, and AppKit matches Shift+punctuation
by the shifted character ("}" for ⇧]). `toAccelerator` returns null for all of
those; the menu item then shows no key and the page still handles it.
- **Recording a key must take the keydown before every window handler** — and
must NOT take reserved ones: preventDefault on ⌘Q while recording would make
the app unquittable from the keyboard.
- **Focus after a state change needs `afterNextRender({ injector })`.** Under
zoneless CD `queueMicrotask` runs before the render that creates an `@if`
element (the editor's find box and textarea), so `.focus()` hit nothing.
## Domain logic
- **sessionId ≠ filesystem identity.** Two panels on the same server are two
SSH sessions. Every same-file/nested-path guard compares `fsKey`
(`resolvedIP:port` / `"local"`); comparing sessionId caused a
truncate-to-zero data-loss bug. When fsKeys differ but paths collide, the
probe-file check unmasks aliased hosts. Never "simplify" this back.
- `createWriteStream` truncates BEFORE the source is read — that's why
self-copy is destructive, and why guards must run before any stream opens.
- The synthetic `'..'` row: type `'dir'`, must be excluded from selection,
targets(), filtering match-count, kind/rights/owner display, and drag
sources. Any new row-level feature must decide what `'..'` does.
- `Semaphore.wake()` must reserve slots synchronously; waking waiters that
increment asynchronously loses budget under concurrent release.
- Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so
new keys auto-default, but the UI SettingsService reads field-by-field —
a new setting silently stays at default until wired in all five spots.
- **Saves are whole-object: a window must not save before its first
`getSettings` landed.** A ⌘+ pressed while an editor opened wrote
`DEFAULT_SETTINGS` (no keyboard remaps) over the user's file.
`SettingsService.persist()` is a no-op until `applyAll` ran once.
- **Every window's RPC must serve `saveSettings`.** The scale shortcuts
shipped in editor and manager windows while only the main RPC served it:
the request went unanswered, the change applied locally, and main's next
broadcast snapped it back. All three factories now share
`saveSettingsHandler`, and `settingsSaved()` in index.ts rebroadcasts to
every window but the sender — an echo would drag a held Cmd+ backwards.
- The editor draft mirror sends full content per tick (2 MB cap upstream);
don't shrink the 400 ms debounce.
- `panel.touched` gates localDir auto-navigation; `connectToken` guards
against a slow connect resolving into a panel the user already reused.
## i18n
- **Translations live in `src/shared/i18n/*.ts` (TS, not JSON)** — deliberate:
it sidesteps `resolveJsonModule` differences between the Angular esbuild and
Bun builds and gives full typing. `en.ts` is the source of truth; other
locales are partial and fall back to English then the raw key.
- **`i18n.t()` is reactive because it reads the `locale` signal** — that's what
makes live language switching work with no pipe. If you cache a translated
string in a plain field it WON'T update on switch; call `t()` in the template
(or in a signal-reading getter) instead. Same for `i18n.date()/size()`.
- Dictionary values are TS string literals: **French/other apostrophes break
single-quoted entries** (`'…c'est…'`). Use double quotes for any value
containing an apostrophe. Plurals are `{ one, other }` maps (not ICU strings);
zh/ja supply only `other`.
- **Native menus can't use the Angular service** — `menu.ts` goes through backend
`i18n.ts`, and the app menu must be rebuilt when the locale changes (wired in
index.ts `settingsChanged`), or it keeps the old language. The context menu no
longer has this problem: it is Angular now and reads `I18nService` directly.
- `eu`/`zh`/`ja` are AI drafts pending native review (see the i18n README).
- **Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched
result.** A normal `refresh()` is masked by `busy` (input blocked), but the
silent variant (poll/watch) runs while the user can navigate — without the
guard, a poll that started in dir A resolves and stamps A's listing over the
dir B the user just opened. It also never calls `scrollCursorIntoView`
(scroll must stay put) and returns early if `busy()` or already refreshing.
## Build / tooling
- Angular CLI rejects Bun's Node-compat version — `scripts/node-version-shim.ts`
is preloaded via `bunfig.toml` at the root AND in `ui/`. Removing either
breaks `ng`.
- ssh2's optional native deps must stay `external: ["cpu-features", "*.node"]`
in `electrobun.config.ts` — bundling them breaks the build; ssh2 falls back
to pure JS at runtime. (Inert without `mainProcess: "bun"` — see Electrobun.)
- **2.x installers dropped the `stable-` prefix; the updater feed kept it.**
`macos-arm64-miniSCP.dmg` / `win-x64-miniSCP-Setup.zip` /
`linux-x64-miniSCP-Setup.tar.gz` next to `stable-<os>-<arch>-update.json`,
`.tar.zst` and `.patch`. Anything that globs `artifacts/stable-*` misses the
installers — release.ts collects by `<os>-<arch>-`, deploy.sh publishes
`artifacts/*` verbatim (release.ts empties `artifacts/` first), and the site
links the new names.
- `bun run --cwd ui build` (flag order matters; `bun --cwd ui run build` does
not do what you want).
- `version.gen.ts` is gitignored and regenerated by postinstall + every build
script; a fresh checkout fails `tsc` until `bun install` (or any script)
has run once. Never commit it — it would bump the commit count it records.
- Icon pipeline is `scripts/gen-icons.ts` (resvg) — regenerate after touching
`assets/logo.svg` or `assets/tray/tray-glyph.svg`; the .ico and tray PNGs
are build inputs, not build outputs.