On a 1x display TDesign's + smeared at every small size: its 24-grid strokes are centred on the box's middle, which at 12 or 14px is a pixel boundary, so each ~1px bar covered two pixels at half ink. A thinner stroke alone keeps the bar on the boundary. This is not Electrobun: headless Chrome renders the same. pixelAdd (icons.ts) redraws the + on the 14px grid with 1px bars at 7.5, so they fill whole pixels at 1x and 2x, half a pixel off-centre. It replaces tdesignAdd in the tab bar, the shortcut rows, the connection manager and the tab menu. Checked at 1x in the real tab-bar and shortcut-row markup: single-pixel bars at full ink. Co-Authored-By: Claude <noreply@anthropic.com>
425 lines
28 KiB
Markdown
425 lines
28 KiB
Markdown
# 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.
|
||
- **An icon size is one of the four `--icon-size-*` tokens, never a one-off
|
||
rem.** TDesign glyphs are 24-unit SVGs with 2-unit strokes, so a fractional
|
||
size (0.9rem = 14.4px) lands every stroke between device pixels and blurs
|
||
it. Per-template picks had drifted to 12 sizes, and neighbours stopped
|
||
matching: a 14.4px cog beside a 12.8px +, and a 12px + beside 9.6px × in
|
||
the shortcut chips.
|
||
- **On a 1x screen, a TDesign line through the icon's centre smears at any
|
||
small size.** At 12 or 14px the centre is a pixel boundary, so a ~1px bar
|
||
covers two pixels at half ink. A 1px stroke alone doesn't help, because the
|
||
bar stays centred on the boundary. This is geometry, not Electrobun:
|
||
headless Chrome renders the same. So the + is `pixelAdd` (`icons.ts`),
|
||
redrawn on the 14px grid with 1px bars at 7.5, which fill whole pixels at 1x
|
||
and at 2x. It's tuned for `--icon-size-sm` only. Arrow shafts (↑ →) have
|
||
the same issue if it ever shows.
|
||
|
||
## 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.
|
||
- **In a master-detail list, selection must not follow focus** when the detail
|
||
is an unsaved form: the connection manager's `edit()` replaces the draft
|
||
without asking, so arrowing through the list would silently discard edits.
|
||
Arrows move focus; Enter loads.
|
||
- **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.
|