Compare commits

...
47 Commits
Author SHA1 Message Date
jgamboaandClaude 0eb5608873 Migrate to Electrobun 2.0.2 (Hutch)
Electrobun 2.x replaces the vendored CLI with Hutch: the npm package is now a
bootstrap whose exports are a throwing stub, and the SDK is projected into
.hutch/devkit. The app stays on the Bun main process — `mainProcess: "bun"` is
load-bearing, since the 2.x default (Cottontail) ignores build.bun and with it
the ssh2 externals — and the bundle now ships the pinned Bun 1.4.0.

- electrobun 2.0.2 as an exact devDependency. `electrobun prepare` takes the
  old CLI re-sign step's slot in postinstall and every dev/build/start/release
  script: tsc (root tsconfig extends the devkit's) and the Angular build
  (electrobun/view mapped into the devkit) resolve the SDK there.
- Imports move to electrobun/main. The CLI re-sign script and the v1 "three"
  type shim are gone (Hutch's binaries are validly signed).
- UI: Electroview's bunSocket is hostSocket; noImplicitReturns is off because
  the devkit's raw-TS browser SDK compiles inside the UI program.
- Updater.applyUpdate() now consults before-quit, so the transfers/unsaved
  guard's "Quit Anyway" re-runs the apply instead of quitting without it.
- 2.x applies trafficLightOffset from macOS's default button position (1.18.1
  clamped it inside the titlebar); {12, 4} centres the lights in the header.
- Releases: mac-x64 dropped (2.x publishes no macOS x64 core, so no Rosetta
  path). artifacts/ is emptied before each build and published verbatim.
  Installers lost the stable- prefix (the update feed kept it), so release.ts,
  deploy.sh and the site links follow. Linux ships Electrobun's Setup
  installer instead of the portable tarball and its miniSCP.sh wrapper.
- Docs cover all of the above, plus re-checks against 2.0.2: still no native
  modal windows; window close is now cancellable through will-close.

Verified: tsc, build:ui, a stable mac-arm64 release (dmg + the v1-compatible
stable-macos-arm64 feed) and dev launches (RPC, Settings window, traffic
lights). Windows/Linux builds and the v1 -> 2.x Windows update bridge still
need their first run on native machines.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-05 11:08:03 +02:00
jgamboaandClaude 9bd237d89c Give the right-click menu the darkest surface and Oxide's selection
The menu now sits on the darkest surface (the frame color) behind a faint
border, and its highlighted item takes Oxide's selected style: a dark green
fill (their surface-accent) with green text. Mouse and keyboard share that
one highlight, so a hovered row and a keyboard-selected row can no longer
light up at the same time.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-05 10:29:22 +02:00
jgamboaandClaude bfc2147c77 Adopt Oxide's color scheme and a square design
The palette now follows Oxide's design system (docs.oxide.computer): a cool
neutral scale at hue 260 with almost no chroma, opaque strokes, and Oxide's
green as the only accent. The accent-color option is gone: selection, the
cursor row, focus rings and the active-panel line all read green.

The file panel's header strip now steps toward the list, one shade per row
(session strip, path bar, column headers), derived by color-mix so every
theme, AMOLED included, follows on its own.

Everything is square: every rounded utility and the radius tokens are gone,
and checkboxes, radios, selects, number spinners and scrollbars are
restyled to match (square scrollbars are always visible on macOS, the cost
of styling them).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-05 10:21:50 +02:00
jgamboaandClaude 318760efa6 Mark the active panel with an accent line, not a tinted header
Tinting the whole header strip of the focused panel was too much: the
strip changed color every time focus moved. Both panels now share one
neutral header strip, and the active panel is marked by a thin accent
line along its top edge (an inset shadow, so switching panels never
shifts the layout).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-05 09:59:40 +02:00
jgamboaandClaude 141d675bf7 Move settings into a standalone Settings window
The tab-bar popover had outgrown itself. Settings now open in their own
native window (a fourth window kind, built like the connection manager but
not modal): a sidebar with Appearance, Transfers, Editor, File list and
About, holding every popover row plus the visible-columns choice. Each
control is an ordinary settings save, so the other windows follow live
while it stays open.

The gear button in the tab bar, Ctrl+, and the macOS app menu's
"Settings..." (Cmd+,) open it. On macOS Cmd+W arrives as the main window's
close-tab menu action, so bun closes the focused Settings window first
instead of a main-window tab.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-05 09:55:15 +02:00
jgamboaandClaude f3bec7aeaf Shade the window frame, panel headers and file rows apart
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>
2026-10-05 09:47:32 +02:00
jgamboaandClaude 99bfd3abc2 Switch the UI icons from mynaui to TDesign
Swap @ng-icons/mynaui for @ng-icons/tdesign-icons (34.0.0, the release
matching @ng-icons/core), drawn at TDesign's native stroke. Each former
mynaui name maps to one TDesign name everywhere it was used; the unused
ArrowLeftRight is dropped. The options button's ellipsis goes from 1.5rem
to 0.8rem: TDesign's dots span 18 of 24 units against mynaui's 8, so this
keeps today's footprint.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-23 17:00:43 +02:00
jgamboaandClaude a83264c77d Rename the darker tone to AMOLED; file icons are always colored
- Tone: the "darker" option is labelled "AMOLED" in all 7 locales — it is
  the true-black AMOLED palette. The persisted value stays "darker", so no
  settings migration.
- File icons: the Plain/Multi option is gone and the per-category glyphs +
  colors are always on. Removed the iconColors setting (type, default,
  signal, setter, options row, strings) and the plain branch in
  file-icons.ts. A stale "iconColors" key in settings.json is carried
  harmlessly and dropped on the next save.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-23 16:30:55 +02:00
jgamboaandClaude e3a5750b01 Merge origin/main into feat/7css-skin
Brings in 6c05e39 (re-sign electrobun's CLI binary so the app launches).

Two conflicts, both resolved to keep both sides:
- package.json: start keeps scripts/start.ts (injects the Linux NVIDIA env)
  behind main's fix-electrobun-cli re-sign; build and postinstall take
  main's re-sign step (the resize watchdog is already gone).
- docs/build-and-release.md: the release usage keeps the linux-x64 target
  and gains main's fix-electrobun-cli step.

scripts/release.ts and docs/gotchas.md auto-merged with both sides intact:
the Linux target + tarball wrapper, and the arm64/x64 CLI re-sign.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-23 16:25:34 +02:00
jgamboaandClaude c3d97ce524 Save interface-scale changes from every window
Ctrl/Cmd +/-/0 in editor and connection-manager windows called
SettingsService.persist() -> saveSettings, but only the main window's RPC
served that request: it went unanswered, the new scale applied locally, and
the next broadcast from main snapped it back.

All three window RPCs now serve saveSettings through one shared
saveSettingsHandler (persist, then hand off). index.ts's settingsSaved() is
the single place settings side effects run — transfer concurrency, locale +
app menu, the preload snapshot — whichever window saved, and it rebroadcasts
to every window EXCEPT the sender: an echo arriving behind a newer local
change (a held Cmd+) would drag it backwards. Editors are skipped by their
registry key, main and the manager by name.

Known ceiling, noted at the handler: whole-object writes, so two windows
saving in the same instant means the later one wins.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-23 16:24:27 +02:00
jgamboaandClaude f7f02083fe Carry over what only the win7 tree had: middle-click close, ARIA, menu fix
The deleted win7 tree held a few behaviours the modern tree never got:

- Context menu: the first ArrowUp in a fresh menu skipped the last item
  (Delete in the row menu, Disconnect in the tab menu) — stepping from -1
  landed one short. The index math moves to core/menu-nav.ts stepIndex(),
  checked by menu-nav.spec.ts (bun test; verified to fail on the old logic).
  Items get ids and the menu exposes aria-activedescendant, so screen
  readers follow the arrow keys while focus stays on the menu.
- Middle-click closes a tab, as in browsers. auxclick also fires for the
  right button, so only button 1 counts; closeWorkspace already refuses the
  last tab. A middle press is swallowed so WebView2 doesn't start autoscroll
  over the scrollable strip. The hover ✕ stays.
- ARIA: the tab strip is a tablist of role=tab buttons with aria-selected
  (the in-tab ✕ is taken out of the a11y tree and tab order — Ctrl/Cmd+W and
  the tab menu remain the keyboard paths); transfer bars are progressbars
  with value and a name.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-23 16:22:30 +02:00
jgamboaandClaude 2bac7678ca Remove the Windows 7 (7.css) skin
The win7 skin is dropped. Everything else the branch built stays: Linux
support and the HTML context menus, the workspace-tab menu, the continuous
interface scale (rem everywhere, stepper, Ctrl/Cmd +/-/0) and the core/
service layer. The modern tree already carried all of them, and nothing
outside skins/7like/ imported the win7 tree except main.ts and styles.css.

Removed: the skins/7like/ tree (5.1k lines, fonts), the 7.css dependency,
the skin setting and its options row + strings in all 7 locales, the
frameless-window plumbing (window-control RPCs, windowState push,
trackMaximizeState, the synthesized resize grip and its postinstall
watchdog), the session-skin pinning in buildPreload/chromeOptions, the
skin/skinfix cascade layers, and the win7-only probes and screenshot
harness (the harness cannot render the modern tree: plain Firefox has no
RPC bridge). chromeOptions() stays as the one shared frame decision.

Fixed on the way out:
- index.html's per-theme pre-paint background is restored. The branch had
  replaced it with a win7-only transparent rule, so modern dark windows had
  silently lost one of the three flash guards.
- editor-windows.ts wrote its window-key separator as a literal NUL byte,
  which made git and grep treat the file as binary; it is `\0` now (same
  string at runtime).

No settings migration: a stale "skin" key is carried harmlessly by the
store's spread-merge and dropped on the next save.

UI bundle: 616.7 kB -> 405.4 kB, back under the 500 kB budget.

Docs: architecture, gotchas and platform-notes lose the win7 material but
keep what stays true (construction-time preloads/frames, drag regions
ignoring preventDefault, electrobun's missing resize/maximize primitives);
flash-prevention, dev-URL and Bun-panic notes move back under Electrobun;
stale context-menu/tab-bar links fixed; README documents the scale keys.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-23 16:17:38 +02:00
jgamboaandClaude 0a0c708140 Reduce the synthesized resize to the bottom-right grip only
The handles are invisible and paint above the frame, so the north strip ran the
full window width across the caption buttons and the north-east square sat on
the close button's own top-right corner. Crossing one dropped that button's
:hover mid-glow, which reads as the Aero glow flickering or sticking — an
artifact with nothing wrong in the CSS at all.

Only the bottom-right corner overlaps nothing: it lands in the 6px gutter that
.window-body's margin leaves below and right of the content. That is also where
Win7 put its own resize gripper, so one grip is the more faithful answer anyway.
Bumped to 1rem from 0.75rem since it is now the only way to resize.

resizeFrame stays general and the probe still exercises all eight edges: the
origin-moving cases are the subtle ones and are worth keeping proven, so
restoring a side later is one entry in the component against tested math. Both
the component and gotchas.md now spell out which edges are safe to restore
(south, east, west) and which are not (north and both top corners).

Harness diffs 0; math, hover and menu probes clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 22:28:49 +02:00
jgamboaandClaude 4605a481f1 Synthesize window resize for the frameless win7 skin (a documented stopgap)
The win7 skin is frameless so 7.css can draw the Aero caption, and on GTK that
means undecorated, which takes the window manager's resize borders and corner
grips with it. All three win7 windows were completely unresizable.

electrobun 1.18.1 offers nothing to re-enable, verified against the shipped
binary rather than assumed: libNativeWrapper exports startWindowMove/
stopWindowMove with no resize counterpart, imports gtk_window_begin_move_drag
but not gtk_window_begin_resize_drag, dragRegions.ts implements moving only,
and BrowserWindow has neither setResizable nor min-size options. But the
window is still programmatically resizable, so only the input affordance is
missing: eight handles overlay the frame and drag it through a new
windowGetFrame/windowSetFrame pair on the shared windowControlHandlers factory,
which gives all three window kinds resize with no per-window wiring.

Three things that would have made this quietly wrong:

- dragRegions.ts starts a window MOVE from a mousedown listener on document,
  never checks defaultPrevented, and is not suppressed by a pointerdown
  handler either (the compatibility mousedown still fires). Every handle
  carries app-region-no-drag, which short-circuits its check first.
- deltas come from screenX, not clientX: a north/west resize moves the window
  origin under a stationary pointer, so client coords feed that back into the
  next delta and the window runs away.
- the min-size clamp re-anchors the opposite edge; clamping width alone lets x
  keep tracking the pointer, walking the window off-screen while it looks
  frozen.

This is a stopgap and the point is that it is removable on sight. The frame
math is a dependency-free module so scripts/resize-math-probe.ts asserts all
eight edges without a window (verified it catches the unanchored-clamp bug:
x jumps to 5100 instead of parking at 460). scripts/check-electrobun-resize.ts
scans the installed electrobun for the primitives this is waiting on and prints
the removal checklist when they appear; it runs from postinstall so a version
bump surfaces it, never fails an install, and is silent otherwise. Both trigger
paths were simulated. The component header carries the full checklist; docs
cover the platform consequence and the dragRegions traps.

Harness diffs exactly 0 in both themes; hover and menu probes still clean.
Not yet exercised with a real pointer — that needs a live window.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 22:16:48 +02:00
jgamboaandClaude 5ed029a2b8 Flatten the selection and the menu highlight; kill the Aero afterglow
Three things looked wrong, three different causes.

The selected row carried a border. 7.css outlines tr.highlighted (#aaddfa) on
top of its fill, and we had been restating that outline in the new blue — so
the band had an edge, and on the cursor row the dotted focus rect made three
concentric outlines around a single row. The reserved 1px border stays (it is
what stops selection from shifting rows) but is transparent in every state;
the fill paints under it, since background-clip is border-box. --w7-sel-bd and
--w7-sel-bd-idle are gone with it.

The context menu highlighted items with 7.css's translucent gradient wash —
the same gradient the ListView selection just dropped. It now uses
--w7-sel-bg, so selection reads identically wherever it appears.

The hover-off animation was button.css bleeding into menu items, which ARE
buttons: button::before is a glow overlay that fades out over a full second,
and menu.css suppresses it with content:none on the :hover arm ONLY — so
leaving an item brought the pseudo back with opacity animating 1 -> 0, a ghost
trailing the pointer. Killing content in every state removes it. Same family,
also fixed: button:focus runs pulse-anim infinitely, and this menu moves real
focus between items for keyboard nav.

menu-probe.ts now checks the highlight too, using hover-probe's :hover ->
.simhover rewrite: it measures colour spread down a text-free strip of a
force-hovered item, so "is it flat?" is a measurement. Verified the check can
fail by injecting the old wash into the built stylesheet — spread 9, exit 2;
the flat fill reads spread 0 at exactly #cbe8f6. The harness lost its
selection outlines and nothing else, in both themes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 21:04:09 +02:00
jgamboaandClaude c4364c1051 Fix the dead win7 context menu, flatten the selection, add a tab menu
Right-click did nothing under the win7 skin, and then swallowed the next
click. 7.css declares `position: relative` on ul[role=menu] and the `skin`
layer outranks `utilities`, so Tailwind's `.fixed` lost: [style.left/top.px]
became flow offsets from the menu's static position — the last child of the
h-screen frame — and every menu rendered below the viewport, unreachable
behind overflow:hidden. Its backdrop is a plain <div>, unmatched by 7.css, so
that stayed genuinely fixed and ate the following click. Regressed in 0ae2a62,
when the menu adopted 7.css's real markup.

Restated `position: fixed` in skinfix, scoped to a new `w7-ctx` marker so
7.css's own menus keep theirs. scripts/menu-probe.ts is the regression test:
it renders the menu at declared coordinates, pixel-scans whether it landed
there, and carries a marker-less control menu (the bug verbatim) as its own
discriminator. A resting pixel diff cannot see this class of bug.

Selection band goes flat: --w7-sel-bg/-bd (#cbe8f6/#26a0da) is what Explorer
actually painted, where 7.css reused --w7-el-grad-h, its Aero element-hover
ramp. That var is left alone — buttons and tabs still want the gloss. The
unfocused pane gets the grey idle band; the band still never disappears with
focus, it only weakens. Harness diff confirms the change touches selection
rows and nothing else, in both themes.

New workspace-tab right-click menu in both skins: New Tab, Close Tab, Close
Other Tabs, Close Tabs to the Right, Disconnect. The target id travels inside
the action string because the right-clicked tab is not necessarily the active
one. Illegal entries are disabled rather than omitted, the way Win7 greyed
out — and since 7.css's [aria-disabled] handling is CSS-only, the keyboard
paths check the flag themselves.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 20:50:42 +02:00
jgamboaandClaude 89af52e1c0 Clip the close button's hover glow at the frame edge; add a hover probe
Hovering the win7 X painted a red mark on the window border's far edge.
Diagnosed programmatically, per request, with a new scripts/hover-probe.ts:
Firefox headless has no pointer, so the probe copies the built stylesheet
with every `:hover` rewritten to a `.simhover` class, renders the app's exact
frame markup (w7-frame + direct-child title-bar + aria-label'd controls) with
the X force-hovered, and pixel-scans four regions for red-dominant pixels,
printing a verdict table and exiting non-zero on artifacts.

Verdict from the first run: the right border and the region beside it were
CLEAN — the reported mark is the glow escaping over the TOP edge. 7.css's
hover ::before carries box-shadow 0 0 7px 3px #e68e75, a child's box-shadow
is not clipped by the parent's border or radius, and the caption buttons sit
flush against the window's top edge (border-top: 0) — so 100 red pixels
landed above the frame: a tint across the top border and a halo floating on
the desktop past it.

Fix: clip-path inset(0 0 -1rem 0) on `.w7-frame > .title-bar` in skinfix. The
glow now dies at the frame border — where the real Win7 DWM stopped it — and
the bottom stays open so the authentic bleed toward the caption survives.
clip-path clips hit-testing too, but only outside the region; the drag area
is the title-bar's own box, entirely inside. Scoped to w7-frame so harness
and demo windows keep stock 7.css behaviour.

Verified: probe verdict now clean (escape regions 0, the intended in-frame
glow still measures 30 px — the effect survived, only the escape is gone);
the resting-state harness diffs exactly zero, so nothing but hover pixels
changed; tsc + build clean. The probe stays in scripts/ as the regression
test; gotchas.md records both the clipping rule and the :hover -> class
technique.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 18:29:21 +02:00
jgamboaandClaude 9c54a441a9 Scale stage 3: shortcuts service + Ctrl/Cmd +/-/0 for the scale
Groundwork for the vim-style bindings planned next: the app previously had NO
keyboard abstraction — `event.metaKey || event.ctrlKey` copy-pasted inline in
six places, no typing-target guard anywhere, every window root matching keys
by hand.

New @core/shortcuts.service.ts owns:
- isMod() (also accepts MouseEvent — Mod+click toggle-select uses the same
  rule) and isTypingTarget();
- ONE declarative keymap: resolve(event, scope) -> ShortcutId | null, scoped
  main / editor / manager. Layout rules are encoded once: digits, +/- and
  Alt-letter chords match on event.code (AZERTY digit rows, layouts where '+'
  needs Shift, macOS Option-Z producing Ω); plain letter chords match on
  event.key so they follow the user's layout.

Both app.ts, both editor-window.ts and both connection-manager.ts now resolve
through the service and switch on ids — behaviour-preserving for every
existing shortcut, including the deliberate quirks (Ctrl+Tab never meta
because Cmd-Tab is the macOS app switcher; Cmd+9 = last tab; editor Mod+W is
the Windows/Linux arm, macOS routes via the app menu). Escape and Tab stay
component-local on purpose: their meaning is per-window state (find bar open?
close prompt up? caret in the textarea?), not a global binding. The file
panels keep their contextual key state machine but delegate modifier tests.

On top of the table: Ctrl/Cmd +/-/0 steps/resets the interface scale in every
window kind, all three previously unclaimed. isTypingTarget is applied
per-shortcut, not globally in resolve(): the scale keys must not fire while
typing ('-' in a rename prompt must type '-'), but the editor WANTS Mod+S
mid-typing and the tab shortcuts keep their existing reach.

Docs: architecture.md's density prose now describes the uiScale lever;
gotchas.md gains the scale section — the disassembly evidence that native
page zoom is an empty stub on Linux (so nobody tries it again), the
everything-must-be-rem rule, the skinfix-outranks-variants trap the harness
caught (48px close button silently 29px), and the px-mirrors-in-TS class of
bug with the remPx() remedy.

Verified: tsc + build clean; live boot with the user's real legacy settings
file migrates textSize:large -> uiScale 1.2 and renders scaled. Not verified
here (needs interaction): the stepper clicks and the shortcut keys themselves.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 18:15:14 +02:00
jgamboaandClaude f101257f20 Scale stage 2: continuous uiScale setting + the (-) 100% (+) stepper
AppSettings.textSize (compact|default|large) becomes uiScale: number, 0.5-4
in 0.1 steps, default 1. One shared clampUiScale (next to the
effectiveConcurrency precedent) defines "valid" for the store, the service
and the stepper: clamp to range, snap to the step grid, strip float noise,
and map NaN/Infinity to 1.

- SettingsStore.load migrates legacy files (compact/default/large ->
  0.9/1/1.2, never over an explicit uiScale) and clamps ALWAYS, not just on
  migration — buildPreload writes this value into every window's root
  font-size, so a hand-edited settings.json (uiScale: 100) must not be able
  to render the UI unusable. Verified against 100, 0.001, -3, NaN, Infinity
  and float noise; migration verified against a real legacy file.
- SettingsService: applyUiScale sets the root font-size (16 * scale, removed
  entirely at 1 per the delete-when-default rule). Rides the existing
  settingsChanged broadcast, so open editor/manager windows follow live.
- buildPreload bakes the same font-size so the first paint is already scaled
  — without it every window opens at 100% and snaps.
- Both tab-bars: the 3-button Interface row becomes the stepper. Clicking the
  percentage resets to 100% (tooltip says so). i18n: compact/default/large
  dropped, smaller/larger/reset added, all 7 locales.

Verified live, win7 + modern: scale 1 is pixel-identical to the pre-work
baseline (opaque interior diff exactly 0); 0.5x is a real dense mode (~2x
rows); 2x scales title bar, tabs, rows, icons and controls together with text
contained in its boxes — which is what stage 1c's 7.css bridge bought; 4x is
enormous but coherent, and the win7 title-bar controls stay on-screen at the
default window size. The known caveat stands: no minimum window size exists,
so 4x in a small window is legal but cramped.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 18:09:59 +02:00
jgamboaandClaude 9f2a076e08 Scale stage 1: everything the UI sizes with is now rem
Groundwork for the continuous interface scale (a root font-size lever): every
size the UI derives from must be root-relative, or it freezes while the rest
grows. Gate for this commit: at the default 16px root the app renders
pixel-identical to before — every conversion is exact (11px = 0.6875rem …).

- The 8 panel-chrome tokens in styles.css: px -> rem, and the data-text
  compact/large tiers are DELETED — a continuous scale replaces discrete
  tiers. Same for --w7-fs (12px -> 0.75rem) and the win7 --radius (5px ->
  0.3125rem, still landing rounded-md exactly on --w7-el-bdr's 3px).
  --radius-sm/md subtrahends: px -> rem, or corners would sharpen as the UI
  grew.
- All 93 text-[11px]/text-[10px] arbitraries -> rem equivalents, plus the two
  inline padding:24px empty-state cells in the 7like file panel.
- 7like.css gains a scale bridge in skinfix: 7.css is 370 px values with zero
  rem, and --w7-font reaches only 15 rules' TYPOGRAPHY — under a root lever
  win7 text would grow inside boxes frozen at 23px/14px/15px. The bridge
  re-expresses 7.css's size vars and its hardcoded box metrics (buttons via
  .w7-btn only, inputs, table rows/headers, title-bar controls, menu items,
  tabs, the 10pt check glyph) in rem at identical scale-1 pixels. 1px
  hairlines and focus rects stay px deliberately — crisp at any scale, and
  the tr border exists precisely to stop selection shifting rows.
- Two skinfix traps found while writing it, one by reasoning and one by
  pixels: (a) NO blanket `.win7 button` restatement — skinfix outranks the
  skin-layer neutralizer that strips 7.css's 75x23 metrics off the ~84 icon
  buttons, so a blanket rule would re-inflate them all; (b) restating a base
  rule in skinfix also outranks 7.css's own VARIANTS of it by layer — the
  title-bar rule silently shrank the close button from 48px to 29px until the
  harness pixel diff caught it, and the active tab's padding-bottom needed
  the same treatment. Both variants are restated alongside their base rules.
- TS px constants that mirror rem CSS are the latent-bug half of this work:
  both context menus' flip/clamp math converts through a new @core/metrics
  remPx() helper reading the LIVE root font-size (the shadlike menu's
  MENU_W=224 diverged from its w-56 box the moment scale != 1; the 7like
  menu's [style.width.px] pinned the box at 200px while text grew — it now
  binds width in rem, so math and box cannot disagree). The editors'
  cachedCharWidth is now keyed on the computed font string, self-invalidating
  under any font change, and both || px fallbacks derive from the element's
  font size instead of assuming a 16px root.

Verified: tsc + build clean; modern and win7 live captures and the harness
all diff to zero against pre-change baselines in their opaque regions (the
harness to exactly zero end to end; modern's only delta is the native GTK
titlebar focus state; win7's is the translucent Aero corner over the
desktop).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 18:02:41 +02:00
jgamboaandClaude c8ccc6b918 Skin is a launch-time choice; stop applying it to a live window
Switching Modern -> Win7 at runtime broke the UI. SettingsService.applySkin
toggled the `.win7` class the moment the setting changed — harmless when there
was one component tree, and a broken window as soon as there were two. The tree
is bootstrapped once from the preload-baked __APP_SKIN, so the live flip painted
7.css onto shadlike markup that has no .window.glass, no .title-bar, no
menu[role=tablist] and no <table> ListView. The CSS landed on a tree that cannot
receive it.

A reload would not have helped, and neither would anything short of a restart:

- The preload is a persistent WebKitUserScript injected at DOCUMENT_START, so
  its string is frozen at webview construction — loadURL() re-runs it with the
  OLD __APP_SKIN. updatePreloadScriptToWebView exists in the FFI but is
  reachable only from electrobun's internal <electrobun-webview> handler.
- titleBarStyle/transparent have no setters; a live window cannot be re-framed.
  win7 -> modern is the dangerous direction — the modern tree draws no title
  bar, so it would land in a frameless transparent window with no way to move,
  minimize or close it.
- Recreating the main window is also out: exitOnLastWindowClosed is true, the
  main rpc is captured by six push closures, the tray captures `win`, and
  listeners cannot be removed.

So: applySkin is deleted outright, from setSkin AND from applyAll — the latter
matters independently, since applyAll runs on the settingsChanged push and was
therefore breaking every open editor and manager window too. Clicking a skin now
only persists it.

Also fixed a second leak found while tracing this: manager-window and
editor-windows build from `currentSettings()`, the LIVE snapshot, so a window
opened after a pending change would boot the new tree and frame while main still
ran the old one. `initSessionSkin` now freezes the skin at startup before any
window exists, and buildPreload/chromeOptions read that pinned value —
chromeOptions loses its parameter so no call site can get it wrong.

The options row shows the saved value as pending (skinPending() compares
settings.skin() against APP_SKIN) with the hint retexted in all 7 locales; it
said "Reopen the window to change its frame", which described the deleted
design.

Verified: tsc + build:ui clean; both skins boot correctly from a persisted
setting (win7 frameless, modern with the native frame). The live flip is now
impossible by construction, which is the check that matters here — the UI bundle
contains no classList write for win7 at all, the only writer is buildPreload,
and sessionSkin has exactly one assignment site.

Not verified: the click itself and the pending hint appearing, which need an
interaction pass — no window automation is available here. Both are present in
the built bundle.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 14:56:16 +02:00
jgamboaandClaude 5bbfc2f475 Document the core/skins split
architecture.md gains a "UI layout: one core, two skin trees" section — the
folder map, the barrel contract that lets main.ts stay ignorant of both trees,
why duplicate selectors across trees are safe, and where a new rule belongs
(core / a skin / shared tokens). Its "Window kinds" section now describes
two-axis root selection, and the "Skins" section is no longer stale prose about
a deleted design: it covers the stylesheet split and the two-phase nature of a
skin change (CSS live, tree and frame at next window open).

gotchas.md gains a section for the split, all of it earned during the work:
`.win7` cannot be renamed because 7.scoped.css is precompiled against that
literal; reach core through @core/* rather than relative paths, which is what
let 67 imports survive two relocations; @font-face url() rebases when a
stylesheet moves and fails silently; the @layer statement must stay first;
duplicate selectors across trees are safe; and ngtsc type-checks components
nothing imports, so a green build does say something about the inactive skin.

Also corrected the component paths that moved under skins/, and the
updater.service.ts link that moved under core/. Checked every ../ link in
docs/ resolves.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 05:04:11 +02:00
jgamboaandClaude eafe426c8e Stage 5: wire the skin switch — both UIs now reachable
The reunion is complete: one build contains both UIs and picks between them at
bootstrap.

- src/shared/types.ts regains `skin: "modern" | "win7"` on AppSettings.
- SettingsService regains the skin signal, setSkin() and applySkin(), which
  toggles the `.win7` class on <html>.
- buildPreload emits __APP_SKIN and adds `.win7` conditionally again, instead
  of unconditionally as it had since fe028a9.
- chromeOptions() regains its parameter: win7 gets frameless + transparent,
  modern gets the macOS hiddenInset fusion or the plain native frame.
- core/platform.ts regains APP_SKIN / IS_WIN7_CHROME (added in stage 4).
- main.ts now selects on both axes — skin picks the tree, APP_MODE picks the
  root within it.
- Both tab-bars get a skin row, with the reopen caveat as its tooltip.

DEFAULT_SETTINGS.skin is "modern", matching the original design and `main`.
Worth flagging: this branch has booted win7 unconditionally since fe028a9, so
after this commit it starts in the modern UI unless the setting says otherwise.
One word in types.ts flips that if the win7 look should be the default.

i18n: en.ts is the source of truth and everything else falls back to it, so
only en was strictly required — but es/eu/fr/de/zh/ja got real translations
too, each matching the terminology that locale already uses for the Theme row.

Verified both skins on live single-instance captures. Modern boots and renders
fully styled, which also proves Tailwind scans skins/shadlike/** — a purge
would have produced an unstyled page rather than this one. Win7, selected by
setting skin in the persisted settings, is still pixel-identical to the
pre-refactor baseline across all five stages. The persisted settings file was
backed up and restored, so it still carries no skin key and resolves to the
default through the backend's merge.

Not verified: clicking the new skin row itself, which needs an interaction pass
— no window automation is available here. Its bindings compile and the built
bundle contains options.skin, setSkin and the translated labels.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 05:01:57 +02:00
jgamboaandClaude bb97e5418f Stage 4: restore the modern tree into app/skins/shadlike/
Both UIs now live side by side. The 18 modern component files come straight
from `git show main:<path>` — recovered, not rewritten, which is possible
because main's tip IS the merge base of this branch. There is no title-bar
among them: that component is win7-only, since the modern skin defers to the
native window frame.

Their imports were rewritten to @core the same way stage 1 did, leaving only
skin-internal relative imports behind. Added skins/shadlike/index.ts exporting
SHAD_ROOTS with the same shape as SEVEN_ROOTS, so main.ts can select a tree
without knowing either skin's internals.

Restored the skin axis in core/platform.ts, deleted in fe028a9 and needed
again by the modern tree: __APP_SKIN on the Window global, APP_SKIN,
IS_WIN7_CHROME, and IS_MAC_CHROME = IS_MAC && !IS_WIN7_CHROME. That last one
is what reserves pl-24 for the macOS traffic lights in the fused hiddenInset
header — and correctly stops reserving it under win7, where titleBarStyle
'hidden' removes the traffic lights entirely.

The tree is not reachable yet; the build is the gate. I checked that the gate
is real rather than assuming it: injecting a type error into an unreferenced
shadlike file does fail `bun run build:ui`, so ngtsc type-checks the whole
program including components nothing imports. Without that check, "the build
passes" would have proven nothing about 18 files no one references.

win7 remains pixel-identical to the pre-refactor baseline.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 04:56:20 +02:00
jgamboaandClaude b35c30d5ab Stage 3: split styles.css into a shared base plus per-skin sheets
The 919-line stylesheet had a clean seam: `main`'s version is 278 lines and
this branch only ever APPENDED to it. Verified that directly — a diff of
main's styles.css against our first 298 lines differs only by the @layer
statement and the 7.css import.

- ui/src/styles.css (296) keeps the shared half: the @layer statement, the
  Tailwind import, the OKLCH tokens, @theme inline, the data-text interface
  sizes, the animations and the accent axis. The @layer statement must stay
  first here — it is one of only two rule types allowed above @import, and it
  is what sorts `skin` above `utilities`.
- app/skins/7like/7like.css (636) takes the whole win7 section plus the
  scoped-7.css import that feeds layer(skin).
- app/skins/shadlike/shadlike.css is near-empty by nature, not by omission:
  that skin IS the shared token system plus inline Tailwind, so there is no
  framework to bridge and no cascade to invert. The file documents why, and
  what does/does not belong in it.

Both are @imported from styles.css, so one bundle still ships both, scoped by
`.win7` exactly as before.

One real trap caught here: the @font-face url() is resolved relative to the
stylesheet, so moving the win7 section three directories deeper would have
silently 404'd Noto Sans — the build stays green and the app just falls back
to a different font. Fixed by moving the fonts INTO the skin (they exist only
for the Win7 look, where Noto Sans substitutes for Segoe UI), which keeps
`./fonts/...` correct wherever the folder goes. Confirmed in the built output:
both woff2 emitted as hashed assets with the url rewritten to ./media/.

Verified: build clean, built CSS keeps `@layer theme,base,components,
utilities,skin,skinfix` and all 7.css selectors, the live window's opaque
interior is pixel-identical to the pre-refactor baseline, and both harness
renders (light and dark) diff to exactly zero. A layer-assignment mistake here
would not be subtle — 7.css would lose to ~1900 Tailwind utilities and the app
would look nothing like this.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 04:53:57 +02:00
jgamboaandClaude 39e019af9f Stage 2: move the win7 component tree into app/skins/7like/
All 14 components (including title-bar/, which has no modern counterpart)
move under app/skins/7like/. git records every one as a rename — no content
changed except main.ts.

The @core alias from stage 1 paid off exactly as intended: not one of the 67
service imports needed touching despite every file changing depth. The only
relative imports left inside the tree are skin-internal (app -> its dialogs,
connection-manager -> title-bar), and those survived because the move
preserved their relative structure.

Added skins/7like/index.ts exporting SEVEN_ROOTS { main, manager, editor }.
Every skin will export that same shape, so main.ts can pick a tree by skin
and then a root by APP_MODE without knowing either skin's internals. main.ts
selects from that barrel now, with the second axis still hardcoded until the
shadlike tree lands.

Verified: tsc clean, build:ui clean, opaque interior pixel-identical to the
pre-refactor baseline. That last check also proves Tailwind still scans the
templates from their new skins/** paths — a silent utility purge would have
collapsed the layout rather than reproducing it exactly.

The three structural 7.css contracts that a careless move breaks silently are
intact by construction, since no markup changed: app-title-bar is still a
direct child of .window.w7-frame, nothing new nests inside menu[role=tablist],
and the context menu still emits ul[role=menu] > li[role=menuitem] > button.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 04:50:53 +02:00
jgamboaandClaude 17bb1a14fb Stage 1: extract the skin-agnostic UI layer into app/core/
First step of splitting the UI into two skin trees. All 12 services plus
platform.ts, format.ts, file-icons.ts, icons.ts and app.config.ts move to
ui/src/app/core/ — the layer both skins will share.

This is a pure move: git records all 17 as renames, and no file content
changed beyond import paths. The premise holds because `git diff
main...feat/7css-skin` touches none of these files — the service layer was
already skin-agnostic, so there is nothing to untangle, only to relocate.

Components now reach it through a new `@core/*` tsconfig alias rather than a
relative path. That is deliberate and load-bearing for the next stage: skin
components are about to move under app/skins/<skin>/ at varying depths, and
an alias keeps every one of these 67 imports correct wherever its file ends
up. `@shared/*` (the bun-side src/shared) already set the precedent.

Intra-core imports needed no edits at all — every ./sibling reference among
the moved files points at another moved file.

Verified: tsc clean, `bun run build:ui` clean (AOT resolves every import and
template symbol), and the running window's opaque interior is pixel-identical
to a pre-refactor capture.

Note on that comparison: the Aero frame and title bar do NOT compare equal
between launches, and that is not a regression — .window.glass::before runs
at opacity .6, so the chrome composites over whatever desktop is behind the
window and shifts by a uniform ~8% brightness. The check therefore diffs the
opaque interior (inset 30px sides, 80px top), where anything this refactor
could break would show.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 04:49:27 +02:00
jgamboaandClaude ab0bb4ef6f Pane command rows go flat, like the Connections button
The file panels' action verbs (Send/Edit/Rename/new folder/new file/
permissions/Delete) and the panel picker were wearing the full Aero slab,
which is the dialog push-button treatment — wrong for what is really an
Explorer command bar. Marked the session strip w7-toolbar so they get the
flat-with-hover-glow treatment the Connections button already had from the
tab-bar row.

The path bar gets it too. Its up/refresh controls sit directly under the
action row, so leaving them as slabs beside newly-flat neighbours would have
read as arbitrary rather than deliberate. w7-toolbar only targets button, so
the path <input> keeps its 7.css textbox border.

Verified on a live single-instance capture: both rows flat, the path input
unchanged. architecture.md's "three header rows" was now wrong and says
"every command row" instead.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 04:18:56 +02:00
jgamboaandClaude 06a14fb6a1 Make w7-flat actually flatten; document this round
The status bar's transfers summary is a text toggle, but 7.css painted it as
a full Aero push button — a stray bordered slab sitting in the status line.
Marked it w7-flat, the per-button escape hatch that existed for exactly this
and had never been used.

It did not work, for a reason worth keeping: 7.css restates border-color at
`.win7 button:not(:disabled):not(:hover)` = (0,3,1), which outranks
`.win7 .w7-flat` = (0,2,0). The .w7-toolbar arm of the same rule only ever
won that fight by tying on specificity and landing later in the same layer —
an accident w7-flat did not share. Split the rule: `background:
revert-layer` stays in `skin` (it must land on `utilities`), and the
paint-suppression literals move to `skinfix`, where layer order settles it
instead of a specificity arms race.

Docs updated in the same change per the working agreement — architecture.md
gains the tabs contract, the w7-flat/w7-toolbar split and the frame
transparency invariant; gotchas.md gains a win7-skin section (unlayered beats
every layer, the (0,3,1) border-color restatement, overflow-x clipping the
y-axis, 7.css's hardcoded light literals, exact selectors) plus the two
Angular traps this round hit: a duplicate `class` attribute is silently
dropped, and a backtick inside a component template's HTML comment terminates
the TS template literal.

Also recorded the verification trap that cost three wrong readings today:
`bun run start` leaves the old instance running and `import -window` grabs
the OLDEST match, so screenshots silently come from a stale build — this is
what made the w7-flat fix above look like a no-op when it was already
correct. Kill and count instances before capturing. And `pkill -f
'electrobun dev'` matches the shell running it and kills your own command.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 04:10:36 +02:00
jgamboaandClaude 7921b2986b Let the frame corners be transparent; square them when maximized
The rounded frame only works if nothing paints an opaque full-bleed surface
behind it — the area outside a border-radius is not painted by the element,
so whatever sits underneath fills the corners. The frame comment asserted
that invariant ("nothing but .window paints an opaque surface") but nothing
enforced it: `html, body { background: var(--background) }` painted an
opaque rectangle across the whole window, so the corners had a ground to
show and the window read as a rectangle with a curve drawn on it.

Reset html/body to transparent under .win7. The override has to be
UNLAYERED, which is the non-obvious part: the html/body rule it overrides is
itself unlayered, and unlayered declarations beat every @layer regardless of
specificity, so the skinfix layer could not have reached it. Corrected the
frame comment, which stated the invariant as if it were free.

Maximized windows now drop the radius: a rounded maximized window leaves
four notches of desktop in the screen corners. title-bar.ts publishes its
existing `maximized` signal as `is-maximized` on its host, and the frame
reads it off its own direct child with :has(), avoiding a second
windowState subscription. The radius is declared three times in 7.css — on
.window and on both the ::before tint and the ::after glass layer — so all
three are zeroed; leaving one rounded would cut the corner off the square
frame.

NOT visually verified, and I could not verify it here: `import -window`
captures the window's own pixels without compositing, so a genuinely
transparent corner and an opaquely black one flatten to the same black in
the capture, and forcing png32 just fills alpha with 1. XWayland's root
window is not the composited desktop, and no compositor-level capture tool
(grim/scrot/maim) is installed. Both rules are confirmed present in the
built stylesheet; whether the corner now shows the desktop, and how the
maximized state looks, need eyes on the actual screen.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 04:04:04 +02:00
jgamboaandClaude aaa12c3e23 Workspace tabs are a real 7.css tablist
The tab strip was a hand-rolled lookalike: bg-accent for the active tab and
an absolutely-positioned bg-primary underline, competing with a tab
component 7.css already ships. Replaced with its actual contract —
menu[role=tablist] plus one button[role=tab][aria-selected] per workspace —
so 7.css draws the joined inactive tabs and the raised white active one.

Per the agreed design the per-tab X is gone: real Win7 tab controls had
none, and a button nested in a button is invalid HTML. Closing keeps
Ctrl/Cmd+W and gains middle-click (auxclick, guarded on button 1 so the
right button never closes a tab). Drag-reorder moved onto the tab button
itself and still works.

Three things 7.css does that had to be accommodated, each found by looking
rather than by reasoning:

- Its selector is the DESCENDANT `menu[role=tablist] button`, so the +,
  update pill and right-hand tools deliberately stay outside the menu.
- The strip is a .w7-toolbar, whose neutralizer strips background, border
  and both pseudo-elements to make toolbar buttons flat. That flattened the
  tabs to nothing, so the rule now exempts :not([role=tab]) — tabs want the
  full Aero slab, their neighbours want flat.
- overflow-x:auto on the scroller forces computed overflow-y from visible
  to auto, so the strip clips vertically. 7.css raises the active tab with
  margin-top:-2px, which put its top border outside the scroll box and
  silently deleted it. The strip now reserves 2px of padding-top, mirroring
  the padding-left:3px 7.css already reserves for the same tab's -3px left
  margin. Caught by pixel-scanning the tab's top edge against 7.css's own
  reference markup in the harness: reference had #8e8f8f, ours jumped
  straight from toolbar grey to tab fill.

Also: tabs.css hardcodes color:#222 on every tablist button, outside 7.css's
var system, leaving labels near-invisible in dark. Routed through a new
--w7-tab-c token declared light in :root.win7 and overridden in BOTH dark
blocks (attribute and prefers-color-scheme), matching the ListView and menu
literals. And its `display: block` outranks Tailwind's .flex, which jammed
the icon against the label — flex/gap restored in skinfix.

role="tabpanel" on the panel area was considered and skipped: 7.css gives it
padding:14px, margin-bottom:9px and its own border, which would fight the
split-pane layout for a join that cannot read anyway with the panel toolbar
row in between.

The harness grew a section 5 mirroring this markup, because the live app
starts with exactly one workspace and there is no way to click "+" from a
headless capture — multi-tab state is only checkable there. Verified in
both themes headless and on a live window capture.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 03:56:19 +02:00
jgamboaandClaude 01739e4cf4 Fix Stage-1 layout regressions; ListView fills the pane
Three layouts collapsed in fe028a9, where the isMacChrome branches were
removed by a scripted edit that was wrong in two ways. The *bindings* were
dead by construction; the layout classes inside them were not, and nothing
replaced them.

- tab-bar: the edit appended a second `class` attribute to an element that
  already had one. A duplicate attribute is silently dropped, so the
  workspace strip lost h-9/pt-1/pl-1.5 entirely and collapsed under the
  title bar. Merged into one attribute, with a comment saying why there is
  exactly one. A template sweep found no other element carrying two.
- connection-manager + editor-window <header>: the edit deleted all five
  [class.h-*]/[class.pl-*] bindings and put nothing back, leaving both
  headers with no height and no left padding. Restored h-9 pl-3 (the
  non-mac arm) and dropped the now-false "on macOS this row IS the
  titlebar" comments — every window is frameless now.

Separately, the ListView never filled its panel: 7.css sets table-layout:
fixed but declares no width, so the table shrank to content and left a dead
grey gutter down the right of each pane. w-full is safe as a utility
precisely because 7.css declares no width for `skin` to outrank it with;
the unsized first <col> then absorbs the slack, which is what makes Name
elastic.

Verified on a live window capture, not from a build log: the tab strip has
its height back and both tables reach the pane edge. The connection-manager
window needs a click to reach and is unverified visually — its fix is a
verbatim copy of the editor-window one.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 03:46:36 +02:00
jgamboaandClaude 7b15e0982e Ares-style dark glass; drop the window drop shadow
Two requests, plus a correctness bug the first one exposed.

Drop shadow removed: the frame keeps only 7.css's inset 1px glass highlight.
That highlight shares the `box-shadow` declaration with the drop shadow, so it
has to be restated rather than the shorthand cleared. With no shadow there is
nothing to reserve a gutter for, so the window is full-bleed 100vh again
instead of inset by 9/13px.

Dark theme retoned after the Ares reference: the glass goes near-black
(--w7-w-bg #101013, #060607 on the darker tone) with a white caption, and
7.css's own alpha streaks/highlights become the gloss. Blue stays in the
accents — selection, sorted column, active tab — not in the glass. Two literals
7.css hardcodes had to be themed for this: the caption text (black + an 8-fold
white halo, which is invisible on black) and the glass streak gradient (its two
#fff5 corner shines washed a dark caption silver).

The bug: those dark restatements were keyed on [data-theme='dark'], which
matches ONLY the forced-dark path. SYSTEM dark sets no attribute — it runs
through the prefers-color-scheme media query — so every literal I had restated
for the ListView and menu was silently reverting to its light value for anyone
on system dark. Rather than duplicate each rule, the restated literals are now
tokens (--w7-lv-*, --w7-mn-*, --w7-cap-*, --w7-glass-title) declared in
:root.win7 with 7.css's own light values and overridden in BOTH existing dark
blocks, which is the pattern this file already uses. The rules themselves are
written once and theme through the tokens.

Also: the accent -> --w7-w-bg mapping is now light-only, or picking an accent
repaints the Ares glass with it.

Verified zoomed in both themes: light renders identically to 7.css's reference
beside it; dark shows black glass, white caption, subdued streaks.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 03:27:40 +02:00
jgamboaandClaude 0ae2a62229 Stage 4a: context menu is pure 7.css; retire the lookalike and the false claim
The menu now renders 7.css's real contract — ul[role=menu] > li[role=menuitem]
> button for actions, input[type=checkbox] + label for the checkable column
toggles, and dividers as .has-divider on the PRECEDING item (7.css draws them
as an ::after bevel, not as a node; the service's separator entries collapse
onto their predecessor at render time). With the markup right, the framework
supplies everything the hand-written version imitated or lacked: the left
gutter stripe, bevelled separators, the gradient selection band, the checked
box, and disabled/submenu styling we previously had none of.

Deleted: the w7-menu lookalike (both the button-slab stripper in `skin` and
the whole hand-authored Aero set in `skinfix`) and its w7-menu/w7-menu-sep
hooks. The "7.css has no menu component" claims in styles.css and
architecture.md were FALSE — dist/gui/menu.css is a full component; the old
markup (div > button) simply matched none of it. Both corrected to state the
real contract and the trap.

Keyboard now follows the platform instead of a highlight class: arrows move
REAL focus between items — 7.css styles :focus-visible identically to :hover,
and keyboard-initiated programmatic focus counts — and Enter/Space activate
buttons natively; only labels need a synthetic click. This also makes the menu
honest to screen readers.

Dark mode: menu.css hardcodes light literals (color:initial, white bevel
halves, the #b3d3f9 checked-box ring); restated in skinfix's dark arm like the
ListView's.

Also fixed, caught BY the harness: the Stage 3 frame sizing rule was keyed on
bare `.window`, forcing 100vh height onto every 7.css window (the harness's
demo windows blew up to full page). It is now keyed on `w7-frame`, a hook only
the three real window roots carry.

Verified zoomed in light and dark against 7.css's reference markup: gutter,
dividers, checkmark box, grey right-aligned accelerator all match; bundle
490.92 kB.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 03:20:55 +02:00
jgamboaandClaude 1f773261a8 Fix glass frame: title-bar fused to frame, no black around buttons, sane shadow
Three defects reported from the real window, each with a distinct cause:

1. "Top and sides do not connect" — 7.css couples the glass treatment to the
   title bar with a DIRECT-child selector (.window.glass > .title-bar), and our
   .title-bar lived inside an inner div of <app-title-bar>, so the rule never
   matched: the title bar fell back to the opaque base style with its own
   border and radius, a blue slab sitting on the frame. The .title-bar class
   (and the drag region + dblclick-maximize) moved onto the component HOST, so
   the selector now matches and the title bar is the translucent streaked glass
   fused with the frame.

2. "Black background near the window buttons" — .window.glass::after is
   backdrop-filter: blur(4px). CSS cannot sample the DESKTOP behind a
   transparent native window (there is no backdrop to read) and WebKitGTK
   paints the unsampleable layer BLACK, so every see-through part of the glass
   — the ring, the area around the controls — composited over black.
   Neutralized in skinfix; the glass tint now shows the real desktop through
   the transparent window, which is closer to Aero than a blur we cannot have.
   (True blur-behind needs compositor support; GNOME exposes none to
   X11/XWayland clients.)

3. "Shadow way too strong" — the 6px gutter hard-clipped 7.css's
   2px 2px 10px 1px #000000b3 shadow into a solid dark band. The gutter now
   matches the shadow's extents exactly: blur+spread-offset = 9px up/left,
   offset+blur+spread = 13px down/right, so it falls off to nothing.

Verified on the REAL window this time: captured the running app with
ImageMagick import (the app runs on XWayland, so X11 capture works) before and
after. Before: black block around min/max/close, murky dark ring, detached blue
title strip. After: streaks span the top, controls hang from the frame edge as
on real Win7, ring is glass-blue, no black anywhere.

Known remaining (pre-existing, next stages): the tab-bar/toolbar row overlaps
itself top-left (tabs conversion, Stage 4); maximized windows should drop
radius/shadow/gutter.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 02:51:24 +02:00
jgamboaandClaude fe75904872 Stage 3b: transparent native window so the Aero frame is a real frame
.window's 6px radius and its outer drop shadow are painted by the webview
OUTSIDE the window's own rectangle, so on an opaque surface they were simply
clipped — square corners, no shadow. chromeOptions now passes
`transparent: true`, the page paints no background of its own, and .window
takes a 6px margin as the gutter the shadow falls into (h-screen shrinks by the
same amount so the gutter does not produce a scrollbar).

Confirmed on Linux/WebKitGTK in a real run, which is the half the Firefox
harness cannot model:
  GTK: Created window without decorations (custom titlebar)
  GTK: Created transparent window
  GTK WebKit: Applied transparent background to webview

Also caught by the run rather than shipped: `transparent` is set in
chromeOptions, which openSecondaryWindow spreads too — so the editor and
connection-manager windows became transparent while still rendering bare
content, i.e. floating widgets on nothing. Both roots now take the same
.window.glass frame and their own windowFocused signal. The editor's blur
binding had to merge with the existing flushDraft() call; a duplicated host key
is a TS1117 build error rather than a merge, which is how it surfaced.

The old anti-flash <style> in index.html is gone with the opaque backgrounds it
guarded — windows are created hidden and revealed on dom-ready anyway.

Not handled yet: a maximized Win7 window has no radius, shadow or gutter. The
6px margin currently applies in every state.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 02:35:44 +02:00
jgamboaandClaude 2294453c93 Stage 3a: wrap the app in 7.css's .window.glass.active frame
The app rendered a bare .title-bar with no .window around it, which is why the
Aero frame, glass and drop shadow were missing and the title-bar controls sat
permanently in their inactive colours: EVERY control-glyph rule in 7.css is
keyed on `.title-bar.active` / `.window.active .title-bar`, and nothing ever
set .active.

- app.html's root is now `.window.glass`, so it picks up the 6px radius, the
  1px frame, the `inset 0 0 0 1px #fff9` highlight, the drop shadow and the
  backdrop-filter glass layer.
- The content below the title bar moved into `.window-body`, 7.css's recessed
  inner panel (6px inset + its own border) — the thing that gives a Win7 window
  its depth.
- `.active` binds to a new `windowFocused` signal, seeded from
  document.hasFocus() and updated from window focus/blur. The webview receives
  those when the NATIVE window's focus changes, so no RPC is needed.

Harness renders the shell focused and unfocused: active shows the red close
button and crisp glyphs, inactive desaturates, matching the reference.

Native window transparency (for the drop shadow and rounded corners to fall
outside the window rather than being clipped by an opaque surface) is
deliberately NOT in this commit — it is a native flag with a real chance of
misbehaving on Linux, so it gets its own change and a real-app check.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 02:31:27 +02:00
jgamboaandClaude 2db367318d Stop rows shifting 2px when selected
7.css gives ONLY tr.highlighted a border (1px solid #aaddfa), and its table is
border-collapse: collapse — so a selected row genuinely grew 1px at top and
bottom and pushed every row beneath it down by 2px. Moving the cursor made the
whole list jitter.

Every tbody row now reserves that 1px as a transparent border, so selecting a
row changes colour and nothing about the geometry.

The rule has to live in `skinfix`, which outranks `skin` by LAYER regardless of
specificity — so a blanket transparent border there also blanks 7.css's own
highlight border. Both selected states therefore restate border-color
explicitly: the inactive pane via --w7-li-bd-hl (the same #aaddfa 7.css uses,
and already restated by the dark palette), the focused pane via --w7-el-bd-h.

Added a "selection geometry" pair to the harness — identical tables, one with a
middle row selected — so the baselines either line up or they visibly do not.
They did not before this change; they do now, verified zoomed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 02:15:54 +02:00
jgamboaandClaude 66076cf1d7 Pane focus reads from band strength, not from the dots; cursor visible in both panes
The previous model made the dotted rectangle the de-facto focus signal: the
cursor row got ONLY dots (and only in the active pane), so an unfocused pane's
cursor row disappeared entirely, and selection strength was the only band
difference. Wrong way around for a commander UI.

Now (matching Win7 Explorer / the commander convention):
- tr.highlighted = "selected via Space" OR "cursor row" — the template ORs
  them, so the cursor row always carries the selection band in BOTH panes.
- Pane focus is what changes the band: strong Aero band (--w7-el-grad-h) in
  the focused pane, 7.css's own faint inactive wash in the unfocused pane —
  that wash IS Win7's inactive-selection colour, faint by design.
- The dotted rect is demoted to what it was in Win7: a keyboard-focus marker
  layered ON TOP of the band, focused pane only. It is no longer the thing
  that tells you which pane is active.

Harness now renders a focused and an unfocused pane stacked, with selected
rows and a cursor row in each; verified zoomed in light and dark.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 02:10:36 +02:00
jgamboaandClaude 4102020f5c Fix ListView selection and cursor rendering (regressions from Stage 2)
You were right that selecting a line looked off. Three separate problems, two
of them introduced by the Stage 2 dark-mode block:

1. The selection band was ERASED in dark mode. Stage 2 restated the dark
   surface on `table > tbody > tr > *`, i.e. on the CELLS — but 7.css paints
   tr.highlighted on the ROW, and a cell background paints over a row
   background. The selected row kept only its 1px border and no fill. The base
   surface now goes on `table` alone; cells stay transparent so the row band
   shows through. Same trap re-applies to the cell separators, which 7.css
   deliberately drops inside a selected row and the dark rule was re-adding.

2. The cursor rectangle was drawn PER CELL. `tr.is-cursor > *` puts a dotted
   outline around every cell, so the focused row rendered as a row of dotted
   boxes. Win7 drew one rectangle around the row — the outline belongs on the
   `tr`.

3. Selection was barely visible even in light, and that one is 7.css's own
   semantics rather than a bug: listview's `.highlighted` is Win7's *inactive*
   selection, a near-white wash. Win7 drew the FOCUSED pane's selection as the
   stronger Aero band. The table now carries `is-active` from the panel's own
   isActive(), and the focused pane's selection uses --w7-el-grad-h/-bd-h,
   which the dark palette already restates, so it recolours for free.

Verified by cropping and zooming the harness screenshots in both themes rather
than eyeballing a full page: full-row band, separators dropped inside it, and a
single dotted rectangle on the cursor row.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 02:00:30 +02:00
jgamboaandClaude 11c3d419c7 Stage 2: file panels become a real 7.css ListView
The file list was a CSS grid of <div>s, so 7.css's listview component never
matched a single selector and Tailwind styled the whole thing. It is now the
plain <table> 7.css documents, which is what makes the Aero treatment appear.

- The column header row moved OUT of its own div and INTO <thead>: 7.css makes
  thead position:sticky, so it stays put while the body scrolls, and it has to
  be inside the same table to do that.
- Column widths moved from a computed grid-template-columns to <colgroup>,
  because 7.css sets table-layout: fixed. `gridTemplate()` is deleted.
- Selection is now `tr.highlighted` (7.css's gradient band + #aaddfa border)
  instead of bg-primary/15.
- The sort arrow was the text glyph ↑/↓ from `sortIndicator()`, which is also
  deleted; the sorted column is now `th.highlighted.indicator[.up]` and 7.css
  draws the real clip-path triangle in the header.
- Row height, padding, borders and fonts now come from 7.css, so --row-h and
  the per-cell text/color/font utilities are gone.

Two things 7.css does not cover, added in `skinfix`:
- Dark mode. listview.css hardcodes light literals (#fff, #c0c1cd, #eee and a
  white header gradient) rather than --w7-* vars, so it does NOT follow the
  hand-authored dark palette — the header labels rendered invisible, dark on
  dark. The dark arms restate those rules; light keeps 7.css's own values.
- The keyboard cursor. 7.css models selection only; Win7 drew the focused row
  with a dotted focus rectangle over it, which is what tells "cursor" apart
  from "selected" when they differ. That is `tr.is-cursor`.

Verified visually this time, not just by build log: harness screenshots in both
light and dark show the header gradient, sort triangle, cell separators,
selection band and cursor rectangle matching 7.css's reference markup. Backend
`tsc --noEmit` clean, `bun run build:ui` clean, bundle 489.83 kB (from 490.50).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 01:48:20 +02:00
jgamboaandClaude fe028a9b01 Stage 1: make this branch Windows 7 only
The dual-skin arrangement existed to let one build toggle between the modern
shadcn look and 7.css. Keeping both correct is what forced the cascade tricks
that fight 7.css, so the branch drops the toggle: this build IS the Windows 7
build.

- `skin` is gone from AppSettings/DEFAULT_SETTINGS, the settings store, the
  SettingsService (signal, applySkin, persist payload), the options popover
  control, and the i18n keys.
- buildPreload adds `.win7` unconditionally; `chromeOptions()` no longer takes a
  skin and always returns frameless `titleBarStyle: "hidden"`.
- `IS_WIN7_CHROME` and `IS_MAC_CHROME` are deleted along with every
  `isMacChrome` branch in the tab bar, editor window and connection manager.
  Those reserved `pl-24` for macOS traffic lights, which frameless chrome
  removes on every platform, so the branches were dead by construction.
- The title bar is no longer conditional — it is the window's only chrome.
- index.html keeps just the win7 anti-flash arm (four theme states).

Not done here, deliberately: stripping Tailwind's element styling. The
neutralizers in styles.css (notably `.win7 button { min-width: 0 }`) are still
load-bearing until each component's markup is converted to 7.css's real
contract, so they come out per component rather than in one sweep.

Verified: backend `tsc --noEmit` clean, `bun run build:ui` clean; bundle
490.50 kB, down from 492.52 kB.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 01:45:40 +02:00
jgamboaandClaude 72643051f9 Stage 0: headless screenshot harness for the Win7 skin
Everything about this branch's look has so far been verified by reading build
logs, which is how a fully black window got reported as a clean boot. This adds
a way to actually LOOK at the skin.

- scripts/shot.ts — renders a page with Firefox headless (already installed; no
  Chromium/puppeteer download) and writes a PNG. Firefox needs a dedicated
  profile, since a running Firefox locks the default one.
- ui/dev/harness.html — static gallery pairing 7.css's own documented markup
  ("reference") against what the app currently renders ("ours"), for the window
  frame, ListView, context menu, buttons, textbox, progress bars and tabs. Lives
  outside ui/src so the Angular build never sees it.

Two traps found while building it, both documented in the files:
- The composed page MUST be written into ui/dist beside the stylesheet.
  Firefox's security.fileuri.strict_origin_policy blocks a file:// document
  from loading CSS in another directory, and the result renders completely
  unstyled — indistinguishable from "the skin is broken".
- replaceAll, not replace, for the {{STYLES}} token: the harness documents the
  token in a header comment that precedes the <link>, so a first-match replace
  substitutes the comment and leaves the stylesheet unlinked. This produced one
  false "everything is unstyled" result before it was spotted.

What the first screenshots already confirm, visually:
- The window has no Aero frame, radius, drop shadow or glass, and its title-bar
  controls sit in their inactive colours — we render a bare .title-bar with no
  .window.glass.active wrapper.
- The file list gets none of the ListView treatment (header gradient, sort
  indicator, cell separators, selection band) because it is a div grid.
- The context menu gets none of the menu treatment (gutter, dividers, disabled
  state, real checkmarks).
- NEW, not previously predicted: `.win7 button { min-width:0; padding:
  revert-layer }` — added to stop Tailwind icon buttons ballooning — also
  crushes legitimate dialog push buttons well below Win7's 75x23 metrics.
- Dark mode: ListView header labels and tab labels are invisible (dark on
  dark), since listview.css hardcodes light literals the dark block never
  restates.

Caveat recorded in the script: Firefox is Gecko, not the WebKitGTK engine the
app ships on. Good for skin fidelity, useless for native window transparency.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 01:31:54 +02:00
jgamboaandClaude 6bb4c3cd7d Merge main into feat/7css-skin + give the HTML context menu an Aero skin
main retired electrobun's native context menu (a documented no-op on Linux) in
favour of an HTML one on every platform. Merging that here needs the menu to
look right under the Win7 skin as well.

Conflicts, all resolved by keeping both sides:
- src/bun/index.ts — createAppRPC keeps the branch's mainWindow getter, drops
  main's removed contextMenu dep.
- ui/src/app/app.ts — imports both TitleBar and ContextMenu.
- ui/src/app/platform.ts — kept the branch's version (its IS_MAC_CHROME accounts
  for the frameless win7 chrome, and MOD_LABEL correctly keys on the real
  platform rather than the chrome), plus exporting IS_MAC and adding IS_LINUX
  for the menu's per-OS shell wording.

Aero menu (branch-only): 7.css has no menu component, so it is hand-authored
across both layers — the row <button>s shed their push-button slab in `skin`
(revert-layer only reaches `utilities` from there), and the square-cornered
slab, hairline border and gradient selection band are stated in `skinfix`.
Built strictly from existing --w7-* vars, never literal hex, so the
hand-written dark Win7 palette recolours it for free. Hover and the keyboard
cursor share one band (the component marks the cursor with `bg-accent`, which
is overridden alongside :hover) as a real Win7 menu does.

Also answers a question the merge raised: frameless windows DO work on Linux.
The GTK wrapper honours titleBarStyle "hidden" — it calls
gtk_window_set_decorated(false) and logs "GTK: Created window without
decorations (custom titlebar)". Verified on GNOME/Wayland and documented in
architecture.md.

Verified: backend `tsc --noEmit` clean, `bun run build:ui` clean (492.52 kB
initial, still under the 500 kB budget), app launches with the skin active,
frameless, no console errors and no GBM failures.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 14:59:02 +02:00
jgamboaandClaude 30a432cd37 Fix black window on Linux + NVIDIA (WebKitGTK DMABUF renderer)
On the NVIDIA proprietary driver the app booted, logged "miniSCP started",
reported no error — and painted a completely black window, with stderr showing
"Failed to create GBM buffer of size WxH: Invalid argument" and an X11
GLXBadWindow. WebKitGTK's DMABUF renderer cannot allocate buffers there;
WEBKIT_DISABLE_DMABUF_RENDERER=1 is the upstream workaround. Pre-existing on
Linux, unrelated to the context-menu change — it just had never been looked at
because nothing on Linux had been run before.

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), so setting it in
src/bun/index.ts is inert even from a module imported before `electrobun/bun`.
It has to be in the environment before the app process starts, which means
every launch path must inject it:

- scripts/linux-gpu.ts — shared `launchEnv()` helper, gated on
  /proc/driver/nvidia so Intel/AMD keep hardware compositing, and never
  overriding an explicit value from the user's environment.
- scripts/dev.ts — uses it for `bun run dev`.
- scripts/start.ts (new) — `bun run start` previously called `bunx electrobun
  dev` straight from package.json, where no env could be set; it now goes
  through a script like dev does.
- scripts/release.ts — writes a `miniSCP.sh` wrapper beside bin/launcher in the
  Linux tarball, since a packaged app goes through none of the above.

Documented in gotchas.md (including that a "does it boot?" log check does NOT
catch this — the log looks healthy) and platform-notes.md, with the smoke test
updated to require that the UI actually renders.

Verified: backend `tsc --noEmit` clean, `bun run build:ui` clean, and
`bun run start` now boots with zero GBM/GLX errors.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 14:53:22 +02:00
jgamboaandClaude f7ebed5478 Make Linux a supported platform: HTML context menus + runtime deps + release target
`bun run start` died on Linux with ERR_DLOPEN_FAILED (missing
libayatana-appindicator3.so.1). Fixing the environment exposed the real gap:
electrobun's ContextMenu.showContextMenu is a documented no-op on Linux, and
since the UI suppresses the webview's own right-click menu, EVERY panel context
menu was silently dead there — Send to Other Panel, Open, Rename, Permissions,
Delete, Copy Path, Reveal and the column picker all unreachable. Electrobun
itself logs "Application menus are not supported on Linux. Implement menu UI in
your webview HTML instead." at boot.

- Context menus are now HTML on EVERY platform (ui/src/app/context-menu.ts +
  context-menu.service.ts), not just as a Linux fallback: one implementation
  cannot drift from a second one. The item builders are ported verbatim from
  the old bun menu, so ordering, per-platform labels and the Windows-local
  chmod hide are unchanged, and picks are emitted as the same `ctx-*` / `col:*`
  strings through RpcService.emitMenuAction — App.handleMenuAction is untouched
  and still the single dispatcher for both the macOS menubar and right-clicks.
  Adds what the native menu gave for free: arrow/Enter/Escape keys, viewport
  edge-flip, close on blur/resize/scroll. The Edit item now shows the real F4
  hint instead of the native menu's "e" mnemonic.
- New shellItemAction RPC replaces showRowMenu/showColumnsMenu — the only part
  the webview can't do itself (reveal, open-with-default, copy path). Deletes
  src/bun/context-menu.ts entirely.
- release.ts gains a linux-x64 target. Electrobun emits only the .tar.zst
  updater blob on Linux, so the artifact is a .tar.gz of the assembled app
  folder, mirroring the Windows portable zip.
- Docs: platform-notes gets a Linux runtime-dependency table (AppIndicator is
  linked HARD — a missing lib is a boot failure, not a degraded tray), the
  ldd diagnostic, GNOME tray caveat, known issues and a first-boot smoke test;
  gotchas gets the ERR_DLOPEN_FAILED trail and the benign GLX/GBM startup
  noise; architecture/build-and-release/README updated accordingly.

Verified: backend `tsc --noEmit` clean, `bun run build:ui` clean (399 kB
initial), app boots on Linux with no errors beyond the documented benign
warnings. NOT verified on macOS/Windows — the native menu is retired on those
platforms too, so the menus need eyeballing on a Mac before the next release.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 14:23:58 +02:00
jgamboaandClaude 07825c1bd1 Add a toggleable Windows 7 (7.css) skin behind a new skin setting
Evaluation branch: a full Aero skin, including frameless windows with our own
title bar, switchable against the existing modern look.

The app already had a four-axis runtime skinning mechanism and zero component
stylesheets, so the skin is one new setting plus one stylesheet section rather
than a parallel UI. The key move is cascade order: 7.css is imported into a
`skin` layer ABOVE Tailwind's `utilities`, so its bare element rules outrank
the ~1900 inline utility classes already in the templates. Template changes are
limited to additive, skin-inert hooks (ARIA roles, `has-scrollbar`,
`w7-toolbar`, `w7-btn`).

- styles.css: explicit layer order, `7.css/dist/7.scoped.css` via `layer(skin)`,
  a token bridge re-pointing the existing semantic tokens at Win7 values (no new
  tokens, so `@theme inline` is untouched), a hand-authored dark Win7 palette,
  and neutralizers that `revert-layer` 7.css's dialog-button metrics back to the
  utility layer. Neutralizers must live inside `skin`, not `skinfix` —
  `revert-layer` only reaches `utilities` from one layer up.
- Frameless chrome: `chromeOptions(skin)` is now the single place either skin's
  frame is decided. `titleBarStyle: "hidden"` on every platform, which is also
  the only way to drop the macOS traffic lights. New `title-bar` component
  mounted by all three window roots.
- Window controls over RPC (minimize/toggle-maximize/close/is-maximized), served
  per window kind so the X reuses each one's existing close path — editors still
  get their dirty prompt. electrobun has no maximize event, so the state is
  derived from `resize`; double-click-to-maximize is implemented here too.
- Font: Noto Sans vendored (OFL-1.1) under ui/src/fonts, since Segoe UI is
  absent off Windows and not redistributable.

Verified: backend `tsc --noEmit` clean, `bun run build:ui` clean (486 kB initial,
under the 500 kB budget), app launches with the skin active and no console
errors. The rendered result has not been eyeballed — screen capture and
accessibility are both denied to the shell on this machine.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-14 15:02:27 +02:00
89 changed files with 3343 additions and 1949 deletions
+8 -7
View File
@@ -1,14 +1,16 @@
# Release matrix — INERT until act_runner instances with these labels are # Release matrix — INERT until act_runner instances with these labels are
# registered on git.teknes.es. Each OS builds its own artifacts (electrobun # registered on git.teknes.es. Each OS builds its own artifacts (electrobun
# cannot cross-compile: packaging needs codesign/hdiutil on macOS and # cannot cross-compile: packaging needs codesign/hdiutil on macOS and
# rcedit/PowerShell on Windows). # rcedit/PowerShell on Windows). No mac-x64: Electrobun 2.x has no macOS x64
# core.
# #
# Runner prerequisites: # Runner prerequisites:
# macos — arm64 Mac: bun, Xcode CLT, Rosetta 2 (softwareupdate # macos — Apple Silicon Mac: bun, Xcode CLT, network to github.com and the
# --install-rosetta), network to github.com (electrobun downloads # Electrobun/Hutch release hosts (`bun install` runs `electrobun
# its per-arch core binaries on first build) # prepare`; the first build per Electrobun version fills ~/.hutch
# windows — Windows 11 x64: bun, network to github.com; WebView2 runtime is # with Hutch, the core, Cottontail and the pinned Bun, ~200 MB)
# preinstalled on Win 11 # windows — Windows 11 x64: bun, the same network access; WebView2 runtime
# is preinstalled on Win 11
# #
# Trigger: push a tag like build-62 (git tag build-$(git rev-list --count HEAD)). # Trigger: push a tag like build-62 (git tag build-$(git rev-list --count HEAD)).
name: release name: release
@@ -26,7 +28,6 @@ jobs:
fetch-depth: 0 # full history — the build number is the commit count fetch-depth: 0 # full history — the build number is the commit count
- run: bun install --frozen-lockfile - run: bun install --frozen-lockfile
- run: bun scripts/release.ts --target mac-arm64 - run: bun scripts/release.ts --target mac-arm64
- run: bun scripts/release.ts --target mac-x64
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
with: with:
name: mac name: mac
+3
View File
@@ -8,3 +8,6 @@ ui/dist/
ui/.angular/ ui/.angular/
src/shared/version.gen.ts src/shared/version.gen.ts
release/ release/
# Hutch-generated (electrobun 2.x): the projected SDK devkit and build scratch
.hutch/
.cottontail-tmp/
+65 -23
View File
@@ -15,7 +15,8 @@ remote server over SSH/SFTP, so you can move files **local↔remote**,
Dense reference material lives in [docs/](docs/): [architecture](docs/architecture.md) Dense reference material lives in [docs/](docs/): [architecture](docs/architecture.md)
(processes, windows, RPC, every module/service), [build & release](docs/build-and-release.md) (processes, windows, RPC, every module/service), [build & release](docs/build-and-release.md)
(Build-n versioning, per-target release pipeline, CI), [platform notes](docs/platform-notes.md) (Build-n versioning, per-target release pipeline, CI), [platform notes](docs/platform-notes.md)
(macOS/Windows matrix + smoke tests), and [gotchas](docs/gotchas.md) (known landmines). (macOS/Windows/Linux matrix, runtime deps + smoke tests), and
[gotchas](docs/gotchas.md) (known landmines).
## Features ## Features
@@ -35,7 +36,7 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu
- **Menu-bar (tray) icon**: template icon that adapts to light/dark; click it - **Menu-bar (tray) icon**: template icon that adapts to light/dark; click it
to bring the app to the front. to bring the app to the front.
- **Edit files** (F4 / toolbar / File menu): a built-in editor works on local - **Edit files** (F4 / toolbar / File menu): a built-in editor works on local
and remote files directly; or pick an external app (Options → Editor → and remote files directly; or pick an external app (Settings → Editor →
Custom → Choose…). External edits of remote files download to a temp folder, Custom → Choose…). External edits of remote files download to a temp folder,
are watched, and every save auto-uploads (visible in the transfer list) until are watched, and every save auto-uploads (visible in the transfer list) until
the session disconnects. Leftover temp files from a crash are offered for the session disconnects. Leftover temp files from a crash are offered for
@@ -52,10 +53,10 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu
| Layer | Choice | | Layer | Choice |
| ---------- | ------------------------------------------------------------- | | ---------- | ------------------------------------------------------------- |
| Runtime | Bun (main process, all SSH/SFTP + file IO) | | Runtime | Bun (main process, all SSH/SFTP + file IO) |
| Desktop | Electrobun 1.18 (native webview, typed RPC bridge) | | Desktop | Electrobun 2.0 via Hutch (native webview, typed RPC bridge) |
| Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` | | Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` |
| Styling | Tailwind CSS v4, shadcn-style tokens (no component library) | | Styling | Tailwind CSS v4, Oxide-derived tokens (no component library) |
| Icons | @ng-icons/mynaui | | Icons | @ng-icons/tdesign-icons |
| SSH/SFTP | `ssh2` (pure-JS under Bun) | | SSH/SFTP | `ssh2` (pure-JS under Bun) |
The repo is a Bun workspace: the Electrobun app at the root, the Angular app in The repo is a Bun workspace: the Electrobun app at the root, the Angular app in
@@ -75,8 +76,17 @@ ui/src/app/ Angular components + signal services
- [Bun](https://bun.sh) ≥ 1.3 (`bun --version`). - [Bun](https://bun.sh) ≥ 1.3 (`bun --version`).
- macOS: **Xcode Command Line Tools** (for the app build). - macOS: **Xcode Command Line Tools** (for the app build).
- Linux: **AppIndicator** — the app links it for the tray and will not start
without it (`ERR_DLOPEN_FAILED`). `libayatana-appindicator` (Arch),
`libayatana-appindicator3-1` (Debian/Ubuntu),
`libayatana-appindicator-gtk3` (Fedora), plus system `webkit2gtk`.
Details and diagnostics in [platform notes](docs/platform-notes.md).
- No Node install required — a preload shim (`scripts/node-version-shim.ts`) - No Node install required — a preload shim (`scripts/node-version-shim.ts`)
satisfies the Angular CLI's Node version check while running under Bun. satisfies the Angular CLI's Node version check while running under Bun.
- Network on the first `bun install`: Electrobun 2.x's npm package is a
bootstrap that downloads its build tool (Hutch), the Electrobun core and the
pinned Bun runtime into `~/.hutch` (~200 MB), and projects the SDK into the
gitignored `.hutch/devkit/`.
## Setup ## Setup
@@ -101,19 +111,19 @@ Versioning is Sublime-style: **Build n**, where n is the commit count
`src/shared/version.gen.ts` by every build script (and `postinstall`). `src/shared/version.gen.ts` by every build script (and `postinstall`).
One artifact per invocation, on the matching host — electrobun cannot One artifact per invocation, on the matching host — electrobun cannot
cross-compile between OSes: cross-compile (Hutch builds for its own OS and arch):
```sh ```sh
bun run release -- --target mac-arm64 # on the Mac (native) bun run release -- --target mac-arm64 # on an Apple Silicon Mac
bun run release -- --target mac-x64 # on the same arm64 Mac, via Rosetta 2
bun run release -- --target win-x64 # on a Windows 11 x64 machine bun run release -- --target win-x64 # on a Windows 11 x64 machine
bun run release -- --target linux-x64 # on a Linux x64 machine
``` ```
Artifacts land in `release/<target>/miniSCP-Build<n>-<target>.*` — a `.dmg` Artifacts land in `release/<target>/miniSCP-Build<n>-<target>.*` — a `.dmg`
per mac arch; on Windows a `-Setup.zip` installer **and** a `-portable.zip` on the Mac; on Windows a `-Setup.zip` installer **and** a `-portable.zip`
(unzip anywhere and run). The mac-x64 build fetches electrobun's x64 CLI once (unzip anywhere and run); on Linux Electrobun's `-Setup.tar.gz` installer.
into `~/.cache/miniscp/` and runs it under Rosetta so the whole pipeline There is no Intel Mac build: Electrobun 2.x publishes no macOS x64 core.
believes it is on an Intel Mac. CI: `.gitea/workflows/release.yml` runs the CI: `.gitea/workflows/release.yml` runs the
same script on a macos + windows runner matrix once act_runners are same script on a macos + windows runner matrix once act_runners are
registered; trigger by pushing a `build-<n>` tag. registered; trigger by pushing a `build-<n>` tag.
@@ -128,7 +138,7 @@ compiled from `assets/icon.iconset` into `AppIcon.icns` automatically.
For a distributable (optimized, with auto-update channel) build, use For a distributable (optimized, with auto-update channel) build, use
`bunx electrobun build --env=stable` (see the `bunx electrobun build --env=stable` (see the
[Electrobun docs](https://framework.blackboard.sh/electrobun/) for signing, [Electrobun docs](https://electrobun.dev/electrobun/) for signing,
notarization, and release hosting). notarization, and release hosting).
## Rebranding ## Rebranding
@@ -172,7 +182,8 @@ Saved connections live in a plain JSON file in the OS app-data directory
`Space` select · `Enter` open folder · `Backspace` parent · `Space` select · `Enter` open folder · `Backspace` parent ·
`Ctrl/Cmd+A` select all · `F5` send to other panel · `F2` rename · `Ctrl/Cmd+A` select all · `F5` send to other panel · `F2` rename ·
`F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab · `F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab ·
`Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab. `Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab · `Cmd/Ctrl+,` settings ·
`Cmd/Ctrl+=`/`-`/`0` interface bigger / smaller / 100%.
(On Macs, function keys may need `fn` — every action also has a toolbar button.) (On Macs, function keys may need `fn` — every action also has a toolbar button.)
@@ -210,6 +221,12 @@ shell-integration wording, drive listing, and the connections-file ACL. The
application menu ([src/bun/menu.ts](src/bun/menu.ts)) is macOS-only for now; application menu ([src/bun/menu.ts](src/bun/menu.ts)) is macOS-only for now;
the in-app UI covers every menu action. the in-app UI covers every menu action.
Right-click menus are **HTML, on every platform**
([ui/src/app/core/context-menu.service.ts](ui/src/app/core/context-menu.service.ts)) rather than native —
electrobun's native context menu is a documented no-op on Linux, and one menu
implementation beats two that drift. Only reveal / open-with-default / copy-path
cross to the backend, through the `shellItemAction` RPC.
## Windows (beta) ## Windows (beta)
Windows 11+ x64 is supported as a **beta** target (win-arm64 deferred). All Windows 11+ x64 is supported as a **beta** target (win-arm64 deferred). All
@@ -217,11 +234,12 @@ electrobun APIs the app uses are implemented on Windows; known issues are
upstream: upstream:
- **HiDPI blur** ([electrobun#324](https://github.com/blackboardsh/electrobun/issues/324)): - **HiDPI blur** ([electrobun#324](https://github.com/blackboardsh/electrobun/issues/324)):
WebView2 renders at 1x on scaled displays until upstream adds DPI awareness. WebView2 rendered at 1x on scaled displays on Electrobun 1.18; 2.x ships
per-monitor DPI setup — to re-check on the first 2.x Windows build.
- **Notifications** use legacy balloon style, not Windows toast. - **Notifications** use legacy balloon style, not Windows toast.
- **Unsigned binaries**: both the Setup.exe installer and the portable exe - **Unsigned binaries**: both the Setup.exe installer and the portable exe
trigger SmartScreen/Mark-of-the-Web warnings on first run. No Start Menu trigger SmartScreen/Mark-of-the-Web warnings on first run. The installer
entry or uninstaller (installer unpacks to `%LOCALAPPDATA%`). unpacks to `%LOCALAPPDATA%` and, since Electrobun 2.x, ships an uninstaller.
- `connections.json` is protected with an `icacls` current-user ACL instead of - `connections.json` is protected with an `icacls` current-user ACL instead of
POSIX 0600 (which is meaningless on Windows). POSIX 0600 (which is meaningless on Windows).
@@ -231,20 +249,44 @@ drive root ('..' disappears); local↔remote transfers; external editor launch
renders colored; Permissions absent on local panels; Ctrl+labels in tooltips; renders colored; Permissions absent on local panels; Ctrl+labels in tooltips;
portable zip runs from any folder. portable zip runs from any folder.
## Linux (beta)
Linux x64 is supported as a **beta** target and ships Electrobun's own
installer (`-Setup.tar.gz`).
One hard requirement: the AppIndicator library listed under
[Prerequisites](#prerequisites) — electrobun links it for the tray, so without
it the app aborts at startup with `ERR_DLOPEN_FAILED` rather than merely losing
its tray icon.
- **No native menus at all** upstream (both the app menubar and context menus
are no-ops). The menubar was already macOS-only and the context menus are now
HTML, so nothing is missing in practice.
- **Tray visibility** depends on the desktop: KDE, XFCE, MATE and Cinnamon show
it; GNOME 3.26+ needs the AppIndicator extension. The app works either way.
- **Unsigned**, and no AppImage or `.deb` — unpack the `-Setup.tar.gz` and run
its `installer`.
- **NVIDIA GPUs** need `WEBKIT_DISABLE_DMABUF_RENDERER=1` or the window renders
black. The dev scripts set it automatically when an NVIDIA driver is
detected; an installed build needs it set by hand (e.g. in its desktop
entry) — see [gotchas](docs/gotchas.md#linux).
Full dependency table, diagnostics and the first-boot smoke test:
[platform notes](docs/platform-notes.md).
## Known issues ## Known issues
- **Rare native crash under load.** A long-running session (multiple remote - **Rare native crash under load.** A long-running session (multiple remote
connections, extended uptime) can hit a Bun panic — a segfault inside Bun connections, extended uptime) could hit a Bun panic — a segfault inside Bun
1.3.13's FFI thread-safe callbacks executing Electrobun's native→worker 1.3.13's FFI thread-safe callbacks executing Electrobun's native→worker
bridge (`FFI_Callback_threadsafe_call` → `web_worker.spin` in the decoded bridge (`FFI_Callback_threadsafe_call` → `web_worker.spin` in the decoded
crash report). This is an upstream Bun × Electrobun bug, not app code; a crash report). This is an upstream Bun × Electrobun bug, not app code; a
restart recovers cleanly and in-flight work isn't corrupted. Expected to restart recovers cleanly and in-flight work isn't corrupted. Electrobun 2.0.2
resolve when Electrobun bumps its pinned Bun (currently 1.3.13; 1.3.14+ pins Bun 1.4.0, which may have fixed it — not yet confirmed under load. Bun's
available). Bun's own crash reporter link is printed to the console on panic. own crash reporter link is printed to the console on panic.
## Not in v1 ## Not in v1
Drag-and-drop, file preview/edit, transfer resume, symlink following Drag-and-drop, file preview/edit, transfer resume, symlink following
(symlinks are listed but skipped by transfers), OS keychain, tab persistence (symlinks are listed but skipped by transfers), OS keychain, tab persistence
across restarts, Windows/Linux packaging (the code is portable; only macOS across restarts, Linux AppImage/.deb packages (the release is Electrobun's
packaging is wired up). Setup installer).
+13 -65
View File
@@ -5,7 +5,6 @@
"": { "": {
"name": "bunscp", "name": "bunscp",
"dependencies": { "dependencies": {
"electrobun": "1.18.1",
"ssh-config": "^5.2.0", "ssh-config": "^5.2.0",
"ssh2": "^1.17.0", "ssh2": "^1.17.0",
}, },
@@ -13,6 +12,7 @@
"@resvg/resvg-js": "^2.6.2", "@resvg/resvg-js": "^2.6.2",
"@types/bun": "latest", "@types/bun": "latest",
"@types/ssh2": "^1.15.5", "@types/ssh2": "^1.15.5",
"electrobun": "2.0.2",
"png-to-ico": "^3.0.2", "png-to-ico": "^3.0.2",
"typescript": "~5.9.3", "typescript": "~5.9.3",
}, },
@@ -26,7 +26,7 @@
"@angular/core": "^22.0.0", "@angular/core": "^22.0.0",
"@angular/platform-browser": "^22.0.0", "@angular/platform-browser": "^22.0.0",
"@ng-icons/core": "^34.0.0", "@ng-icons/core": "^34.0.0",
"@ng-icons/mynaui": "^34.0.0", "@ng-icons/tdesign-icons": "^34.0.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
}, },
@@ -133,8 +133,6 @@
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@babylonjs/core": ["@babylonjs/core@7.54.3", "", {}, "sha512-P5ncXVd8GEUJLhwloP9V0oVwQYIrvZztguVeLlvd5Rx+9aQnenKjpV8auJ6SRsUlAmNZU4pFTKzwF6o2EUfhAw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
@@ -253,8 +251,6 @@
"@lmdb/lmdb-win32-x64": ["@lmdb/lmdb-win32-x64@3.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-JF1BmLCm9kGEVZgYmJq43zeQVdHVgAJnTi/NURWEsy6L1ZrrlSmdltS+D17QN4LODwf+1LMXAA9auIZVXtWwzw=="], "@lmdb/lmdb-win32-x64": ["@lmdb/lmdb-win32-x64@3.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-JF1BmLCm9kGEVZgYmJq43zeQVdHVgAJnTi/NURWEsy6L1ZrrlSmdltS+D17QN4LODwf+1LMXAA9auIZVXtWwzw=="],
"@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@1.1.1", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
@@ -307,7 +303,7 @@
"@ng-icons/core": ["@ng-icons/core@34.0.0", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular-devkit/schematics": ">=21.0.0", "@angular/common": ">=21.0.0", "@angular/core": ">=21.0.0", "@schematics/angular": ">=21.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-SrL8dBApSKzXhihEGmiJ/tAj+wSvt6XmZJOAzUTVcgkuV0YuL1WCHVfAwXgQHVG5Te27JkjyXd2n76pNFMuekw=="], "@ng-icons/core": ["@ng-icons/core@34.0.0", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular-devkit/schematics": ">=21.0.0", "@angular/common": ">=21.0.0", "@angular/core": ">=21.0.0", "@schematics/angular": ">=21.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-SrL8dBApSKzXhihEGmiJ/tAj+wSvt6XmZJOAzUTVcgkuV0YuL1WCHVfAwXgQHVG5Te27JkjyXd2n76pNFMuekw=="],
"@ng-icons/mynaui": ["@ng-icons/mynaui@34.0.0", "", { "dependencies": { "tslib": "^2.3.0" } }, "sha512-Wstqby65RIOmlbBzrHfSEb9+HX87GbOHmIfSh3oEV8Wr6vulnOnQ11S8M3aYT5kuuIe9Bq00OzYwbHuqYBCNrw=="], "@ng-icons/tdesign-icons": ["@ng-icons/tdesign-icons@34.0.0", "", { "dependencies": { "tslib": "^2.3.0" } }, "sha512-FIRho+rAX3ludOeIMP+/Tr0Xp3zi3j/JXT5YooGeoMo8AF3qk0gvG4Cu1kI+8V5r3SxoOia9ktPxt0ADKt3LXg=="],
"@npmcli/agent": ["@npmcli/agent@4.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg=="], "@npmcli/agent": ["@npmcli/agent@4.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg=="],
@@ -475,8 +471,6 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "postcss": "^8.5.15", "tailwindcss": "4.3.2" } }, "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g=="], "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "postcss": "^8.5.15", "tailwindcss": "4.3.2" } }, "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g=="],
"@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="],
"@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="], "@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="],
"@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="], "@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="],
@@ -497,7 +491,7 @@
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
@@ -513,14 +507,10 @@
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="],
"basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="],
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
"beasties": ["beasties@0.4.2", "", { "dependencies": { "css-select": "^6.0.0", "css-what": "^7.0.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "htmlparser2": "^10.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.49", "postcss-media-query-parser": "^0.2.3", "postcss-safe-parser": "^7.0.1" } }, "sha512-NvcGjG/7AVUAfRbvrJmHunDQS9uHnE6Q/7AkaPr8oKE8HjOlpjRG5075z/th2Tmlezk3VlaaS8+X9I1RwHJMQw=="], "beasties": ["beasties@0.4.2", "", { "dependencies": { "css-select": "^6.0.0", "css-what": "^7.0.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "htmlparser2": "^10.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.49", "postcss-media-query-parser": "^0.2.3", "postcss-safe-parser": "^7.0.1" } }, "sha512-NvcGjG/7AVUAfRbvrJmHunDQS9uHnE6Q/7AkaPr8oKE8HjOlpjRG5075z/th2Tmlezk3VlaaS8+X9I1RwHJMQw=="],
@@ -583,18 +573,12 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"cross-spawn-windows-exe": ["cross-spawn-windows-exe@1.2.0", "", { "dependencies": { "@malept/cross-spawn-promise": "^1.1.0", "is-wsl": "^2.2.0", "which": "^2.0.2" } }, "sha512-mkLtJJcYbDCxEG7Js6eUnUNndWjyUZwJ3H7bErmmtOYU/Zb99DyUkpamuIZE0b3bhmJyZ7D90uS6f+CGxRRjOw=="],
"css-select": ["css-select@6.0.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^7.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "nth-check": "^2.1.1" } }, "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw=="], "css-select": ["css-select@6.0.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^7.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "nth-check": "^2.1.1" } }, "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw=="],
"css-what": ["css-what@7.0.0", "", {}, "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ=="], "css-what": ["css-what@7.0.0", "", {}, "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ=="],
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -611,7 +595,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"electrobun": ["electrobun@1.18.1", "", { "dependencies": { "@babylonjs/core": "^7.45.0", "@types/bun": "^1.3.8", "png-to-ico": "^2.1.8", "proxy-agent": "^6.5.0", "rcedit": "^4.0.1", "three": "^0.165.0" }, "bin": { "electrobun": "bin/electrobun.cjs" } }, "sha512-tgZ+WKGskn/1/Y5i1mpVCCkgRa1O31Tz7MIArBTK44GfPzT43uOq9c/HvxdQGrLeZV8ZvjK1lQrwqSXCgh6tvA=="], "electrobun": ["electrobun@2.0.2", "", { "bin": { "electrobun": "bin/electrobun.cjs" } }, "sha512-UDs2iTNs6qYCzdWiFch/0DtxrsijNDr1WFDQfRmSA3Iouv0iEzyCF5mh4uDxkwNnAbuwFyow36EiVTYP4DRzig=="],
"electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="], "electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="],
@@ -639,14 +623,6 @@
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
@@ -695,8 +671,6 @@
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="],
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
@@ -737,8 +711,6 @@
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
@@ -751,8 +723,6 @@
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
@@ -807,7 +777,7 @@
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
"lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
@@ -859,8 +829,6 @@
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"netmask": ["netmask@2.1.1", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="],
"node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="], "node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="],
"node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="], "node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="],
@@ -903,10 +871,6 @@
"p-map": ["p-map@7.0.5", "", {}, "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA=="], "p-map": ["p-map@7.0.5", "", {}, "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA=="],
"pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="],
"pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="],
"pacote": ["pacote@21.5.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg=="], "pacote": ["pacote@21.5.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg=="],
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
@@ -945,18 +909,12 @@
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
"range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"rcedit": ["rcedit@4.0.1", "", { "dependencies": { "cross-spawn-windows-exe": "^1.1.0" } }, "sha512-bZdaQi34krFWhrDn+O53ccBDw0MkAT2Vhu75SqhtvhQu4OPyFM4RoVheyYiVQYdjhUi6EJMVWQ0tR6bCIYVkUg=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
@@ -1041,8 +999,6 @@
"tar": ["tar@7.5.19", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw=="], "tar": ["tar@7.5.19", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw=="],
"three": ["three@0.165.0", "", {}, "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
@@ -1105,10 +1061,10 @@
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@npmcli/agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"@npmcli/agent/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "@npmcli/agent/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
@@ -1145,18 +1101,18 @@
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"electrobun/png-to-ico": ["png-to-ico@2.1.8", "", { "dependencies": { "@types/node": "^17.0.36", "minimist": "^1.2.6", "pngjs": "^6.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-Nf+IIn/cZ/DIZVdGveJp86NG5uNib1ZXMiDd/8x32HCTeKSvgpyg6D/6tUBn1QO/zybzoMK0/mc3QRgAyXdv9w=="],
"hosted-git-info/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "hosted-git-info/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"https-proxy-agent/agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@@ -1165,16 +1121,14 @@
"ora/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], "ora/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
"pac-proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
"png-to-ico/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="], "png-to-ico/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="],
"proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"ui/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "ui/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
@@ -1187,16 +1141,10 @@
"@angular/compiler-cli/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@angular/compiler-cli/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@npmcli/git/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], "@npmcli/git/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], "@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"electrobun/png-to-ico/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
"electrobun/png-to-ico/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
+163 -62
View File
@@ -6,14 +6,21 @@ Dense reference for development and debugging. Companion files:
## Process model ## Process model
- **Bun main process** (`src/bun/`): electrobun runs it inside a WebWorker of - **Bun main process** (`src/bun/`): the native launcher runs electrobun's
its native launcher. Owns every privileged capability: filesystem, SSH, `main.js` on the Bun the 2.x devkit pins (1.4.0, shipped in the bundle —
window management, native menus/dialogs/tray, the connection/settings stores. `build.mainProcess: "bun"`), which starts our bundle in a Worker. Imports the
SDK as `electrobun/main` (projected into `.hutch/devkit/`). Owns every
privileged capability: filesystem, SSH, window management, native
menus/dialogs/tray, the connection/settings stores.
- **Webviews** (`ui/` Angular): one per window, system WebKit on macOS / - **Webviews** (`ui/` Angular): one per window, system WebKit on macOS /
WebView2 on Windows. Zero privileged access — everything goes through RPC. WebView2 on Windows / WebKitGTK on Linux. Zero privileged access — everything
goes through RPC.
- **RPC transport**: one WebSocket server in the bun process, multiplexed by - **RPC transport**: one WebSocket server in the bun process, multiplexed by
`webviewId` (each webview auto-connects using natively injected globals). `webviewId` (each webview auto-connects using natively injected globals;
Requests are typed by the `AppRPC` schema in `src/shared/rpc.ts`. packets AES-GCM-encrypted per webview). 2.x Electroview queues sends while
its `hostSocket` connects and falls back to a native bridge when the socket
is unavailable. Requests are typed by the `AppRPC` schema in
`src/shared/rpc.ts`.
### The one-schema rule ### The one-schema rule
@@ -29,8 +36,8 @@ Each `BrowserWindow` MUST receive its own `defineRPC(...)` instance: binding a
BrowserView calls `rpc.setTransport(...)`, so a shared instance would have its BrowserView calls `rpc.setTransport(...)`, so a shared instance would have its
transport stolen by the most recent window. `createAppRPC` (main), transport stolen by the most recent window. `createAppRPC` (main),
`createEditorRPC` (per editor window, hooks close over that window's record), `createEditorRPC` (per editor window, hooks close over that window's record),
`createManagerRPC` (the single manager window). `rpc.send.*` on an instance `createManagerRPC` (the single manager window), `createSettingsRPC` (the single
targets exactly its own webview. Settings window). `rpc.send.*` on an instance targets exactly its own webview.
### The `events` late-binding pattern ### The `events` late-binding pattern
@@ -38,16 +45,20 @@ targets exactly its own webview.
exist until after construction). `AppDeps.events` is a mutable object of exist until after construction). `AppDeps.events` is a mutable object of
no-op functions, reassigned in `src/bun/index.ts` after everything exists: no-op functions, reassigned in `src/bun/index.ts` after everything exists:
`sessionClosed`, `connectionsChanged`, `openEditor`, `settingsChanged`, `sessionClosed`, `connectionsChanged`, `openEditor`, `settingsChanged`,
`openConnectionManager`, `showAbout`. `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 ## Window kinds
`src/bun/windows.ts` — `buildPreload(mode, ctx?)` injects `__APP_PLATFORM`, `src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects
`__APP_MODE` (`main|editor|manager`), `__APP_CTX` before any page script. `__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager|settings`), `__APP_CTX`
`ui/src/main.ts` picks the root component from `APP_MODE`; all three roots use before any page script, plus the persisted theme attributes and interface
selector `app-root` (index.html hosts only that tag). `openSecondaryWindow` is scale so the first paint is already right. `ui/src/main.ts` picks the root
the ONLY place that branches platform for secondary chrome: macOS component from `APP_MODE`; all four roots use selector `app-root` (index.html
`hiddenInset` + `trafficLightOffset {x:12,y:13}` (the UI renders an 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 `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. native titlebar and a compact `h-9` non-drag header.
@@ -57,15 +68,16 @@ has **no `off()`** — `track()` closures capture only `(this, key)` so closed
records can be garbage collected. A future "window follows window" grouping records can be garbage collected. A future "window follows window" grouping
belongs here (`anchor.on('move')` + `setPosition`). belongs here (`anchor.on('move')` + `setPosition`).
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/editor-window/`) ### 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 One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
through a concrete session, and `local` is one shared session anyway). through a concrete session, and `local` is one shared session anyway).
Re-opening focuses the existing window. The webview fetches its own content Re-opening focuses the existing window. The webview fetches its own content
(`readFile`) — `openEditor` just creates the window with ctx in the preload. (`readFile`) — `openEditor` just creates the window with ctx in the preload.
**Close safety** (electrobun window `close` is NOT cancellable — verified, no **Close safety** (designed when electrobun window `close` was NOT cancellable —
`allow` response like `before-quit` has): 1.18.1 had no `allow` response like `before-quit`; 2.x adds a cancellable
`will-close` for the titlebar close, not used yet — see gotchas):
1. ⌘W (routed via the app menu → `editorCloseRequest`) / Close button → 1. ⌘W (routed via the app menu → `editorCloseRequest`) / Close button →
styled in-window Save/Don't Save/Cancel; the confirm path calls styled in-window Save/Don't Save/Cancel; the confirm path calls
`editorClose` which sets `suppressPrompt` before `win.close()`. `editorClose` which sets `suppressPrompt` before `win.close()`.
@@ -89,7 +101,9 @@ not `findInPage`, which is a shim on WebView2).
### Connection-manager window (`src/bun/manager-window.ts`) ### Connection-manager window (`src/bun/manager-window.ts`)
Single instance, centered over main, **modal to main only** (editors stay Single instance, centered over main, **modal to main only** (editors stay
usable). No native modality exists, so three layers approximate it: bun 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 bounces main's `focus` event back to the manager; the main webview renders a
click-swallowing overlay (`managerState` message); main drops its keyboard click-swallowing overlay (`managerState` message); main drops its keyboard
shortcuts and bun drops main-targeted menu actions while open. Connect / shortcuts and bun drops main-targeted menu actions while open. Connect /
@@ -100,6 +114,20 @@ main's `ConnectionsService` reloads; a reload-retry in `App.findConnection`
covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it
would float over editor windows). 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 Ctrl+, (`openSettings` from main) and, on macOS, the app
menu's "Settings…" (⌘,, which the menu intercepts first). Its RPC serves only
`getSettings`/`saveSettings`/`pickEditorApp`/`showAbout`. ⌘W reaches bun as
the main window's `close-tab` menu action, so 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 ## Bun modules
- `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops + - `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops +
@@ -148,20 +176,18 @@ would float over editor windows).
- `store/connections.ts` — plaintext-passwords JSON (deliberate v1 choice), - `store/connections.ts` — plaintext-passwords JSON (deliberate v1 choice),
atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
spread-merge on load, so NEW SETTINGS KEYS need no backend change. spread-merge on load, so NEW SETTINGS KEYS need no backend change.
- `context-menu.ts` — native row/empty-space menus + the column picker.
Local shell actions (reveal/open/copy-path) run bun-side via Utils; others
forward through `menuAction` and the UI applies them to the row it
remembered (`FileActionsService.menuTarget`). Labels are per-platform.
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the - `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 Connect submenu mirrors the store; "Settings…" carries ⌘,). ⌘W is intercepted
routes `close-tab` to the focused editor window first. Labels go through by the menu, so bun routes `close-tab` to a focused Settings or editor window
first. Labels go through
backend `i18n.t` and the menu is rebuilt on a locale change too. backend `i18n.t` and the menu is rebuilt on a locale change too.
- `i18n.ts` — backend translator (native menus + thrown error messages, which - `i18n.ts` — backend translator (native menus + thrown error messages, which
no Angular service can reach). `setLocale(setting)` caches the resolved no Angular service can reach). `setLocale(setting)` caches the resolved
locale (from `settings.locale`, `system` via `Intl…resolvedOptions().locale`); locale (from `settings.locale`, `system` via `Intl…resolvedOptions().locale`);
`t(key, params)` shares the same `src/shared/i18n` dictionaries as the UI. `t(key, params)` shares the same `src/shared/i18n` dictionaries as the UI.
Wired in index.ts on boot and on `settingsChanged` (relocalizes + rebuilds Wired in index.ts on boot and on `settingsChanged` (relocalizes + rebuilds
the app menu). `context-menu.ts` labels resolve through `t()` at show-time. the app menu). The context menu no longer needs this — it is Angular and reads
`I18nService` directly.
- `tray.ts` — static icon, click = activate main. macOS template image; - `tray.ts` — static icon, click = activate main. macOS template image;
Windows colored icon. No `setImage` calls ever (they lose the template Windows colored icon. No `setImage` calls ever (they lose the template
treatment) and no menu (clicks arrive directly). treatment) and no menu (clicks arrive directly).
@@ -169,14 +195,33 @@ would float over editor windows).
rpc; manager/editors constructed after win; quit guard; focus tracking for rpc; manager/editors constructed after win; quit guard; focus tracking for
notification suppression across all windows). notification suppression across all windows).
## UI services (`ui/src/app/`) ## UI layout
```
ui/src/app/
core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts,
metrics.ts and every *.service.ts — the non-visual layer
skins/shadlike/ the component tree: four window roots (SHAD_ROOTS barrel,
picked by main.ts), dialogs, context menu
```
Components reach `core/` through the **`@core/*`** tsconfig alias (alongside
`@shared/*` → `src/shared`), which is deliberate: components sit at varying
depths and an alias keeps their imports identical wherever a file moves. Do not
replace it with relative paths.
**Where a new rule goes**: behaviour → `core/`; markup and appearance → the
component; tokens → `ui/src/styles.css`.
## UI services (`ui/src/app/core/`)
- `rpc.service.ts` — wraps `Electroview.defineRPC`. **Push messages fan out - `rpc.service.ts` — wraps `Electroview.defineRPC`. **Push messages fan out
through plain callback registries, never signals**: a signal consumed from through plain callback registries, never signals**: a signal consumed from
an effect that also writes signals re-triggers itself (this froze the whole an effect that also writes signals re-triggers itself (this froze the whole
app once), and signal coalescing drops progress snapshots. All requests are app once), and signal coalescing drops progress snapshots. All requests are
gated on the socket-open promise — calls fired during boot would otherwise gated on the socket-open promise (`electroview.hostSocket`) — on 1.18.1 calls
be silently lost. `request` and `send` are ready-gated Proxies. fired during boot were silently lost; 2.x Electroview queues them itself, so
the gate is belt-and-braces now. `request` and `send` are ready-gated Proxies.
- `panels.service.ts` — `PanelState` (per panel): sessionId, fsKey, cwd, - `panels.service.ts` — `PanelState` (per panel): sessionId, fsKey, cwd,
entries, sort, cursor, selection, type-to-filter. `sorted` computed: entries, sort, cursor, selection, type-to-filter. `sorted` computed:
filter → dirs-first sort (name/size/mtime numeric, kind/rights/owner/group filter → dirs-first sort (name/size/mtime numeric, kind/rights/owner/group
@@ -199,11 +244,15 @@ would float over editor windows).
enqueue and runs the collision flow (same-path auto-skip, type-mismatch enqueue and runs the collision flow (same-path auto-skip, type-mismatch
skip, overwrite dialog with all/none batch modes). skip, overwrite dialog with all/none batch modes).
- `settings.service.ts` — one signal per setting; **imperative apply pattern** - `settings.service.ts` — one signal per setting; **imperative apply pattern**
(`applyTheme/DarkTone/Accent/TextSize` set `data-*` attributes on (`applyTheme/DarkTone` set `data-*` attributes on `<html>`;
`<html>`); `applyAll` also serves the `settingsChanged` broadcast so editor `applyUiScale` sets its inline `font-size`); `applyAll` also serves the
and manager windows restyle live (they render no settings UI, so no `settingsChanged` broadcast, so every window follows a save made in any
persist echo). New setting = five touches here + types.ts (backend merges other. Every window RPC serves `saveSettings` (editor and manager windows
automatically). save too — the scale shortcuts — and the Settings window is nothing but
saves); bun's `settingsSaved()` in index.ts runs the
side effects once and rebroadcasts to every window **except the sender**, so
there is no echo to fight a held Cmd+. `applyAll` never persists. New setting
= five touches here + types.ts (backend merges automatically).
- `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the - `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the
main window's panels listen. Exists so dialogs don't inject PanelsService main window's panels listen. Exists so dialogs don't inject PanelsService
(whose constructor spawns a workspace — fatal in secondary windows). (whose constructor spawns a workspace — fatal in secondary windows).
@@ -213,19 +262,45 @@ would float over editor windows).
(dotted lookup, `{param}` interpolation, `{one,other}` plural maps via (dotted lookup, `{param}` interpolation, `{one,other}` plural maps via
`Intl.PluralRules`); missing keys fall back to English then the raw key. `Intl.PluralRules`); missing keys fall back to English then the raw key.
**Reactive by design**: `t()` reads the `locale` signal, so every template **Reactive by design**: `t()` reads the `locale` signal, so every template
binding that calls it re-renders live on a language switch (same call-stack binding that calls it re-renders live on a language switch (call-stack
tracking as `iconClass`) — no pipe. Also exposes locale-aware `date()`/`size()` tracking, see gotchas.md) — no pipe. Also exposes locale-aware `date()`/`size()`
/`speed()` so the file list reformats on switch. `SettingsService.applyLocale` /`speed()` so the file list reformats on switch. `SettingsService.applyLocale`
resolves the `locale` setting (`system` → `navigator.language`) into it, on resolves the `locale` setting (`system` → `navigator.language`) into it, on
boot and on the `settingsChanged` broadcast (so editor/manager windows follow). boot and on the `settingsChanged` broadcast (so editor/manager windows follow).
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always - `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
against an explicit panel; `menuTarget` remembers the right-clicked row. against an explicit panel; `menuTarget` remembers the right-clicked row.
- `context-menu.service.ts` + `skins/shadlike/context-menu.ts` — the panel and tab right-click
menus, in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was
retired on ALL platforms rather than kept as a second code path (see
platform-notes.md). The service builds the item list (row / empty-space menu,
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 as
`ctx-*` / `col:*` / `tab:*` strings through `RpcService.emitMenuAction`, so they
land in the SAME `App.handleMenuAction` dispatcher 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 the `shellItemAction`
RPC, which is all that remains of the old native menu.
`tabItems()` builds the workspace-tab menu (`tab:new`, and
`tab:close|close-others|close-right|disconnect:<id>`), served by
`PanelsService.closeOtherWorkspaces` / `closeWorkspacesToRight` /
`disconnectWorkspace`. **The target id travels inside the action string**: the
right-clicked tab is not necessarily the active one, so nothing on that path
may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not
omitted — a menu whose items move between right-clicks is worse than one with
a dead row. The disabled styling (`pointer-events: none`) only stops the
pointer; the keyboard path never consults it — so `pick()` and the arrow-key
walker check the flag themselves.
- `file-icons.ts` — extension → category map driving glyphs, colors - `file-icons.ts` — extension → category map driving glyphs, colors
(fixed `--icon-*` tokens, never accent-tinted) and the Kind column. (fixed `--icon-*` tokens from Oxide's hue scales) and the Kind column.
- `icons.ts` — the one icon registry (TDesign, `@ng-icons/tdesign-icons`), passed to
`provideIcons` in `app.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`, - `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
`EDITOR_CONTEXT`, `IS_MAC_CHROME`, `IS_WINDOWS`, `MOD_LABEL`/`ALT_LABEL`) `EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`,
+ `blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside `MOD_LABEL`/`ALT_LABEL`) + `blockNativeContextMenu` (kills WebKit's
text fields). Reload/Inspect menu outside text fields — the app draws its own menu instead).
- `updater.service.ts` — auto-updater state for the tab-bar pill. Signals - `updater.service.ts` — auto-updater state for the tab-bar pill. Signals
`state` (`idle→available→downloading→ready`|`error`), `version`, `progress`. `state` (`idle→available→downloading→ready`|`error`), `version`, `progress`.
Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on
@@ -237,26 +312,52 @@ would float over editor windows).
## Theming ## Theming
`ui/src/styles.css`: shadcn-style OKLCH tokens; dark values are duplicated in `ui/src/styles.css`: OKLCH tokens adapted from **Oxide's design system**
`:root[data-theme='dark']` AND a `prefers-color-scheme` media block (system (docs.oxide.computer: a cool neutral scale at hue 260 with almost no chroma,
mode) — every dark token change goes in BOTH. Light is the zinc family; the one green accent, opaque strokes; the comments carry Oxide's step names
dark neutrals are a **warm gray family (hue 107)** anchored on `#1a1a19` n0…n1300 / green-800). Dark values are duplicated in `:root[data-theme='dark']`
(`--background`) — retoning one dark neutral means retoning them all, or warm AND a `prefers-color-scheme` media block (system mode) — every dark token
and cool surfaces clash. Surface elevation ramp, both themes: `--chrome` change goes in BOTH; retone neutrals along the one scale, or warm and cool
(frame strips) sits *below* `--background` (content panes) < `--card` surfaces clash. **There is no accent setting**: `--primary` is Oxide green per
(popovers/dialogs) < `--accent` (hover). Dark ramp `#151514` → `#1a1a19` → theme (green-800 dark, green-500 light), so selection (`bg-primary/15`), the
`#20201f` → `#2e2e2d`. The "darker" tone is true-black AMOLED: `--chrome` AND cursor row (`--cursor-tint`, a `color-mix` of `--primary`), focus rings
`--background` are both `#000` (the frame/content step collapses on purpose), (green-600) and `--success` all read green. **Surface layers**, each its own
elevation survives only via `--card`/`--accent`, and `--border`/`--input` go to shade: `--chrome` (frame — tab/transfer/status bars, the editor/manager/settings
16%/18% white because those lines then carry the whole layout. header+footer strips and sidebars), the file panel's header strip **stepping
`bg-chrome` is on the tab bar, status bar, transfer bar, the editor/manager toward the list** — session strip `--toolbar`, path bar `--toolbar-2`, column
header+footer strips and the manager's connection sidebar; panel-internal headers `--toolbar-3` (`color-mix` steps declared once in `:root`, so every
toolbars deliberately stay on `--background`. Accent tones: one theme follows) — then `--background` (file lists, editor text, forms;
`--tone-h/--tone-c` line per tone + three formula blocks derive `--primary`, `--row-alt` stripes odd file rows, skipped on the cursor/selected row so two
`--ring`, `--cursor-tint`; graphite = attribute removed = pristine zinc. `bg-*` utilities never fight) and `--card` (popovers, dialogs, toasts). The
right-click menu is the exception: the darkest surface (`bg-chrome`) behind a
faint `border-border/60`, and its highlighted item takes Oxide's selected style —
`--primary-surface` fill (Oxide surface-accent: green-200 dark, green-1200
light) with `text-primary`; mouse and keyboard share that one highlight
(`activeIndex`). The
ACTIVE panel is marked by a 0.125rem `--primary` line along its top edge (an
inset box-shadow on the session strip, so switching panels never shifts
layout). Dark: frame 0.162 < list 0.195 < header 0.275 → 0.248 → 0.221 < card
0.29; light steps greys out from near-white content: frame 0.925 < header 0.95
→ 0.965 → 0.98 < list = card 0.995. **`--accent` is translucent** (n0 at 5%
light, white 7% dark): hover/selected states sit on every layer, and an opaque
hover vanishes on a surface of its own lightness. Strokes are opaque
(`--border` n300 dark / n900 light, `--input` one step stronger). The
"darker" tone is true-black AMOLED: chrome, background, toolbar (+ its steps)
and row-alt are all `#000` (the layers collapse on purpose); elevation
survives only via `--card`/`--muted`/`--accent` (opaque there). **Square
design**: no `rounded-*` utilities and no radius tokens; an `@layer base`
block restyles native controls to match — checkboxes/radios (`appearance:
none`, a drawn check / square dot), selects (a `currentColor` caret; use
`pl-*`, never `px-*`, on a select), hidden number spinners, and square
`::-webkit-scrollbar`s (which make macOS scrollbars always visible).
`@theme inline` maps tokens to Tailwind utilities (`bg-primary`, `@theme inline` maps tokens to Tailwind utilities (`bg-primary`,
`text-icon-folder`, `bg-cursor-tint`). Selected rows are `bg-primary/15` `text-icon-folder`, `bg-toolbar-2`, `bg-cursor-tint`). File-type icon colors
(follow the accent); the cursor row uses `--cursor-tint` so graphite stays come from Oxide's hue scales (800 steps dark, 600 light). **Interface scale** (`settings.uiScale`,
pixel-identical to the old neutral hover. Text-size levels set `--row-h`, 0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets
`--list-font` etc. per `data-text` attribute, consumed via `h-(--row-h)` / `html { font-size: 16px * scale }`, and since EVERY size in the app is rem
`text-(length:--list-font)` arbitrary utilities. (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.
+119 -74
View File
@@ -10,74 +10,105 @@ electrobun}`). It is regenerated by `postinstall` and at the start of every
records, hence gitignored. Fresh checkouts fail `tsc` until `bun install` has records, hence gitignored. Fresh checkouts fail `tsc` until `bun install` has
run once (postinstall creates the file). `BRANDING.version` stays run once (postinstall creates the file). `BRANDING.version` stays
semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel` semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel`
("Build n") is what humans see (About box, options popover). ("Build n") is what humans see (About box, Settings → About).
## Dev loop ## Dev loop
- `bun run dev` — gen-version → scripts/dev.ts: starts `ng serve` (HMR) and - `bun run dev` — gen-version → `electrobun prepare` → scripts/dev.ts: starts
`electrobun dev --watch`, with `DEV_SERVER_URL` pointing the webviews at the `ng serve` (HMR) and `electrobun dev --watch`, with `DEV_SERVER_URL` pointing
dev server (only honored on the dev channel and only if the server answers). the webviews at the dev server (only honored on the dev channel and only if
- `bun run build` — gen-version → Angular build → `electrobun build` the server answers).
(**dev channel** — fine for boot checks, NOT a release). - `bun run build` — gen-version → `electrobun prepare` → Angular build →
`electrobun build` (**dev channel** — fine for boot checks, NOT a release).
- `scripts/node-version-shim.ts` (preloaded via `bunfig.toml`, root AND ui/) - `scripts/node-version-shim.ts` (preloaded via `bunfig.toml`, root AND ui/)
fakes a Node version the Angular CLI accepts under Bun. Both preloads are fakes a Node version the Angular CLI accepts under Bun. Both preloads are
load-bearing. load-bearing.
- `scripts/fix-electrobun-cli.ts` re-signs electrobun's prebuilt CLI binary, - **Electrobun 2.x is driven by Hutch.** The `electrobun` devDependency (exact
whose upstream ad-hoc signature is invalid — macOS SIGKILLs it and the app `2.0.2`) is only a bootstrap: its first command downloads the paired Hutch
silently never launches, with exit code 0 (see [gotchas.md](gotchas.md)). It (0.27.1) from the Electrobun GitHub release, checks its SHA-256 against
runs from `postinstall` **and** at the start of `dev`/`start`/`build`/ `hutch-artifacts.json`, caches it under `~/.hutch/npm/electrobun/<ver>/`, and
`release`, because the CLI is downloaded lazily on first use rather than at forwards every `electrobun …` to `hutch electrobun …`. Hutch then fetches the
install time; when the binary is missing it provokes that download first, so Electrobun core, its build-time Cottontail and the pinned Bun 1.4.0 (the
a fresh checkout self-heals. Equally load-bearing — removing it means runtime the app ships) into `~/.hutch` (~200 MB on a fresh machine) and
`bun run start` does nothing at all. projects the SDK into the gitignored `.hutch/devkit/` (plus a gitignored
`.cottontail-tmp/` scratch dir). The devDependency version IS the toolchain
pin: moving Electrobun = bump it + `bun install`. No Node needed — the
bootstrap's `#!/usr/bin/env node` runs under `bunx`.
- `bunx electrobun prepare` (re)projects `.hutch/devkit`. It runs from
`postinstall` **and** at the start of `dev`/`start`/`build`/`release` (~0.5 s
when current — the slot the v1 CLI re-sign step held; that step is gone,
Hutch's binaries carry valid signatures). tsc (the root tsconfig extends
`.hutch/devkit/tsconfig.json`) and the Angular build / `ng serve` (the
`electrobun/view` path in `ui/tsconfig.json`) resolve the SDK there *before*
any electrobun command would project it; without it both fail on unresolved
`electrobun/*` imports. Bun stays the package manager: Hutch's own resolver
(`hutch.lock`, no lifecycle scripts) only runs on `hutch install`, which
nothing calls — hence no `hutch.config.ts`.
## Release builds ## Release builds
`bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64]` (default: host). `bun scripts/release.ts [--target mac-arm64|win-x64|linux-x64]`
Steps: gen-version → fix-electrobun-cli → gen-icons → Angular build → (default: host).
`electrobun build --env=stable` → copy/rename artifacts into Steps: gen-version → electrobun prepare → gen-icons → Angular build → empty
`artifacts/` → `electrobun build --env=stable` → copy/rename the installers into
`release/<target>/miniSCP-Build<n>-<target>.*`. `release/<target>/miniSCP-Build<n>-<target>.*`.
Facts the script encodes: Facts the script encodes:
- **`--env=stable` is mandatory for releases** — the CLI defaults to the dev - **`--env=stable` is mandatory for releases** — `electrobun build` defaults to
channel (different app name `miniSCP-dev`, no distributable artifacts). the dev channel (different app name `miniSCP-dev`, no distributable artifacts).
- **No OS cross-compile.** The CLI builds for its host (its `targets` config - **No cross-compile, and no Intel Mac.** Hutch builds for its host OS *and*
field is unread in 1.18.1); packaging needs host tools (codesign/hdiutil on arch only; packaging needs host tools (codesign/hdiutil on macOS,
macOS, rcedit/PowerShell on Windows). Hence one script run per artifact, on rcedit/PowerShell on Windows). Hence one script run per artifact, on the
the matching machine; `win-x64` on a non-Windows host errors out. matching machine; `win-x64` on a non-Windows host and `linux-x64` on a
- **mac-x64 from the arm64 Mac (Rosetta)**: the script downloads electrobun's non-Linux host both error out. **Electrobun 2.x publishes no macOS x64 core
x64 CLI binary once (`electrobun-cli-darwin-x64.tar.gz` from the GitHub (nor an x64 Hutch)**, so mac-x64 was dropped — the v1 trick of running the x64
release matching the installed version, cached at CLI under Rosetta has nothing left to run. `--target mac-x64`, and any build
`~/.cache/miniscp/electrobun-cli-x64-<ver>/`) and runs it directly — on an Intel Mac, fail with that message.
Rosetta translates it, the CLI sees `process.arch === "x64"`, downloads the - **Runtime downloads**: on first use per Electrobun version Hutch fetches the
`dist-macos-x64` core into the electrobun package dir on first use, and platform core from `github.com/blackboardsh/electrobun/releases`
packages a genuine x64 app. Note: Bun's `fetch`→`Bun.write` streaming hung (SHA-256-checked against `electrobun-artifacts.json`), plus Cottontail and the
on the GitHub redirect chain; the script uses `curl -fsSL` deliberately. pinned Bun, into `~/.hutch` — network is a build-time requirement on every
- **Both CLI binaries need re-signing.** The x64 CLI fetched for the Rosetta fresh machine/runner.
path carries the same broken upstream signature as the arm64 one, so - **`artifacts/` is emptied before the build** — `site/deploy.sh` publishes it
`x64Cli()` re-signs it after download *and* on cache reuse; the native path verbatim, so it must hold exactly this build (stale files, e.g. v1-named
is covered by the `fix-electrobun-cli.ts` step up front. installers, would otherwise ship).
- **Core binary downloads**: the installed npm package ships mac-arm64 - **Artifact shapes** (stable channel, in `artifacts/`): 2.x installers carry
binaries only; other `dist-<os>-<arch>` cores are fetched from **no channel prefix**; only the updater feed keeps `stable-<os>-<arch>-` (the
`github.com/blackboardsh/electrobun/releases` at build time — network to names v1.18.1+ clients request). macOS → `macos-arm64-miniSCP.dmg` +
GitHub is a build-time requirement on every fresh machine/runner. `stable-macos-arm64-miniSCP.app.tar.zst` + `stable-macos-arm64-update.json`
- **Artifact shapes** (stable channel, in `artifacts/`): macOS → (verified); Windows → `win-x64-miniSCP-Setup.zip` (setup exe + hidden payload)
`stable-macos-<arch>-miniSCP.dmg` + `.app.tar.zst` + `update.json` + the `stable-win-x64-*` feed, plus the script's own **portable zip** of
(updater feed); Windows → `stable-win-x64-miniSCP-Setup.zip` `build/stable-win-x64/miniSCP` (warns instead of failing if that folder is
(self-extracting installer → `%LOCALAPPDATA%`) plus the script's own missing); Linux → `linux-x64-miniSCP-Setup.tar.gz` (a self-extracting
**portable zip** made from the assembled app folder under `installer` + README) + the `stable-linux-x64-*` feed. The official Linux
`build/stable-win-x64/` (layout to confirm on the first real Windows build — installer IS the release artifact — the v1 portable tarball and its
the step warns instead of failing). `miniSCP.sh` NVIDIA wrapper are gone, so an installed build on NVIDIA needs
`WEBKIT_DISABLE_DMABUF_RENDERER=1` from the user (see gotchas.md). The
Windows/Linux names come from the 2.x docs — confirm on the first real 2.x
build there. A build that can reach the previous release at
`release.baseUrl` also emits `stable-<os>-<arch>-<previous-hash>.patch`.
- **Linux builds need the AppIndicator dev libraries present to RUN** (not to
build) — see platform-notes.md; an installed build started on a machine
without them dies at `dlopen`.
- The script filters artifacts by app name, so stale artifacts from older - The script filters artifacts by app name, so stale artifacts from older
brandings don't get collected. brandings don't get collected.
- **v1 → 2.x bridge**: Windows users on 0.1.78 (the only feed live on
miniscp.com/dl) update through their v1 updater because `app.name`,
`app.identifier` and `release.baseUrl` are unchanged, the build is
`--env=stable`, and `artifacts/` is published unrenamed. 2.x keeps the v1
top-level `update.json` fields (`version`/`hash`/`platform`/`arch`) and the
physical `stable` state root, so user data stays put. Unverified until the
first 2.x Windows release reaches a v1 install.
## CI (Gitea Actions) ## CI (Gitea Actions)
`.gitea/workflows/release.yml` — tag-triggered (`build-*`), macos + windows `.gitea/workflows/release.yml` — tag-triggered (`build-*`), macos (mac-arm64) +
runner matrix, each running the same release script; inert until act_runners windows (win-x64) runner matrix, each running the same release script; inert
with those labels are registered. Needs `fetch-depth: 0` (the build number is until act_runners with those labels are registered. Needs `fetch-depth: 0` (the
the commit count). Runner prereqs are commented in the workflow file. build number is the commit count); `bun install --frozen-lockfile` already
prepares the devkit (postinstall). Runner prereqs are commented in the workflow
file.
## Download host (`site/` + Caddy) ## Download host (`site/` + Caddy)
@@ -85,27 +116,31 @@ the commit count). Runner prereqs are commented in the workflow file.
internal box (plain HTTP `:80`, no domain/TLS). Deploys to `/srv/miniscp/`. internal box (plain HTTP `:80`, no domain/TLS). Deploys to `/srv/miniscp/`.
- `site/index.html` — pure static, no JS, tinygrad-minimal. Download buttons link to **fixed - `site/index.html` — pure static, no JS, tinygrad-minimal. Download buttons link to **fixed
stable filenames** (`/dl/stable-macos-arm64-miniSCP.dmg`, …) so the page never changes per stable filenames** (`/dl/macos-arm64-miniSCP.dmg`, `/dl/win-x64-miniSCP-Setup.zip`, the portable
release. Logo/screenshot `<img>` slots are commented in, images live in `site/assets/`. zip) so the page never changes per release. 2.x dropped the `stable-` prefix from installer
names (the feed kept it), so the v1 `stable-…-Setup.zip`/`.dmg` installers still on the server
are stale — delete them by hand. Logo/screenshot `<img>` slots are commented in, images live in
`site/assets/`.
- `site/Caddyfile` — one flat `/dl/*` folder with `file_server browse` + range requests, and - `site/Caddyfile` — one flat `/dl/*` folder with `file_server browse` + range requests, and
per-type `Cache-Control`: `*.patch` immutable (unique hashed names), `*update.json` `no-cache` per-type `Cache-Control`: `*.patch` immutable (unique hashed names), `*update.json` `no-cache`
(updater must see new versions), installers (`*.dmg *.zip *.tar.zst`) `max-age=300` (stable (updater must see new versions), installers (`*.dmg *.zip *.tar.zst *.tar.gz`) `max-age=300`
names overwritten each release). Landing page has **no** SPA fallback → unknown paths 404. (stable names overwritten each release). Landing page has **no** SPA fallback → unknown paths 404.
Path matcher gotcha: use suffix globs like `*update.json`, not `*/*update.json`. Path matcher gotcha: use suffix globs like `*update.json`, not `*/*update.json`.
- **One flat folder, no per-build dirs** — this layout IS electrobun's `artifacts/` feed, so it - **One flat folder, no per-build dirs** — this layout IS electrobun's `artifacts/` feed, so it
doubles as the (future) updater `baseUrl`. Publishing = drop this machine's `artifacts/stable-*` doubles as the updater `baseUrl`. Publishing = drop this machine's whole `artifacts/` into
into `/dl/`; installers overwrite in place, `.patch` files accumulate (never delete them, or `/dl/`, unrenamed; installers overwrite in place, `.patch` files accumulate (never delete them,
older installs lose their incremental-update trail). **Tradeoff:** only the latest full or older installs lose their incremental-update trail). **Tradeoff:** only the latest full
installer is downloadable; old full versions are overwritten. installer is downloadable; old full versions are overwritten.
- `site/deploy.sh` — run per build machine after `scripts/release.ts`; copies `artifacts/stable-*` - `site/deploy.sh` — run per build machine after `scripts/release.ts`; copies `artifacts/*`
and renames the build-numbered win portable zip to the stable `stable-win-x64-miniSCP-portable.zip`. verbatim and renames the build-numbered win portable zip to the stable
`MINISCP_DL_DIR` overrides the target (stage locally, then rsync to the server). `stable-win-x64-miniSCP-portable.zip`. `MINISCP_DL_DIR` overrides the target (stage locally,
then rsync to the server).
- `release.baseUrl = https://miniscp.com/dl` is set in `electrobun.config.ts`; `miniscp.com` is - `release.baseUrl = https://miniscp.com/dl` is set in `electrobun.config.ts`; `miniscp.com` is
fronted by an external proxy for TLS (Caddy itself stays on `:80` behind it). The value is baked fronted by an external proxy for TLS (Caddy itself stays on `:80` behind it). The value is baked
into `version.json` at build time, so only builds made after it was added check for updates. into `version.json` at build time, so only builds made after it was added check for updates.
With `generatePatch` (default true), release builds now need **build-time network to With `generatePatch` (default true), release builds need **build-time network to
`miniscp.com/dl`** so electrobun can fetch the currently-published version to diff a `.patch` `miniscp.com/dl`** so Hutch can fetch the currently-published version to diff a `.patch` (a
(the first build after enabling it has nothing to diff and just ships the full artifacts). target with no published feed yet — currently mac and Linux — just ships the full artifacts).
## Auto-updater (app side) ## Auto-updater (app side)
@@ -117,16 +152,21 @@ Wired on top of the download host above. See the "Auto-updater" section in
`Updater.onStatusChange` streams `download-progress` (0–100) → `updateStatus{downloading}`. `Updater.onStatusChange` streams `download-progress` (0–100) → `updateStatus{downloading}`.
`checkForUpdate` self-guards the `dev` channel, so `bun run dev` never shows the pill. `checkForUpdate` self-guards the `dev` channel, so `bun run dev` never shows the pill.
- **RPC** ([src/shared/rpc.ts](../src/shared/rpc.ts)): requests `checkForUpdate` / `downloadUpdate` / - **RPC** ([src/shared/rpc.ts](../src/shared/rpc.ts)): requests `checkForUpdate` / `downloadUpdate` /
`applyUpdate` (handlers in [rpc-handlers.ts](../src/bun/rpc-handlers.ts) call the electrobun `Updater`); `applyUpdate` (handlers in [rpc-handlers.ts](../src/bun/rpc-handlers.ts) call the electrobun `Updater`;
push message `updateStatus`. `applyUpdate` goes through `events.applyUpdate` in index.ts); push message `updateStatus`.
- **UI**: `UpdaterService` ([ui/src/app/updater.service.ts](../ui/src/app/updater.service.ts)) holds signals - **`applyUpdate()` asks the before-quit guard first (2.x).** With transfers running or dirty
(`state`/`version`/`progress`); the tab-bar "update pill" ([tab-bar.ts](../ui/src/app/tab-bar/tab-bar.ts)) editors the guard vetoes and shows its Quit Anyway / Cancel box. `events.applyUpdate` marks the
quit as update-initiated, so Quit Anyway re-runs `Updater.applyUpdate()` (a plain `Utils.quit()`
would drop the downloaded update); Cancel leaves the pill at "Restart to apply".
- **UI**: `UpdaterService` ([ui/src/app/core/updater.service.ts](../ui/src/app/core/updater.service.ts)) holds signals
(`state`/`version`/`progress`); the tab-bar "update pill" ([tab-bar.ts](../ui/src/app/skins/shadlike/tab-bar/tab-bar.ts))
is one accent (`bg-primary`) control that announces → fills as a progress bar while downloading → is one accent (`bg-primary`) control that announces → fills as a progress bar while downloading →
"Restart to apply". `state` is `available → downloading → ready` (ready derived from the "Restart to apply". `state` is `available → downloading → ready` (ready derived from the
`downloadUpdate` response). App-menu "Check for Updates…" ([menu.ts](../src/bun/menu.ts)) routes a `downloadUpdate` response). App-menu "Check for Updates…" ([menu.ts](../src/bun/menu.ts)) routes a
`check-for-updates` menu action into `UpdaterService.check()`. `check-for-updates` menu action into `UpdaterService.check()`.
- **Unsigned is fine:** `applyUpdate()` strips the macOS quarantine xattr before relaunch, so the - **Unsigned was fine on 1.18.1:** `applyUpdate()` stripped the macOS quarantine xattr before
swapped bundle isn't Gatekeeper-blocked. relaunch. 2.x hands the swap to a native post-exit helper (transactional replace, restores the
old app on failure) — re-check Gatekeeper on the first unsigned 2.x mac update.
## Icons ## Icons
@@ -138,8 +178,13 @@ Rerun after touching either SVG; outputs are committed (build inputs).
## electrobun config notes ## electrobun config notes
`electrobun.config.ts`: app identity comes from `BRANDING`; `build.bun.external` `electrobun.config.ts`: app identity comes from `BRANDING`; **`build.mainProcess:
must keep `["cpu-features", "*.node"]` (ssh2's optional native accelerators — "bun"` is load-bearing** — 2.x defaults to Cottontail, which ignores the whole
bundling them breaks the build; runtime falls back to pure JS); `build.bun` block (verified: without the line, `bunx electrobun config
`build.copy` ships the Angular dist and tray assets into `views://`; --env=stable` resolves `mainProcess: "cottontail"` with no externals);
`bundleCEF: false` everywhere (system WebKit / WebView2). `build.bun.external` must keep `["cpu-features", "*.node"]` (ssh2's optional
native accelerators — bundling them breaks the build; runtime falls back to pure
JS; a 2.x stable bundle keeps both as runtime `require`s); `build.copy` ships the
Angular dist and tray assets into `views://`; `bundleCEF: false` everywhere
(system WebKit / WebView2). `bunx electrobun config --env=stable` prints the
merged config Hutch will build with.
+238 -33
View File
@@ -20,40 +20,133 @@ touching the adjacent code.
`[class.shadow-[...]]` (brackets) does NOT parse — use `[style.box-shadow]`. `[class.shadow-[...]]` (brackets) does NOT parse — use `[style.box-shadow]`.
Static class attributes may use arbitrary values freely. Static class attributes may use arbitrary values freely.
- Functions called from templates ARE reactive: signals read inside them - Functions called from templates ARE reactive: signals read inside them
register on the view's consumer (call-stack-based tracking). That's why register on the view's consumer (call-stack-based tracking). That's why a
`iconClass(entry)` reacting to `settings.iconColors()` works. template calling `i18n.t()` re-renders on a language switch — `t()` reads
the `locale` signal.
- Dedicated tools > shell: IDE diagnostics between paired template/class - Dedicated tools > shell: IDE diagnostics between paired template/class
edits are often stale mid-sequence — trust `bun run build:ui` (full AOT edits are often stale mid-sequence — trust `bun run build:ui` (full AOT
type-check), not the first red squiggle. type-check), not the first red squiggle.
- Tailwind v4 canonical class names: `wrap-break-word` not `break-words`, - Tailwind v4 canonical class names: `wrap-break-word` not `break-words`,
`accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`. `accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`.
Every color used in a template must resolve through `@theme inline`. 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 ## Electrobun
- **The prebuilt CLI binary ships with a broken ad-hoc signature**, so macOS - **The `electrobun` npm package (2.x) is a bootstrap whose every export is a
SIGKILLs it at exec and `bun run start` prints nothing and exits **0** — throwing stub** ("Electrobun 2.x APIs come from the Hutch devkit, not
`bin/electrobun.cjs` spawns the real binary and forwards node_modules"). Every `electrobun/*` import must resolve through the
`process.exit(code || 0)`, and a signal death has `code === null`. The binary projected `.hutch/devkit/`: the root tsconfig extends
is a Bun single-file executable whose payload is appended *after* the linker `.hutch/devkit/tsconfig.json`, and `ui/tsconfig.json` maps `electrobun/view`
signed it, so `codesign -v` reports "code or signature have been modified"; to `../.hutch/devkit/api/browser/index.ts` (the exact export-map target). Let
the arm64 and x64 CLI tarballs are both affected upstream (confirmed by an import fall through to `node_modules/electrobun` and the webview throws at
hashing a fresh download against the shipped copy — it is not local load. tsc and the UI build fail on unresolved `electrobun/*` imports until
corruption, and re-installing does not help). `scripts/fix-electrobun-cli.ts` `bunx electrobun prepare` has run (postinstall and every dev/build script do
re-seals it with `codesign --force --sign -`. It runs from `postinstall` it). Never point anything into `~/.hutch` — that store is Hutch-owned.
**and** ahead of every electrobun invocation, because the wrapper downloads - **`build.mainProcess` defaults to Cottontail in 2.x**, and Cottontail ignores
the binary **lazily on first use**, not at install time — postinstall alone the whole `build.bun` block — the ssh2 externals included, silently (verified
would no-op on a fresh checkout. It also fixes `.cache/electrobun`, which the with `bunx electrobun config --env=stable`). `mainProcess: "bun"` in
wrapper restores `bin/` from. Never read a silent `exit 0` from the `electrobun.config.ts` is load-bearing.
electrobun CLI as "nothing happened": run `codesign -v` on it first. - **The browser SDK is raw TS compiled inside the Angular program**, so the UI's
- **Window `close` is not cancellable** (response type `{}`; only the strictness flags apply to upstream code: 2.0.2's `createTransport().send`
app-level `before-quit` supports `event.response = {allow:false}`). All returns on one path only, which is why `ui/tsconfig.json` has
unsaved-state guards must either intercept app-controlled close paths `noImplicitReturns: false`. Its `import "./global.d.ts"` also prints "File
(⌘W, buttons) or survive the close (editor draft mirroring). '…/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.
- **⌘W in any secondary window arrives as the MAIN window's `close-tab`
menu action** (macOS: the app menu intercepts it before the webview sees
the key). bun must check the focused secondary window first — the Settings
window, then editors — and close THAT; a new window kind that skips this
check makes ⌘W close a main-window tab behind it.
- **One rpc instance per window** — BrowserView binding steals the rpc's - **One rpc instance per window** — BrowserView binding steals the rpc's
transport; a shared instance silently sends to the wrong webview. transport; a shared instance silently sends to the wrong webview.
- **RPC before socket open is silently lost.** Everything gates on the - **RPC before socket open was silently lost on 1.18.1.** 2.x Electroview queues
socket-open promise (RpcService `ready`). 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 - The emitter has no `off()`. Window event closures must not capture records
you want garbage collected. you want garbage collected.
- `electrobun build` defaults to the **dev** channel; release builds need - `electrobun build` defaults to the **dev** channel; release builds need
@@ -63,21 +156,119 @@ touching the adjacent code.
- Tray: never call `setImage` after construction on macOS — the template - Tray: never call `setImage` after construction on macOS — the template
treatment is lost and the icon renders black on dark menu bars. treatment is lost and the icon renders black on dark menu bars.
- `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are - `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are
single-character only. ContextMenu exists on macOS+Windows, not Linux. 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 - **Window-open flash prevention is three-layer** — remove any one and the
white/inverted blink returns: (1) windows are created `hidden: true` and white/inverted blink returns: (1) windows are created `hidden: true` and
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
fallback), (2) the bun preload bakes the persisted `data-theme`/tone/accent fallback), (2) the bun preload bakes the persisted `data-theme`/tone
attributes before first paint (`buildPreload`), (3) `ui/src/index.html` attributes and the interface scale before first paint (`buildPreload`),
carries an inline `<style>` with the html background per theme — its oklch (3) `ui/src/index.html` carries an inline `<style>` with the html background
values MUST stay in sync with the `--background` tokens in styles.css. 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 - 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 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 server answers a HEAD request; secondary windows must reuse the resolved
URL, never hardcode `views://`. URL, never hardcode `views://`.
- Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`): - Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`):
rare segfault under long sessions; upstream electrobun×Bun issue, restart rare segfault under long sessions; upstream electrobun×Bun issue, restart
recovers, nothing app-side to fix. 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).
## Domain logic ## Domain logic
@@ -96,6 +287,12 @@ touching the adjacent code.
- Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so - Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so
new keys auto-default, but the UI SettingsService reads field-by-field — 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. a new setting silently stays at default until wired in all five spots.
- **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); - The editor draft mirror sends full content per tick (2 MB cap upstream);
don't shrink the 400 ms debounce. don't shrink the 400 ms debounce.
- `panel.touched` gates localDir auto-navigation; `connectToken` guards - `panel.touched` gates localDir auto-navigation; `connectToken` guards
@@ -115,9 +312,10 @@ touching the adjacent code.
single-quoted entries** (`'…c'est…'`). Use double quotes for any value single-quoted entries** (`'…c'est…'`). Use double quotes for any value
containing an apostrophe. Plurals are `{ one, other }` maps (not ICU strings); containing an apostrophe. Plurals are `{ one, other }` maps (not ICU strings);
zh/ja supply only `other`. zh/ja supply only `other`.
- **Native menus can't use the Angular service** — `menu.ts`/`context-menu.ts` - **Native menus can't use the Angular service** — `menu.ts` goes through backend
go through backend `i18n.ts`, and the app menu must be rebuilt when the locale `i18n.ts`, and the app menu must be rebuilt when the locale changes (wired in
changes (wired in index.ts `settingsChanged`), or it keeps the old language. 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). - `eu`/`zh`/`ja` are AI drafts pending native review (see the i18n README).
- **Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched - **Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched
result.** A normal `refresh()` is masked by `busy` (input blocked), but the result.** A normal `refresh()` is masked by `busy` (input blocked), but the
@@ -133,7 +331,14 @@ touching the adjacent code.
breaks `ng`. breaks `ng`.
- ssh2's optional native deps must stay `external: ["cpu-features", "*.node"]` - ssh2's optional native deps must stay `external: ["cpu-features", "*.node"]`
in `electrobun.config.ts` — bundling them breaks the build; ssh2 falls back in `electrobun.config.ts` — bundling them breaks the build; ssh2 falls back
to pure JS at runtime. 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 - `bun run --cwd ui build` (flag order matters; `bun --cwd ui run build` does
not do what you want). not do what you want).
- `version.gen.ts` is gitignored and regenerated by postinstall + every build - `version.gen.ts` is gitignored and regenerated by postinstall + every build
+117 -24
View File
@@ -1,35 +1,43 @@
# Platform notes — macOS / Windows behavior matrix # Platform notes — macOS / Windows / Linux behavior matrix
macOS is the primary target; Windows 11+ x64 is a **beta** target (win-arm64 macOS (Apple Silicon) is the primary target; Windows 11+ x64 and Linux x64 are
deferred; Linux untargeted — electrobun lacks menus/context menus there). **beta** targets (win-arm64 and linux-arm64 deferred). **Intel Macs were
dropped** with the move to Electrobun 2.x, which publishes no macOS x64 core.
Every platform seam carries a `PORTABILITY:` comment at the branch site. Every platform seam carries a `PORTABILITY:` comment at the branch site.
## Seam inventory (where platform branches live) ## Seam inventory (where platform branches live)
| Concern | File | mac | win | other | | Concern | File | mac | win | linux |
|---|---|---|---|---| |---|---|---|---|---|
| Window chrome | `src/bun/windows.ts`, `index.ts` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win | | Window chrome | `src/bun/windows.ts chromeOptions` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
| Scrollbars | `ui/src/styles.css` (`::-webkit-scrollbar`) | custom square, always visible (styling them turns off overlay scrollbars) | custom square | custom square |
| External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` | | External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` |
| Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any | | Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any |
| Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` | | Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` |
| Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty | | Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty |
| chmod/Permissions | context-menu.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full | | chmod/Permissions | context-menu.service.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full |
| Shell wording | `context-menu.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager | | Shell wording | `ui context-menu.service.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
| Shortcut labels | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ | | Shortcut labels | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ |
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored | | Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored (needs AppIndicator, see below) |
| App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — About lives in the options popover | none | | App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — Settings opens from the tab-bar gear (Ctrl+,), About from its About section | none (unsupported upstream) |
| connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 | | connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 |
| App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config | | App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config |
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK | | ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" | | Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
The **context menu is HTML on every platform** (`ui/src/app/skins/shadlike/context-menu.ts` +
`context-menu.service.ts`), not a native electrobun menu — see the Linux section
below for why. Only the three shell actions it can't do itself (reveal, open with
the default app, copy path) cross to bun, via the `shellItemAction` RPC.
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
(`path.posix` throughout `fs/sftp.ts`). (`path.posix` throughout `fs/sftp.ts`).
## Memory footprint (expected, not a bug) ## Memory footprint (expected, not a bug)
Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB
Windows**, which is normal for a Bun + system-WebView app (30 MB is native / Windows** (Linux sits near macOS: WebKitGTK, like WKWebView, is lighter than
Chromium), which is normal for a Bun + system-WebView app (30 MB is native /
no-web-engine territory; Tauri-class apps run ~80–200 MB, Electron 150–400 MB). no-web-engine territory; Tauri-class apps run ~80–200 MB, Electron 150–400 MB).
Not reducible to tens of MB without dropping the web UI. Not reducible to tens of MB without dropping the web UI.
@@ -49,18 +57,102 @@ Not reducible to tens of MB without dropping the web UI.
each), and each open tab keeps both panels' full `entries` arrays; terminal each), and each open tab keeps both panels' full `entries` arrays; terminal
transfers are retained until cleared. None affect normal use. transfers are retained until cleared. None affect normal use.
## Linux runtime dependencies (REQUIRED — the app will not start without them)
electrobun's `libNativeWrapper.so` links AppIndicator *hard* for the tray, so a
missing library is a hard boot failure (`ERR_DLOPEN_FAILED` out of `bun:ffi`
`dlopen`), not a degraded tray. The transitive deps (`libdbusmenu-glib`,
`libdbusmenu-gtk3`, `libayatana-indicator3`, `libayatana-ido3`) come in as
package dependencies:
| Distro | Install |
|---|---|
| Arch / CachyOS | `sudo pacman -S libayatana-appindicator` |
| Debian / Ubuntu | `sudo apt install libayatana-appindicator3-1` |
| Fedora | `sudo dnf install libayatana-appindicator-gtk3` |
GTK/WebKit itself comes from the distro too — electrobun uses the system webview
(`bundleCEF: false`), so `webkit2gtk` must be present.
Diagnosing a boot failure:
```sh
ldd build/dev-linux-x64/*/bin/libNativeWrapper.so | grep 'not found'
```
`libasar.so => not found` in that output is **expected and harmless** — it ships
in the same `bin/` folder and resolves at load time; only an unresolved
`libayatana-appindicator3.so.1` is fatal. (Hard link and path verified on
1.18.1 — re-check both on the first 2.x Linux build.)
**Tray visibility** is a separate matter from linking: GNOME 3.26+ hides the
legacy tray, so the icon needs the
[AppIndicator extension](https://extensions.gnome.org/extension/615/appindicator-support/).
KDE Plasma, XFCE, MATE and Cinnamon show it out of the box. The app runs fine
either way — the tray is a convenience, and every action it offers exists in-app.
## Linux known issues (upstream electrobun)
- **No native menus of any kind.** Application menus and `showContextMenu` are
both no-ops (electrobun's `.hutch/devkit/api/sdks/main/proc/linux.md`, still in
2.0.2; the app logs
"Application menus are not supported on Linux" at boot). The app menubar was
already macOS-only, and the panel context menus are now HTML on every platform,
so nothing is missing at the UI level — but do not add a feature that depends
on a native menu.
- **NVIDIA renders black without the DMABUF workaround.** WebKitGTK can't
allocate GBM buffers on the proprietary driver, so the window paints nothing
while the log looks perfectly healthy. The dev launches
(`scripts/linux-gpu.ts`) set `WEBKIT_DISABLE_DMABUF_RENDERER=1` when
`/proc/driver/nvidia` exists; an installed build (the official Setup
installer) does not — launch it with that variable set (e.g. in the desktop
entry's `Exec`). The app can't do it itself (Bun's `process.env` writes don't
reach child processes); see [gotchas.md](gotchas.md#linux).
- **Benign startup noise**: a deprecation warning pointing at
`libayatana-appindicator-glib`. Not fatal.
- **Unsigned; Electrobun's own installer**: the release artifact is
`linux-x64-miniSCP-Setup.tar.gz` — a self-extracting `installer` + README
(2.x does the desktop-entry packaging and ships an uninstaller); no AppImage
or `.deb`. Layout unverified until the first 2.x Linux build.
## First-Linux-boot smoke test
1. Install the AppIndicator package for your distro (table above), then
`bun run start`. The window opens **and actually renders the UI** (a black
window on NVIDIA means the DMABUF workaround didn't apply — check for
`Failed to create GBM buffer` in the log), and the tray icon appears (or
doesn't, on bare GNOME — that's the extension caveat, not a failure).
2. Right-click a file row: menu appears at the pointer, Send to Other Panel is
first, Permissions is present, Escape / click-outside / arrow keys + Enter all
work, and a right-click near the bottom-right flips the menu instead of
overflowing.
3. Right-click empty panel space (New Folder / New File / Refresh / Show in File
Manager) and a column header (checkmarks toggle columns live).
4. Copy Path puts the path on the clipboard; Show in File Manager opens the
distro's file manager; Open (local file) launches the default app via
`xdg-open`.
5. local↔remote transfers both directions; external editor round-trip
(temp download → save → auto-upload) with the inotify watcher.
6. Move to Trash lands the item in the desktop's trash; connections.json is 0600.
7. `bun scripts/release.ts --target linux-x64` produces
`release/linux-x64/miniSCP-Build<N>-linux-x64-Setup.tar.gz`; unpack it
elsewhere, run its `installer`, then launch the installed app (on NVIDIA with
`WEBKIT_DISABLE_DMABUF_RENDERER=1`).
## Windows known issues (upstream electrobun, documented not fixed) ## Windows known issues (upstream electrobun, documented not fixed)
- **HiDPI blur** — WebView2 renders at 1x on scaled displays - **HiDPI blur** — WebView2 renders at 1x on scaled displays
(blackboardsh/electrobun#324, open). Most user-visible defect; wait for (blackboardsh/electrobun#324, seen on 1.18.1). Most user-visible defect; 2.x
upstream DPI awareness. lists per-monitor DPI setup among its Windows fixes — re-check on the first
2.x Windows build.
- **Notifications** are legacy balloon style (`Shell_NotifyIcon`), not - **Notifications** are legacy balloon style (`Shell_NotifyIcon`), not
Windows toasts. Windows toasts.
- **Unsigned binaries** — Setup.exe and the portable exe both trigger - **Unsigned binaries** — Setup.exe and the portable exe both trigger
SmartScreen / Mark-of-the-Web on first run. No Authenticode support in SmartScreen / Mark-of-the-Web on first run. No Authenticode support (2.x:
electrobun 1.18.1 (signtool post-hook or electrobun-builder are options "Windows release signing is not currently part of Hutch's packaging
later). Installer unpacks to `%LOCALAPPDATA%`, desktop shortcut only — no pipeline"; a signtool post-hook is an option later). Installer unpacks to
Start Menu entry or uninstaller. `%LOCALAPPDATA%`, desktop shortcut only; 2.x installer builds add an
uninstaller (`%LOCALAPPDATA%\<identifier>\<install-root>\uninstall.exe`).
- `hiddenInset` degrades to `hidden` on Windows — irrelevant here because - `hiddenInset` degrades to `hidden` on Windows — irrelevant here because
non-mac windows use the native titlebar. non-mac windows use the native titlebar.
- `findInPage` is a `window.find()` shim on WebView2 — irrelevant here, the - `findInPage` is a `window.find()` shim on WebView2 — irrelevant here, the
@@ -71,10 +163,11 @@ Not reducible to tens of MB without dropping the web UI.
Code paths written blind (no Windows box was available); verify in order: Code paths written blind (no Windows box was available); verify in order:
1. Build: `bun scripts/release.ts --target win-x64` on the Windows machine — 1. Build: `bun scripts/release.ts --target win-x64` on the Windows machine —
core binaries download from GitHub releases on first run; Setup.zip AND Hutch, the core and the pinned Bun download into `~/.hutch` on first run;
the portable zip appear under `release/win-x64/` (the portable step warns Setup.zip AND the portable zip appear under `release/win-x64/` (the portable
instead of failing if the assembled-app folder layout differs — fix the step warns instead of failing if the assembled-app folder layout differs —
`appDir` path in release.ts if so). fix the `appDir` path in release.ts if so). A v1 0.1.78 install must pick up
the published 2.x build through its updater (the bridge release).
2. App boots, tray icon renders colored, window has a native titlebar. 2. App boots, tray icon renders colored, window has a native titlebar.
3. Drives dropdown in each panel lists `C:\` (+ others); navigating to a 3. Drives dropdown in each panel lists `C:\` (+ others); navigating to a
drive root hides the `'..'` row; the path bar shows consistent `\` paths. drive root hides the `'..'` row; the path bar shows consistent `\` paths.
@@ -89,7 +182,7 @@ Code paths written blind (no Windows box was available); verify in order:
7. Ctrl+T/W/F/S and Ctrl+digit tab jumps; tooltips read "Ctrl+…". 7. Ctrl+T/W/F/S and Ctrl+digit tab jumps; tooltips read "Ctrl+…".
8. connections.json ACL: `icacls connections.json` shows only the current 8. connections.json ACL: `icacls connections.json` shows only the current
user after saving a connection. user after saving a connection.
9. Editor/manager windows open, modal bounce works, About shows from the 9. Editor/manager/Settings windows open, modal bounce works, Ctrl+, opens
options popover. Settings and About shows from its About section.
10. Known-issue confirmation: HiDPI blur on a scaled display (expected until 10. Known-issue check: HiDPI blur on a scaled display (seen on 1.18.1 — 2.x's
upstream fixes it). per-monitor DPI setup may have fixed it).
+3
View File
@@ -16,6 +16,9 @@ export default {
baseUrl: "https://miniscp.com/dl", baseUrl: "https://miniscp.com/dl",
}, },
build: { build: {
// Load-bearing: 2.x defaults to Cottontail, which ignores the whole
// `bun` block below — the ssh2 externals included.
mainProcess: "bun",
bun: { bun: {
entrypoint: "src/bun/index.ts", entrypoint: "src/bun/index.ts",
// ssh2's optional native accelerators must never be bundled; its // ssh2's optional native accelerators must never be bundled; its
+5 -5
View File
@@ -7,16 +7,15 @@
"ui" "ui"
], ],
"scripts": { "scripts": {
"dev": "bun scripts/gen-version.ts && bun scripts/fix-electrobun-cli.ts && bun scripts/dev.ts", "dev": "bun scripts/gen-version.ts && bunx electrobun prepare && bun scripts/dev.ts",
"dev:ui": "bun run --cwd ui start", "dev:ui": "bun run --cwd ui start",
"build:ui": "bun run --cwd ui build", "build:ui": "bun run --cwd ui build",
"build": "bun scripts/gen-version.ts && bun scripts/fix-electrobun-cli.ts && bun run build:ui && bunx electrobun build", "build": "bun scripts/gen-version.ts && bunx electrobun prepare && bun run build:ui && bunx electrobun build",
"start": "bun scripts/gen-version.ts && bun scripts/fix-electrobun-cli.ts && bun run build:ui && bunx electrobun dev", "start": "bun scripts/gen-version.ts && bunx electrobun prepare && bun run build:ui && bun scripts/start.ts",
"postinstall": "bun scripts/gen-version.ts && bun scripts/fix-electrobun-cli.ts", "postinstall": "bun scripts/gen-version.ts && bunx electrobun prepare",
"release": "bun scripts/release.ts" "release": "bun scripts/release.ts"
}, },
"dependencies": { "dependencies": {
"electrobun": "1.18.1",
"ssh-config": "^5.2.0", "ssh-config": "^5.2.0",
"ssh2": "^1.17.0" "ssh2": "^1.17.0"
}, },
@@ -24,6 +23,7 @@
"@resvg/resvg-js": "^2.6.2", "@resvg/resvg-js": "^2.6.2",
"@types/bun": "latest", "@types/bun": "latest",
"@types/ssh2": "^1.15.5", "@types/ssh2": "^1.15.5",
"electrobun": "2.0.2",
"png-to-ico": "^3.0.2", "png-to-ico": "^3.0.2",
"typescript": "~5.9.3" "typescript": "~5.9.3"
}, },
+3 -1
View File
@@ -1,5 +1,7 @@
// Dev orchestrator: starts ng serve (UI hot reload), waits for it, then runs // Dev orchestrator: starts ng serve (UI hot reload), waits for it, then runs
// `electrobun dev --watch` (relaunches the app when bun-side code changes). // `electrobun dev --watch` (relaunches the app when bun-side code changes).
import { launchEnv } from "./linux-gpu";
const DEV_SERVER_URL = "http://localhost:4200"; const DEV_SERVER_URL = "http://localhost:4200";
const ui = Bun.spawn(["bun", "run", "--cwd", "ui", "start"], { const ui = Bun.spawn(["bun", "run", "--cwd", "ui", "start"], {
@@ -27,7 +29,7 @@ console.log("Angular dev server is up, launching Electrobun ...");
const app = Bun.spawn(["bunx", "electrobun", "dev", "--watch"], { const app = Bun.spawn(["bunx", "electrobun", "dev", "--watch"], {
stdout: "inherit", stdout: "inherit",
stderr: "inherit", stderr: "inherit",
env: { ...process.env, DEV_SERVER_URL }, env: launchEnv({ DEV_SERVER_URL }),
}); });
function shutdown() { function shutdown() {
-46
View File
@@ -1,46 +0,0 @@
// Re-signs electrobun's prebuilt CLI binary, which ships with a BROKEN ad-hoc
// signature: it is a Bun single-file executable whose payload is appended after
// the linker signed it, so `codesign -v` reports "code or signature have been
// modified" and the macOS kernel SIGKILLs it at exec. The failure is invisible:
// electrobun's bin/electrobun.cjs wrapper spawns the real binary and forwards
// `process.exit(code || 0)` — a signal death has code === null, so `bun run
// start` printed nothing and exited 0 while never launching the app.
// `codesign --force --sign -` re-seals it ad-hoc and it runs normally.
//
// The binary is downloaded LAZILY by that wrapper on first use, not by install,
// so this runs both from postinstall and ahead of every electrobun invocation.
// Idempotent, and a no-op off macOS.
import { existsSync } from "node:fs";
import path from "node:path";
const ROOT = path.join(import.meta.dir, "..");
/** true if `bin` has (or now has) a signature the kernel will accept. */
export function ensureSigned(bin: string): boolean {
if (process.platform !== "darwin" || !existsSync(bin)) return true;
const valid = () => Bun.spawnSync(["codesign", "-v", bin], { stderr: "ignore" }).success;
if (valid()) return true;
const signed = Bun.spawnSync(["codesign", "--force", "--sign", "-", bin], { stderr: "pipe" });
if (!signed.success || !valid()) {
console.warn(`[electrobun-cli] could not re-sign ${path.relative(ROOT, bin)} — the app will not launch`);
return false;
}
console.log(`[electrobun-cli] re-signed ${path.relative(ROOT, bin)} (upstream ad-hoc signature is invalid)`);
return true;
}
if (import.meta.main) {
const pkgDir = path.join(ROOT, "node_modules", "electrobun");
const bin = path.join(pkgDir, "bin", "electrobun");
// The wrapper downloads the CLI on first run and only then execs it, so one
// throwaway invocation leaves a downloaded (still unsigned) binary behind
// even though that exec is killed.
if (process.platform === "darwin" && existsSync(pkgDir) && !existsSync(bin)) {
Bun.spawnSync(["bunx", "electrobun", "--version"], { cwd: ROOT, stdout: "ignore", stderr: "ignore" });
}
// .cache is what the wrapper restores bin/ from, so both copies need fixing.
const ok = [bin, path.join(pkgDir, ".cache", "electrobun")].map(ensureSigned).every(Boolean);
if (!ok) process.exit(1);
}
+34
View File
@@ -0,0 +1,34 @@
// PORTABILITY: WebKitGTK's DMABUF renderer cannot allocate GBM buffers on the
// NVIDIA proprietary driver — the app window paints BLACK and stderr fills with
// "Failed to create GBM buffer of size WxH: Invalid argument" (usually with an
// "X11 Error: GLXBadWindow" alongside). Disabling that renderer path is the
// standard upstream workaround.
//
// It has to be in the environment BEFORE the app process starts: WebKit reads
// it when it forks its web process, and Bun's `process.env` writes do NOT
// propagate to children (verified — a child sees the variable unset), so the
// app cannot set this for itself from inside src/bun/. Only the dev launch
// paths (`bun run dev` / `bun run start`) inject it; an installed build (the
// official Linux Setup installer) needs the user to set
// WEBKIT_DISABLE_DMABUF_RENDERER=1 themselves on NVIDIA.
//
// Scoped to NVIDIA so Intel/AMD keep hardware compositing, and an explicit
// value from the user's environment always wins.
import { existsSync } from "node:fs";
export function needsDmabufWorkaround(): boolean {
return (
process.platform === "linux" &&
process.env.WEBKIT_DISABLE_DMABUF_RENDERER === undefined &&
existsSync("/proc/driver/nvidia")
);
}
/** `process.env` plus the workaround, ready to hand to Bun.spawn. */
export function launchEnv(extra: Record<string, string> = {}): Record<string, string | undefined> {
return {
...process.env,
...(needsDmabufWorkaround() ? { WEBKIT_DISABLE_DMABUF_RENDERER: "1" } : {}),
...extra,
};
}
+39 -62
View File
@@ -1,23 +1,21 @@
// Build one release artifact for one target, on the matching host: // Build one release artifact for one target, on the matching host:
// bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64] // bun scripts/release.ts [--target mac-arm64|win-x64|linux-x64]
// Default target = the host. electrobun cannot cross-compile OSes (its // Default target = the host. electrobun cannot cross-compile (Hutch builds for
// packaging needs host tools: codesign/hdiutil on mac, rcedit/PowerShell on // its host OS and arch only; packaging needs host tools: codesign/hdiutil on
// Windows), so win-x64 must run on a Windows machine. mac-x64 IS buildable on // mac, rcedit/PowerShell on Windows), so win-x64 must run on a Windows machine.
// an arm64 Mac: we fetch electrobun's x64 CLI binary and run it under // There is no mac-x64: Electrobun 2.x publishes no macOS x64 core at all.
// Rosetta 2 — it sees an x64 host, pulls the x64 core, and packages mac-x64. // Artifacts land in release/<target>/miniSCP-Build<N>-<target><ext>.
// Artifacts land in release/<target>/miniSCP-Build<N>-<target>.<ext>. import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import { ensureSigned } from "./fix-electrobun-cli";
const ROOT = path.join(import.meta.dir, ".."); const ROOT = path.join(import.meta.dir, "..");
const TARGETS = ["mac-arm64", "mac-x64", "win-x64"] as const; const TARGETS = ["mac-arm64", "win-x64", "linux-x64"] as const;
type Target = (typeof TARGETS)[number]; type Target = (typeof TARGETS)[number];
function hostTarget(): Target { function hostTarget(): Target {
if (process.platform === "win32") return "win-x64"; if (process.platform === "win32") return "win-x64";
return process.arch === "arm64" ? "mac-arm64" : "mac-x64"; if (process.platform === "linux") return "linux-x64";
return "mac-arm64";
} }
function fail(message: string): never { function fail(message: string): never {
@@ -38,21 +36,25 @@ function run(cmd: string[], opts: { cwd?: string } = {}): void {
// ---- target selection + host guards ---- // ---- target selection + host guards ----
const argIndex = process.argv.indexOf("--target"); const argIndex = process.argv.indexOf("--target");
const requested = (argIndex >= 0 ? process.argv[argIndex + 1] : hostTarget()) as Target; const arg = argIndex >= 0 ? process.argv[argIndex + 1] : hostTarget();
if (!TARGETS.includes(requested)) fail(`unknown target "${requested}" (${TARGETS.join(", ")})`); if (arg === "mac-x64") fail("mac-x64 was dropped — Electrobun 2.x publishes no macOS x64 core.");
if (!TARGETS.includes(arg as Target)) fail(`unknown target "${arg}" (${TARGETS.join(", ")})`);
const requested = arg as Target;
if (requested === "win-x64" && process.platform !== "win32") { if (requested === "win-x64" && process.platform !== "win32") {
fail("win-x64 must be built on a Windows machine/runner — electrobun cannot cross-compile."); fail("win-x64 must be built on a Windows machine/runner — electrobun cannot cross-compile.");
} }
if (requested.startsWith("mac") && process.platform !== "darwin") { if (requested === "linux-x64" && process.platform !== "linux") {
fail(`${requested} must be built on macOS.`); fail("linux-x64 must be built on a Linux machine/runner — electrobun cannot cross-compile.");
}
if (requested === "mac-arm64" && (process.platform !== "darwin" || process.arch !== "arm64")) {
fail("mac-arm64 must be built on an Apple Silicon Mac (Electrobun 2.x has no macOS x64 build).");
} }
const viaRosetta = requested === "mac-x64" && process.arch === "arm64";
// ---- prepare: version, icons, UI bundle ---- // ---- prepare: version, devkit, icons, UI bundle ----
run(["bun", "scripts/gen-version.ts"]); run(["bun", "scripts/gen-version.ts"]);
run(["bun", "scripts/fix-electrobun-cli.ts"]); run(["bunx", "electrobun", "prepare"]);
run(["bun", "scripts/gen-icons.ts"]); run(["bun", "scripts/gen-icons.ts"]);
run(["bun", "run", "--cwd", "ui", "build"]); run(["bun", "run", "--cwd", "ui", "build"]);
@@ -60,68 +62,43 @@ const { BUILD } = await import("../src/shared/version.gen");
const { BRANDING } = await import("../src/shared/branding"); const { BRANDING } = await import("../src/shared/branding");
const buildName = `Build${BUILD.number}`; const buildName = `Build${BUILD.number}`;
// ---- electrobun build (native, or x64 CLI under Rosetta) ---- // ---- electrobun build ----
async function x64Cli(): Promise<string> { // Start from an empty artifacts/: site/deploy.sh publishes it verbatim (the
const pkg = await Bun.file(path.join(ROOT, "node_modules", "electrobun", "package.json")).json(); // updater contract), so an earlier build's files must not ride along.
const dir = path.join(os.homedir(), ".cache", "miniscp", `electrobun-cli-x64-${pkg.version}`); const artifactsDir = path.join(ROOT, "artifacts");
const bin = path.join(dir, "electrobun"); rmSync(artifactsDir, { recursive: true, force: true });
// Same broken upstream ad-hoc signature as the arm64 CLI (see run(["bunx", "electrobun", "build", "--env=stable"]);
// scripts/fix-electrobun-cli.ts) — unsigned it is SIGKILLed on exec.
if (existsSync(bin)) {
if (!ensureSigned(bin)) fail(`x64 CLI at ${bin} has an unusable code signature`);
return bin;
}
console.log(`[release] downloading electrobun x64 CLI ${pkg.version} (one-time)…`);
mkdirSync(dir, { recursive: true });
const url = `https://github.com/blackboardsh/electrobun/releases/download/v${pkg.version}/electrobun-cli-darwin-x64.tar.gz`;
const tarball = path.join(dir, "cli.tar.gz");
run(["curl", "-fsSL", "-o", tarball, url]);
run(["tar", "-xzf", tarball, "-C", dir]);
if (!existsSync(bin)) fail(`x64 CLI tarball did not contain "electrobun" (${dir})`);
run(["chmod", "+x", bin]);
if (!ensureSigned(bin)) fail(`x64 CLI at ${bin} has an unusable code signature`);
return bin;
}
if (viaRosetta) {
// x64 Mach-O runs under Rosetta transparently; it sees process.arch === x64
const rosetta = Bun.spawnSync(["arch", "-x86_64", "true"]);
if (!rosetta.success) {
fail("Rosetta 2 is not installed — run: softwareupdate --install-rosetta --agree-to-license");
}
run([await x64Cli(), "build", "--env=stable"]);
} else {
run(["bunx", "electrobun", "build", "--env=stable"]);
}
// ---- collect artifacts ---- // ---- collect artifacts ----
const [osName, archName] = requested === "win-x64" ? ["win", "x64"] : ["macos", requested.slice(4)]; const [osName, archName] =
const prefix = `stable-${osName}-${archName}-`; requested === "win-x64" ? ["win", "x64"] : requested === "linux-x64" ? ["linux", "x64"] : ["macos", "arm64"];
const artifactsDir = path.join(ROOT, "artifacts"); // 2.x installers carry no channel prefix (macos-arm64-miniSCP.dmg); only the
// update feed (stable-<os>-<arch>-*) keeps it, for v1-compatible clients.
const prefix = `${osName}-${archName}-`;
const outDir = path.join(ROOT, "release", requested); const outDir = path.join(ROOT, "release", requested);
mkdirSync(outDir, { recursive: true }); mkdirSync(outDir, { recursive: true });
const produced = readdirSync(artifactsDir).filter( const produced = readdirSync(artifactsDir).filter(
(f) => f.startsWith(prefix) && f.includes(BRANDING.appName), (f) => f.startsWith(prefix) && f.includes(BRANDING.appName),
); );
if (!produced.length) fail(`no ${prefix}* artifacts found in artifacts/ — did the build run?`); if (!produced.length) fail(`no ${prefix}* installer found in artifacts/ — did the build run?`);
const out: string[] = []; const out: string[] = [];
for (const file of produced) { for (const file of produced) {
const ext = file.endsWith(".dmg") ? ".dmg" : file.endsWith("-Setup.zip") ? "-Setup.zip" : null; const ext = [".dmg", "-Setup.zip", "-Setup.tar.gz"].find((e) => file.endsWith(e));
if (!ext) continue; // skip the raw .app.tar.zst + update.json if (!ext) continue;
const name = `${BRANDING.appName}-${buildName}-${requested}${ext}`; const name = `${BRANDING.appName}-${buildName}-${requested}${ext}`;
cpSync(path.join(artifactsDir, file), path.join(outDir, name)); cpSync(path.join(artifactsDir, file), path.join(outDir, name));
out.push(name); out.push(name);
} }
// Windows: also produce a portable zip from the assembled app folder (the // Windows: also zip the assembled app folder — the portable build (unzip
// Setup.exe is an installer; portable = unzip anywhere and run). Best-effort: // anywhere and run) next to the Setup installer. Best-effort: the folder
// the folder layout is confirmed by the first real Windows build. // layout is confirmed by the first real 2.x Windows build.
if (requested === "win-x64") { if (requested === "win-x64") {
const appDir = path.join(ROOT, "build", `stable-${osName}-${archName}`, BRANDING.appName); const appDir = path.join(ROOT, "build", "stable-win-x64", BRANDING.appName);
if (existsSync(appDir)) { if (existsSync(appDir)) {
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`); const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]); run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]);
+20
View File
@@ -0,0 +1,20 @@
// `bun run start`: launch the app against the prebuilt Angular bundle (no ng
// serve / hot reload — that's `bun run dev`). Exists as a script rather than a
// bare `bunx electrobun dev` in package.json so the Linux GPU workaround can be
// injected into the environment before the app process starts.
import { launchEnv } from "./linux-gpu";
const app = Bun.spawn(["bunx", "electrobun", "dev"], {
stdout: "inherit",
stderr: "inherit",
env: launchEnv(),
});
function shutdown(): void {
app.kill();
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
process.exit(await app.exited);
+1 -1
View File
@@ -19,7 +19,7 @@
header @feed Cache-Control "no-cache" header @feed Cache-Control "no-cache"
# Installers/payloads keep stable names but get overwritten each release. # Installers/payloads keep stable names but get overwritten each release.
@pkg path *.dmg *.zip *.tar.zst @pkg path *.dmg *.zip *.tar.zst *.tar.gz
header @pkg Cache-Control "public, max-age=300" header @pkg Cache-Control "public, max-age=300"
# browse => directory listing; file_server serves range requests # browse => directory listing; file_server serves range requests
+7 -5
View File
@@ -42,7 +42,7 @@ electrobun can't cross-compile, so each OS/arch is built on its own machine. On
machine, after producing artifacts, drop them into the shared `dl/` folder: machine, after producing artifacts, drop them into the shared `dl/` folder:
```bash ```bash
bun scripts/release.ts # or --target mac-x64 / win-x64 bun scripts/release.ts # or --target win-x64 / linux-x64
site/deploy.sh # -> /srv/miniscp/dl site/deploy.sh # -> /srv/miniscp/dl
``` ```
@@ -56,14 +56,16 @@ rsync -av ./_dl/ user@host:/srv/miniscp/dl/
That's the whole workflow: **build, drop the artifacts, done.** Installers keep stable names and That's the whole workflow: **build, drop the artifacts, done.** Installers keep stable names and
overwrite in place, so the download links on the page never change. overwrite in place, so the download links on the page never change.
> **Don't delete old `*.patch` files.** They're the incremental-update trail for the (upcoming) > **Don't delete old `*.patch` files.** They're the incremental-update trail for the
> built-in updater — removing them forces older installs to re-download the full app. > built-in updater — removing them forces older installs to re-download the full app.
## Notes ## Notes
- Downloads live under `/dl/` and are browsable at `http://<host>/dl/`. - Downloads live under `/dl/` and are browsable at `http://<host>/dl/`.
- The page links to fixed, stable filenames (`stable-macos-arm64-miniSCP.dmg`, …) so it stays - The page links to fixed, stable filenames (`macos-arm64-miniSCP.dmg`, `win-x64-miniSCP-Setup.zip`,
static and always points at the latest build. …) so it stays static and always points at the latest build. Electrobun 2.x names installers
without the `stable-` prefix; only the updater feed (`stable-<os>-<arch>-update.json`, `.tar.zst`,
`.patch`) keeps it. Installers left over under the old v1 `stable-…` names are stale — delete them.
- Builds are currently unsigned — the FAQ on the page covers the macOS `xattr` step and the - Builds are currently unsigned — the FAQ on the page covers the macOS `xattr` step and the
Windows SmartScreen prompt. Windows SmartScreen prompt.
- `/dl/` is also the intended `release.baseUrl` for the future electrobun auto-updater. - `/dl/` is also the electrobun auto-updater's `release.baseUrl`.
+9 -6
View File
@@ -3,7 +3,7 @@ set -euo pipefail
# Publish this machine's built artifacts into the served flat download folder. # Publish this machine's built artifacts into the served flat download folder.
# #
# Run from the repo root AFTER building for this host's target, e.g.: # Run from the repo root AFTER building for this host's target, e.g.:
# bun scripts/release.ts # or --target mac-x64 / win-x64 # bun scripts/release.ts # or --target win-x64 / linux-x64
# site/deploy.sh # site/deploy.sh
# #
# electrobun cannot cross-compile, so each OS/arch is built (and deployed) on its # electrobun cannot cross-compile, so each OS/arch is built (and deployed) on its
@@ -19,12 +19,15 @@ set -euo pipefail
dl="${MINISCP_DL_DIR:-/srv/miniscp/dl}" dl="${MINISCP_DL_DIR:-/srv/miniscp/dl}"
mkdir -p "$dl" mkdir -p "$dl"
# electrobun's flat artifacts (stable-<os>-<arch>-*): installers, update payloads, # electrobun's flat artifacts, verbatim — the updater contract, never rename them:
# update.json, and (once release.baseUrl is set) .patch files. # the update feed (stable-<os>-<arch>-update.json, .tar.zst, .patch) plus the
if compgen -G 'artifacts/stable-*' >/dev/null; then # installers, which 2.x names without the channel prefix (macos-arm64-miniSCP.dmg,
cp -f artifacts/stable-* "$dl"/ # win-x64-miniSCP-Setup.zip). release.ts empties artifacts/ first, so it holds
# exactly this build.
if compgen -G 'artifacts/*' >/dev/null; then
cp -f artifacts/* "$dl"/
else else
echo "deploy: WARN — no artifacts/stable-* found (did scripts/release.ts run?)" >&2 echo "deploy: WARN — artifacts/ is empty (did scripts/release.ts run?)" >&2
fi fi
# Windows portable zip is a release.ts extra (build-numbered) → give it a stable name. # Windows portable zip is a release.ts extra (build-numbered) → give it a stable name.
+4 -5
View File
@@ -215,9 +215,8 @@
<h2 id="download">Download</h2> <h2 id="download">Download</h2>
<p>Always the latest stable build.</p> <p>Always the latest stable build.</p>
<div class="downloads"> <div class="downloads">
<a class="dl" href="/dl/stable-macos-arm64-miniSCP.dmg">macOS<small>Apple Silicon</small></a> <a class="dl" href="/dl/macos-arm64-miniSCP.dmg">macOS<small>Apple Silicon</small></a>
<a class="dl" href="/dl/stable-macos-x64-miniSCP.dmg">macOS<small>Intel</small></a> <a class="dl" href="/dl/win-x64-miniSCP-Setup.zip">Windows<small>Installer</small></a>
<a class="dl" href="/dl/stable-win-x64-miniSCP-Setup.zip">Windows<small>Installer</small></a>
<a class="dl" href="/dl/stable-win-x64-miniSCP-portable.zip">Windows<small>Portable</small></a> <a class="dl" href="/dl/stable-win-x64-miniSCP-portable.zip">Windows<small>Portable</small></a>
</div> </div>
<p class="browse">Looking for everything? <a href="/dl/">browse all files</a>.</p> <p class="browse">Looking for everything? <a href="/dl/">browse all files</a>.</p>
@@ -226,8 +225,8 @@
<h2 id="faq">FAQ</h2> <h2 id="faq">FAQ</h2>
<dl class="faqtable"> <dl class="faqtable">
<dt>Which platforms are supported?</dt> <dt>Which platforms are supported?</dt>
<dd>macOS (Apple Silicon and Intel) and Windows 11+ (x64). macOS is the primary target; <dd>macOS (Apple Silicon) and Windows 11+ (x64). macOS is the primary target;
Windows is currently in beta.</dd> Windows is currently in beta. Intel Macs are not supported.</dd>
<dt>macOS says the app is "damaged and can't be opened".</dt> <dt>macOS says the app is "damaged and can't be opened".</dt>
<dd>The builds aren't code-signed yet, so macOS quarantines them on download. Clear the <dd>The builds aren't code-signed yet, so macOS quarantines them on download. Clear the
-128
View File
@@ -1,128 +0,0 @@
import { ContextMenu, Utils, type ApplicationMenuItemConfig } from "electrobun/bun";
import type { ColumnKey } from "../shared/types";
import { t } from "./i18n";
/** Optional list columns, in display order (Name is fixed). */
const COLUMN_KEYS: ColumnKey[] = ["size", "mtime", "kind", "rights", "owner", "group"];
// PORTABILITY: shell-integration wording per OS (resolved at show() for locale)
const IS_WIN = process.platform === "win32";
const revealLabel = (): string =>
t(
process.platform === "darwin"
? "ctx.revealFinder"
: IS_WIN
? "ctx.revealExplorer"
: "ctx.revealFileManager",
);
const trashLabel = (): string => t(IS_WIN ? "ctx.moveToRecycleBin" : "ctx.moveToTrash");
export interface RowMenuContext {
path: string;
name: string;
type: "file" | "dir" | "symlink";
isLocal: boolean;
hasEntry: boolean; // false = right-click on empty panel space
}
/**
* Native right-click menus for the file panels. Local-only items run directly
* here via Utils (reveal, open with default app, copy path); everything else
* forwards to the webview through the menuAction channel — the UI kept its own
* context (which panel/row was clicked) when it requested the menu.
*/
export class PanelContextMenu {
private context: RowMenuContext | null = null;
/** Forwards actions the UI must handle (ctx-open, ctx-delete, ...). */
onAction: (action: string) => void = () => {};
constructor() {
ContextMenu.on("context-menu-clicked", (event) => {
const action = (event as { data?: { action?: string } }).data?.action;
if (!action) return;
// column toggles carry no row context
if (action.startsWith("col:")) {
this.onAction(action);
return;
}
const ctx = this.context;
if (!ctx) return;
switch (action) {
case "ctx-reveal":
Utils.showItemInFolder(ctx.path);
break;
case "ctx-open-default":
Utils.openPath(ctx.path);
break;
case "ctx-copy-path":
Utils.clipboardWriteText(ctx.path);
break;
default:
if (action.startsWith("ctx-")) this.onAction(action);
}
});
}
show(context: RowMenuContext): void {
this.context = context;
ContextMenu.showContextMenu(this.build(context));
}
/** Column picker for the list headers: checkable toggles per column. */
showColumns(visible: ColumnKey[]): void {
ContextMenu.showContextMenu(
COLUMN_KEYS.map((key) => ({
label: t(`columns.${key}`),
action: `col:${key}`,
checked: visible.includes(key),
})),
);
}
private build(ctx: RowMenuContext): ApplicationMenuItemConfig[] {
const items: ApplicationMenuItemConfig[] = [];
if (!ctx.hasEntry || ctx.name === "..") {
// empty space (or the parent row): folder-level actions
items.push({ label: t("ctx.newFolder"), action: "ctx-new-folder" });
items.push({ label: t("ctx.newFile"), action: "ctx-new-file" });
items.push({ type: "separator" });
items.push({ label: t("ctx.refresh"), action: "ctx-refresh" });
if (ctx.isLocal) {
items.push({ type: "separator" });
items.push({ label: revealLabel(), action: "ctx-reveal" });
}
return items;
}
items.push({ label: t("ctx.copyToOther"), action: "ctx-copy" });
if (ctx.type === "dir") {
items.push({ label: t("ctx.open"), action: "ctx-open" });
} else {
if (ctx.isLocal) {
items.push({ label: t("ctx.open"), action: "ctx-open-default", tooltip: t("ctx.openDefault") });
}
items.push({ label: t("ctx.edit"), action: "ctx-edit", accelerator: "e" });
}
items.push({ label: t("ctx.rename"), action: "ctx-rename" });
// chmod on Windows local files only flips the read-only bit — hide the lie
if (!(ctx.isLocal && IS_WIN)) {
items.push({ label: t("ctx.permissions"), action: "ctx-chmod" });
}
items.push({ type: "separator" });
items.push({ label: t("ctx.copyPath"), action: "ctx-copy-path" });
if (ctx.isLocal) {
items.push({ label: revealLabel(), action: "ctx-reveal" });
}
items.push({ type: "separator" });
if (ctx.isLocal) {
items.push({
label: trashLabel(),
action: "ctx-trash",
...(process.platform === "darwin" ? { tooltip: t("ctx.trashTooltip") } : {}),
});
}
items.push({ label: t("ctx.delete"), action: "ctx-delete" });
return items;
}
}
Binary file not shown.
+68 -22
View File
@@ -1,8 +1,7 @@
import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "electrobun/bun"; import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "electrobun/main";
import { BRANDING } from "../shared/branding"; import { BRANDING } from "../shared/branding";
import type { EditorOpenContext } from "../shared/rpc"; import type { EditorOpenContext } from "../shared/rpc";
import { effectiveConcurrency, type AppSettings } from "../shared/types"; import { effectiveConcurrency, type AppSettings } from "../shared/types";
import { PanelContextMenu } from "./context-menu";
import { EditManager } from "./edits"; import { EditManager } from "./edits";
import { EditorWindows } from "./editor-windows"; import { EditorWindows } from "./editor-windows";
import { SessionRegistry } from "./fs/registry"; import { SessionRegistry } from "./fs/registry";
@@ -11,12 +10,13 @@ import { ManagerWindow } from "./manager-window";
import { setLocale as setBackendLocale } from "./i18n"; import { setLocale as setBackendLocale } from "./i18n";
import { applyApplicationMenu } from "./menu"; import { applyApplicationMenu } from "./menu";
import { createAppRPC } from "./rpc-handlers"; import { createAppRPC } from "./rpc-handlers";
import { SettingsWindow } from "./settings-window";
import { ConnectionStore } from "./store/connections"; import { ConnectionStore } from "./store/connections";
import { migrateLegacyDataDir } from "./store/paths"; import { migrateLegacyDataDir } from "./store/paths";
import { SettingsStore } from "./store/settings"; import { SettingsStore } from "./store/settings";
import { AppTray } from "./tray"; import { AppTray } from "./tray";
import { TransferEngine } from "./transfers/engine"; import { TransferEngine } from "./transfers/engine";
import { buildPreload, showWhenReady } from "./windows"; import { buildPreload, chromeOptions, showWhenReady } from "./windows";
const PROD_URL = "views://app/index.html"; const PROD_URL = "views://app/index.html";
@@ -46,7 +46,6 @@ const engine = new TransferEngine(registry);
const store = new ConnectionStore(); const store = new ConnectionStore();
const settings = new SettingsStore(); const settings = new SettingsStore();
const edits = new EditManager(registry, engine, settings); const edits = new EditManager(registry, engine, settings);
const contextMenu = new PanelContextMenu();
const watcher = new LocalDirWatcher(); const watcher = new LocalDirWatcher();
const events = { const events = {
sessionClosed: (_info: { sessionId: string; reason: string }) => {}, sessionClosed: (_info: { sessionId: string; reason: string }) => {},
@@ -54,9 +53,11 @@ const events = {
openEditor: (_ctx: EditorOpenContext) => {}, openEditor: (_ctx: EditorOpenContext) => {},
settingsChanged: (_settings: AppSettings) => {}, settingsChanged: (_settings: AppSettings) => {},
openConnectionManager: () => {}, openConnectionManager: () => {},
openSettings: () => {},
showAbout: () => {}, showAbout: () => {},
applyUpdate: async () => {},
}; };
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, watcher, events }); const rpc = createAppRPC({ registry, engine, store, settings, edits, watcher, events });
// late-bound like events: a watched local dir changed -> tell the main webview // late-bound like events: a watched local dir changed -> tell the main webview
watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir }); watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir });
const url = await getMainViewUrl(); const url = await getMainViewUrl();
@@ -67,8 +68,6 @@ const url = await getMainViewUrl();
let currentSettings = await settings.load(); let currentSettings = await settings.load();
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
const isMac = process.platform === "darwin";
const win = new BrowserWindow({ const win = new BrowserWindow({
title: BRANDING.appName, title: BRANDING.appName,
url, url,
@@ -78,12 +77,8 @@ const win = new BrowserWindow({
x: 120, x: 120,
y: 120, y: 120,
}, },
// macOS: fuse the tab bar with the titlebar — traffic lights overlay the // frame decision lives in chromeOptions (windows.ts); constructor-only
// UI's h-10 tab row (offset centers them vertically). Other platforms keep ...chromeOptions(),
// the native titlebar and the tab bar stays a separate row.
...(isMac
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
: {}),
// lets the UI pick the fused layout + theme before first paint // lets the UI pick the fused layout + theme before first paint
preload: buildPreload("main", currentSettings), preload: buildPreload("main", currentSettings),
rpc, rpc,
@@ -100,6 +95,7 @@ const editors = new EditorWindows({
currentSettings: () => currentSettings, currentSettings: () => currentSettings,
mainFrame: () => win.getFrame(), mainFrame: () => win.getFrame(),
notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) }, notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) },
onSettingsSaved: (s, key) => settingsSaved(s, { editor: key }),
}); });
const manager = new ManagerWindow({ const manager = new ManagerWindow({
store, store,
@@ -113,20 +109,45 @@ const manager = new ManagerWindow({
menuAction: (info) => rpc.send.menuAction(info), menuAction: (info) => rpc.send.menuAction(info),
}, },
rebuildMenu: () => void applyApplicationMenu(store), rebuildMenu: () => void applyApplicationMenu(store),
onSettingsSaved: (s) => settingsSaved(s, "manager"),
});
const settingsWin = new SettingsWindow({
settings,
url,
currentSettings: () => currentSettings,
mainFrame: () => win.getFrame(),
onSettingsSaved: (s) => settingsSaved(s, "settings"),
showAbout: () => showAbout(),
}); });
events.openEditor = (ctx) => editors.open(ctx); events.openEditor = (ctx) => editors.open(ctx);
events.settingsChanged = (s) => { /**
* Every settings save lands here, whichever window made it (editor and manager
* windows save too — the scale shortcuts — and the Settings window is nothing
* but saves): side effects run once, then every OTHER window follows. The
* sender is skipped — it already shows the new values, and an echo arriving
* behind a newer local change (a held Cmd+) would drag it backwards.
*/
function settingsSaved(
s: AppSettings,
sender: "main" | "manager" | "settings" | { editor: string },
): void {
engine.setConcurrency(effectiveConcurrency(s));
const localeChanged = s.locale !== currentSettings.locale; const localeChanged = s.locale !== currentSettings.locale;
currentSettings = s; // future windows' preloads bake the fresh theme currentSettings = s; // future windows' preloads bake the fresh theme
if (localeChanged) { if (localeChanged) {
setBackendLocale(s.locale); setBackendLocale(s.locale);
void applyApplicationMenu(store); // relabel the native menu in the new language void applyApplicationMenu(store); // relabel the native menu in the new language
settingsWin.retitle();
} }
editors.broadcastSettings(s); if (sender !== "main") rpc.send.settingsChanged({ settings: s });
manager.broadcastSettings(s); if (sender !== "manager") manager.broadcastSettings(s);
}; if (sender !== "settings") settingsWin.broadcastSettings(s);
editors.broadcastSettings(s, typeof sender === "object" ? sender.editor : undefined);
}
events.settingsChanged = (s) => settingsSaved(s, "main");
events.openConnectionManager = () => manager.open(); events.openConnectionManager = () => manager.open();
events.openSettings = () => settingsWin.open();
function showAbout(): void { function showAbout(): void {
const { build } = BRANDING; const { build } = BRANDING;
@@ -145,11 +166,11 @@ events.showAbout = showAbout;
// and the modal manager has nothing to guard // and the modal manager has nothing to guard
win.on("close", () => { win.on("close", () => {
manager.close(); manager.close();
settingsWin.close();
editors.closeAllForAppExit(); editors.closeAllForAppExit();
}); });
new AppTray(win); new AppTray(win);
contextMenu.onAction = (action) => rpc.send.menuAction({ action });
// notify on finished transfers while the window is in the background // notify on finished transfers while the window is in the background
let windowFocused = true; let windowFocused = true;
@@ -170,6 +191,7 @@ engine.onUpdate = (snapshot) => {
if ( if (
!windowFocused && !windowFocused &&
!editors.anyFocused() && !editors.anyFocused() &&
!settingsWin.isFocused() &&
(snapshot.state === "completed" || snapshot.state === "failed") (snapshot.state === "completed" || snapshot.state === "failed")
) { ) {
Utils.showNotification({ Utils.showNotification({
@@ -190,11 +212,24 @@ engine.onUpdate = (snapshot) => {
// don't quit out from under running transfers or unsaved editors // don't quit out from under running transfers or unsaved editors
let quitConfirmed = false; let quitConfirmed = false;
// Updater.applyUpdate() asks this guard first (2.x), so remember the quit is
// update-initiated: "Quit Anyway" must re-run the apply, or a plain quit would
// drop the downloaded update.
let applyingUpdate = false;
events.applyUpdate = async () => {
applyingUpdate = true;
try {
await Updater.applyUpdate();
} finally {
applyingUpdate = false;
}
};
Electrobun.events.on("before-quit", (event) => { Electrobun.events.on("before-quit", (event) => {
const transfers = engine.activeJobCount(); const transfers = engine.activeJobCount();
const dirtyEditors = editors.dirtyCount(); const dirtyEditors = editors.dirtyCount();
if (quitConfirmed || (transfers === 0 && dirtyEditors === 0)) return; if (quitConfirmed || (transfers === 0 && dirtyEditors === 0)) return;
(event as { response: { allow: boolean } }).response = { allow: false }; (event as { response: { allow: boolean } }).response = { allow: false };
const updating = applyingUpdate; // read now: it clears once the vetoed apply returns
void (async () => { void (async () => {
const parts = [ const parts = [
transfers > 0 && `${transfers} transfer${transfers === 1 ? " is" : "s are"} still running`, transfers > 0 && `${transfers} transfer${transfers === 1 ? " is" : "s are"} still running`,
@@ -212,7 +247,10 @@ Electrobun.events.on("before-quit", (event) => {
}); });
if (response === 0) { if (response === 0) {
quitConfirmed = true; quitConfirmed = true;
Utils.quit(); // the trailing quit is a no-op once the apply is quitting, and still
// quits if it bails (no prepared download)
if (updating) void Updater.applyUpdate().finally(() => Utils.quit());
else Utils.quit();
} }
})(); })();
}); });
@@ -227,10 +265,14 @@ void applyApplicationMenu(store);
ApplicationMenu.on("application-menu-clicked", (event) => { ApplicationMenu.on("application-menu-clicked", (event) => {
const action = (event as { data?: { action?: string } }).data?.action; const action = (event as { data?: { action?: string } }).data?.action;
if (!action) return; if (!action) return;
// ⌘W while an editor window is focused closes that editor (with its dirty // ⌘W while the Settings window or an editor window is focused closes THAT
// prompt), not a main-window tab. Other menu actions deliberately still // window (an editor with its dirty prompt), not a main-window tab. Other
// target the main window. // menu actions deliberately still target the main window.
if (action === "close-tab") { if (action === "close-tab") {
if (settingsWin.isFocused()) {
settingsWin.close();
return;
}
const focusedEditor = editors.focusedKey(); const focusedEditor = editors.focusedKey();
if (focusedEditor) { if (focusedEditor) {
editors.requestClose(focusedEditor); editors.requestClose(focusedEditor);
@@ -241,6 +283,10 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
manager.open(); manager.open();
return; return;
} }
if (action === "settings") {
settingsWin.open();
return;
}
if (action === "about") { if (action === "about") {
showAbout(); showAbout();
return; return;
+4 -1
View File
@@ -1,4 +1,4 @@
import type { BrowserWindow } from "electrobun/bun"; import type { BrowserWindow } from "electrobun/main";
import type { AppSettings } from "../shared/types"; import type { AppSettings } from "../shared/types";
import { createManagerRPC } from "./rpc-handlers"; import { createManagerRPC } from "./rpc-handlers";
import type { ConnectionStore } from "./store/connections"; import type { ConnectionStore } from "./store/connections";
@@ -24,6 +24,8 @@ export interface ManagerWindowDeps {
}; };
/** Rebuilds the native app menu after store changes. */ /** Rebuilds the native app menu after store changes. */
rebuildMenu: () => void; rebuildMenu: () => void;
/** The manager saved settings — rebroadcast them to every other window. */
onSettingsSaved: (settings: AppSettings) => void;
} }
/** /**
@@ -70,6 +72,7 @@ export class ManagerWindow {
this.close(); this.close();
}, },
onClose: () => this.close(), onClose: () => this.close(),
onSettingsSaved: (next) => this.deps.onSettingsSaved(next),
}, },
); );
+3 -1
View File
@@ -1,4 +1,4 @@
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/bun"; import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/main";
import { BRANDING } from "../shared/branding"; import { BRANDING } from "../shared/branding";
import type { SavedConnection } from "../shared/types"; import type { SavedConnection } from "../shared/types";
import { t } from "./i18n"; import { t } from "./i18n";
@@ -36,6 +36,8 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
submenu: [ submenu: [
{ label: t("menu.about", { app: BRANDING.appName }), action: "about" }, { label: t("menu.about", { app: BRANDING.appName }), action: "about" },
{ type: "separator" }, { type: "separator" },
{ label: t("menu.settings"), action: "settings", accelerator: "," },
{ type: "separator" },
{ label: t("menu.checkForUpdates"), action: "check-for-updates" }, { label: t("menu.checkForUpdates"), action: "check-for-updates" },
{ type: "separator" }, { type: "separator" },
{ role: "hide" }, { role: "hide" },
+89 -42
View File
@@ -1,7 +1,6 @@
import { BrowserView, Updater, Utils } from "electrobun/bun"; import { BrowserView, Updater, Utils } from "electrobun/main";
import type { AppRPC, EditorOpenContext } from "../shared/rpc"; import type { AppRPC, EditorOpenContext } from "../shared/rpc";
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types"; import type { AppSettings, Result } from "../shared/types";
import type { PanelContextMenu } from "./context-menu";
import { readFileText, writeFileText, type EditManager } from "./edits"; import { readFileText, writeFileText, type EditManager } from "./edits";
import { listLocalDrives } from "./fs/local"; import { listLocalDrives } from "./fs/local";
import type { SessionRegistry } from "./fs/registry"; import type { SessionRegistry } from "./fs/registry";
@@ -24,7 +23,6 @@ export interface AppDeps {
store: ConnectionStore; store: ConnectionStore;
settings: SettingsStore; settings: SettingsStore;
edits: EditManager; edits: EditManager;
contextMenu: PanelContextMenu;
watcher: LocalDirWatcher; watcher: LocalDirWatcher;
/** Assigned after the rpc object exists (it needs rpc.send). */ /** Assigned after the rpc object exists (it needs rpc.send). */
events: { events: {
@@ -33,7 +31,10 @@ export interface AppDeps {
openEditor: (ctx: EditorOpenContext) => void; openEditor: (ctx: EditorOpenContext) => void;
settingsChanged: (settings: AppSettings) => void; settingsChanged: (settings: AppSettings) => void;
openConnectionManager: () => void; openConnectionManager: () => void;
openSettings: () => void;
showAbout: () => void; showAbout: () => void;
/** "Restart to apply" — lives next to the before-quit guard it must get past. */
applyUpdate: () => Promise<void>;
}; };
} }
@@ -101,6 +102,22 @@ function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void)
}; };
} }
/**
* saveSettings for every window kind: persist, then hand the new object to the
* composition root (index.ts), which applies the side effects once and
* rebroadcasts to every OTHER window. Known ceiling: whole-object writes — two
* windows saving in the same instant, the later one wins. Upgrade path: send a
* patch and merge it bun-side.
*/
const saveSettingsHandler =
(store: SettingsStore, onSaved: (next: AppSettings) => void) =>
({ settings: next }: { settings: AppSettings }) =>
wrap(async () => {
await store.save(next);
onSaved(next);
return {};
});
const pickKeyFileHandler = () => const pickKeyFileHandler = () =>
wrap(async () => { wrap(async () => {
const { homedir } = await import("node:os"); const { homedir } = await import("node:os");
@@ -116,13 +133,34 @@ const pickKeyFileHandler = () =>
return { path }; return { path };
}); });
/** External-editor picker, shared by the main and settings window rpcs. */
const pickEditorAppHandler = () =>
wrap(async () => {
// PORTABILITY: apps live in different places per OS
const { homedir } = await import("node:os");
const [startingFolder, allowedFileTypes] =
process.platform === "darwin"
? ["/Applications", "app"]
: process.platform === "win32"
? [process.env.ProgramFiles ?? "C:\\Program Files", "exe"]
: [homedir(), "*"];
const picked = await Utils.openFileDialog({
startingFolder,
allowedFileTypes,
canChooseFiles: true,
canChooseDirectory: false,
allowsMultipleSelection: false,
});
const path = picked.find((p) => p.trim().length > 0) ?? null;
return { path };
});
export function createAppRPC({ export function createAppRPC({
registry, registry,
engine, engine,
store, store,
settings, settings,
edits, edits,
contextMenu,
watcher, watcher,
events, events,
}: AppDeps) { }: AppDeps) {
@@ -227,13 +265,7 @@ export function createAppRPC({
// ---- settings ---- // ---- settings ----
getSettings: () => wrap(async () => ({ settings: await settings.load() })), getSettings: () => wrap(async () => ({ settings: await settings.load() })),
saveSettings: ({ settings: next }) => saveSettings: saveSettingsHandler(settings, (next) => events.settingsChanged(next)),
wrap(async () => {
await settings.save(next);
engine.setConcurrency(effectiveConcurrency(next));
events.settingsChanged(next);
return {};
}),
// ---- file editing ---- // ---- file editing ----
readFile: ({ sessionId, path }) => readFile: ({ sessionId, path }) =>
@@ -250,26 +282,7 @@ export function createAppRPC({
editExternal: ({ sessionId, path }) => editExternal: ({ sessionId, path }) =>
wrap(() => edits.editExternal(sessionId, path)), wrap(() => edits.editExternal(sessionId, path)),
pickEditorApp: () => pickEditorApp: pickEditorAppHandler,
wrap(async () => {
// PORTABILITY: apps live in different places per OS
const { homedir } = await import("node:os");
const [startingFolder, allowedFileTypes] =
process.platform === "darwin"
? ["/Applications", "app"]
: process.platform === "win32"
? [process.env.ProgramFiles ?? "C:\\Program Files", "exe"]
: [homedir(), "*"];
const picked = await Utils.openFileDialog({
startingFolder,
allowedFileTypes,
canChooseFiles: true,
canChooseDirectory: false,
allowsMultipleSelection: false,
});
const path = picked.find((p) => p.trim().length > 0) ?? null;
return { path };
}),
pickKeyFile: pickKeyFileHandler, pickKeyFile: pickKeyFileHandler,
@@ -282,10 +295,12 @@ export function createAppRPC({
return {}; return {};
}), }),
// ---- context menu + local shell integration ---- // ---- local shell integration (menu itself is HTML, see ui/) ----
showRowMenu: (params) => shellItemAction: ({ action, path }) =>
wrap(async () => { wrap(async () => {
contextMenu.show(params); if (action === "reveal") Utils.showItemInFolder(path);
else if (action === "open") Utils.openPath(path);
else Utils.clipboardWriteText(path);
return {}; return {};
}), }),
@@ -295,12 +310,6 @@ export function createAppRPC({
return {}; return {};
}), }),
showColumnsMenu: ({ visible }) =>
wrap(async () => {
contextMenu.showColumns(visible);
return {};
}),
// ---- editor windows ---- // ---- editor windows ----
openEditor: (ctx) => openEditor: (ctx) =>
wrap(async () => { wrap(async () => {
@@ -315,6 +324,13 @@ export function createAppRPC({
return {}; return {};
}), }),
// ---- settings window ----
openSettings: () =>
wrap(async () => {
events.openSettings();
return {};
}),
showAbout: () => showAbout: () =>
wrap(async () => { wrap(async () => {
events.showAbout(); events.showAbout();
@@ -338,7 +354,7 @@ export function createAppRPC({
applyUpdate: () => applyUpdate: () =>
wrap(async () => { wrap(async () => {
await Updater.applyUpdate(); // relaunches + quits; may never resolve await events.applyUpdate(); // relaunches + quits; may never resolve
return {}; return {};
}), }),
}, },
@@ -354,6 +370,8 @@ export interface EditorRpcHooks {
onSaved: () => void; onSaved: () => void;
/** Webview resolved its dirty prompt: close without the native fallback. */ /** Webview resolved its dirty prompt: close without the native fallback. */
onCloseConfirmed: () => void; onCloseConfirmed: () => void;
/** This window saved settings (e.g. the scale shortcuts) — rebroadcast them. */
onSettingsSaved: (next: AppSettings) => void;
} }
/** /**
@@ -375,6 +393,7 @@ export function createEditorRPC(
})), })),
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })), getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
editorSave: ({ content }) => editorSave: ({ content }) =>
wrap(async () => { wrap(async () => {
@@ -404,6 +423,8 @@ export interface ManagerRpcHooks {
onConnect: (id: string) => void; onConnect: (id: string) => void;
onOpenStack: (id: string) => void; onOpenStack: (id: string) => void;
onClose: () => void; onClose: () => void;
/** This window saved settings (e.g. the scale shortcuts) — rebroadcast them. */
onSettingsSaved: (next: AppSettings) => void;
} }
/** RPC for the single connection-manager window (per-window instance). */ /** RPC for the single connection-manager window (per-window instance). */
@@ -418,6 +439,7 @@ export function createManagerRPC(
...connectionStoreHandlers(deps.store, hooks.onChanged), ...connectionStoreHandlers(deps.store, hooks.onChanged),
pickKeyFile: pickKeyFileHandler, pickKeyFile: pickKeyFileHandler,
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })), getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
managerConnect: ({ id }) => managerConnect: ({ id }) =>
wrap(async () => { wrap(async () => {
@@ -440,3 +462,28 @@ export function createManagerRPC(
}, },
}); });
} }
export interface SettingsRpcHooks {
/** Every edit here is a settings save — rebroadcast it to the other windows. */
onSettingsSaved: (next: AppSettings) => void;
showAbout: () => void;
}
/** RPC for the single Settings window (per-window instance). */
export function createSettingsRPC(deps: { settings: SettingsStore }, hooks: SettingsRpcHooks) {
return BrowserView.defineRPC<AppRPC>({
maxRequestTime: MAX_REQUEST_TIME_MS,
handlers: {
requests: {
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
pickEditorApp: pickEditorAppHandler,
showAbout: () =>
wrap(async () => {
hooks.showAbout();
return {};
}),
},
},
});
}
+91
View File
@@ -0,0 +1,91 @@
import type { BrowserWindow } from "electrobun/main";
import type { AppSettings } from "../shared/types";
import { t } from "./i18n";
import { createSettingsRPC } from "./rpc-handlers";
import type { SettingsStore } from "./store/settings";
import { openSecondaryWindow } from "./windows";
const SETTINGS_WIDTH = 680;
const SETTINGS_HEIGHT = 480;
export interface SettingsWindowDeps {
settings: SettingsStore;
/** The once-resolved dev/prod view url. */
url: string;
/** Live settings snapshot — baked into the window's preload (theme flash). */
currentSettings: () => AppSettings;
mainFrame: () => { x: number; y: number; width: number; height: number };
/** This window saved settings — rebroadcast them to every other window. */
onSettingsSaved: (settings: AppSettings) => void;
showAbout: () => void;
}
/**
* The single Settings window. Unlike the connection manager it is NOT modal:
* every edit is an ordinary settings save, so the other windows follow live
* while it stays open. On macOS ⌘W reaches bun as the main window's close-tab
* menu action, so index.ts asks isFocused() first and closes this window
* instead of a main-window tab.
*/
export class SettingsWindow {
private win: BrowserWindow | null = null;
private rpc: ReturnType<typeof createSettingsRPC> | null = null;
private focused = false;
constructor(private deps: SettingsWindowDeps) {}
isFocused(): boolean {
return this.focused;
}
open(): void {
if (this.win) {
this.win.activate();
return;
}
this.rpc = createSettingsRPC(
{ settings: this.deps.settings },
{
onSettingsSaved: (next) => this.deps.onSettingsSaved(next),
showAbout: () => this.deps.showAbout(),
},
);
const main = this.deps.mainFrame();
this.win = openSecondaryWindow({
title: t("options.title"),
url: this.deps.url,
mode: "settings",
settings: this.deps.currentSettings(),
frame: {
x: Math.max(0, main.x + (main.width - SETTINGS_WIDTH) / 2),
y: Math.max(0, main.y + (main.height - SETTINGS_HEIGHT) / 2),
width: SETTINGS_WIDTH,
height: SETTINGS_HEIGHT,
},
rpc: this.rpc,
});
this.win.on("focus", () => (this.focused = true));
this.win.on("blur", () => (this.focused = false));
this.win.on("close", () => {
this.win = null;
this.rpc = null;
this.focused = false;
});
console.log("[settings] window opened");
}
close(): void {
this.win?.close(); // the close event does the cleanup
}
/** The native title follows a language switch (the header row is the UI's own). */
retitle(): void {
this.win?.setTitle(t("options.title"));
}
broadcastSettings(settings: AppSettings): void {
this.rpc?.send.settingsChanged({ settings });
}
}
+16 -2
View File
@@ -1,8 +1,11 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { DEFAULT_SETTINGS, type AppSettings } from "../../shared/types"; import { clampUiScale, DEFAULT_SETTINGS, type AppSettings } from "../../shared/types";
import { appDataDir } from "./paths"; import { appDataDir } from "./paths";
/** The retired 3-tier axis, mapped onto the scale that replaced it. */
const LEGACY_TEXT_SIZE: Record<string, number> = { compact: 0.9, default: 1, large: 1.2 };
/** App settings persisted bun-side (WKWebView localStorage on views:// is unreliable). */ /** App settings persisted bun-side (WKWebView localStorage on views:// is unreliable). */
export class SettingsStore { export class SettingsStore {
private readonly file = path.join(appDataDir(), "settings.json"); private readonly file = path.join(appDataDir(), "settings.json");
@@ -10,7 +13,18 @@ export class SettingsStore {
async load(): Promise<AppSettings> { async load(): Promise<AppSettings> {
try { try {
const data = JSON.parse(await fs.readFile(this.file, "utf8")); const data = JSON.parse(await fs.readFile(this.file, "utf8"));
return { ...DEFAULT_SETTINGS, ...data }; const settings: AppSettings = { ...DEFAULT_SETTINGS, ...data };
// migrate the retired textSize tier into the scale that replaced it —
// but never over an explicit uiScale (a file carrying both has already
// been migrated once and then adjusted)
if (data.uiScale === undefined && typeof data.textSize === "string") {
settings.uiScale = LEGACY_TEXT_SIZE[data.textSize] ?? 1;
}
// clamp ALWAYS, not just on migration: buildPreload writes this value
// into every window's root font-size, so a hand-edited settings.json
// (uiScale: 100, "2", NaN) must not be able to render the UI unusable
settings.uiScale = clampUiScale(Number(settings.uiScale));
return settings;
} catch { } catch {
return { ...DEFAULT_SETTINGS }; return { ...DEFAULT_SETTINGS };
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import { Tray, type BrowserWindow } from "electrobun/bun"; import { Tray, type BrowserWindow } from "electrobun/main";
/** /**
* Menu-bar / notification-area icon. Clicking it brings the app window to the * Menu-bar / notification-area icon. Clicking it brings the app window to the
+31 -19
View File
@@ -1,11 +1,9 @@
import { BrowserWindow } from "electrobun/bun"; import { BrowserWindow } from "electrobun/main";
import type { AppSettings } from "../shared/types"; import type { AppSettings } from "../shared/types";
const isMac = process.platform === "darwin";
/** /**
* The preload globals every window gets. Single composition point: the UI * The preload globals every window gets. Single composition point: the UI
* reads them in ui/src/app/platform.ts before first paint (window chrome and * reads them in ui/src/app/core/platform.ts before first paint (window chrome and
* root-component selection both depend on them). * root-component selection both depend on them).
* *
* The persisted theme attributes are baked in here — mirroring the * The persisted theme attributes are baked in here — mirroring the
@@ -15,7 +13,7 @@ const isMac = process.platform === "darwin";
* windows flash the wrong background. * windows flash the wrong background.
*/ */
export function buildPreload( export function buildPreload(
mode: "main" | "editor" | "manager", mode: "main" | "editor" | "manager" | "settings",
settings: AppSettings, settings: AppSettings,
ctx?: unknown, ctx?: unknown,
): string { ): string {
@@ -27,14 +25,37 @@ export function buildPreload(
const dataset: Record<string, string> = {}; const dataset: Record<string, string> = {};
if (settings.theme !== "system") dataset["theme"] = settings.theme; if (settings.theme !== "system") dataset["theme"] = settings.theme;
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone; if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
if (settings.accent !== "graphite") dataset["accent"] = settings.accent; // interface scale: mirrors SettingsService.applyUiScale so the FIRST paint
if (settings.textSize !== "default") dataset["text"] = settings.textSize; // is already scaled — without this every window opens at 100% and snaps.
// Written as an inline style, not a data attribute: the scale is continuous.
if (settings.uiScale !== 1) {
script += `document.documentElement.style.fontSize=${JSON.stringify(`${16 * settings.uiScale}px`)};`;
}
for (const [key, value] of Object.entries(dataset)) { for (const [key, value] of Object.entries(dataset)) {
script += `document.documentElement.dataset[${JSON.stringify(key)}]=${JSON.stringify(value)};`; script += `document.documentElement.dataset[${JSON.stringify(key)}]=${JSON.stringify(value)};`;
} }
return script; return script;
} }
/**
* Native chrome for every window — the ONE place the frame is decided; the main
* window (src/bun/index.ts) and openSecondaryWindow both spread this. macOS
* fuses the titlebar with the app's own header row (hiddenInset: the traffic
* lights overlay it, which is what IS_MAC_CHROME reserves pl-24 for); other
* platforms keep the native titlebar. Constructor-only: there is no API to
* re-decorate a live window.
*
* electrobun 2.x adds trafficLightOffset to macOS's default button position
* (9/9 in the 32pt macOS 26 titlebar) and grows the titlebar to fit — 1.18.1
* clamped the shift inside the stock titlebar instead. {12, 4} puts the 14pt
* buttons at left 21 / top 13: vertically centred in the 40px h-10 header.
*/
export function chromeOptions() {
return process.platform === "darwin"
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 4 } }
: {};
}
/** /**
* Windows are created hidden and revealed on the webview's first dom-ready — * Windows are created hidden and revealed on the webview's first dom-ready —
* showing them earlier paints the native (white) surface before the themed * showing them earlier paints the native (white) surface before the themed
@@ -59,7 +80,7 @@ export interface SecondaryWindowOptions {
title: string; title: string;
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */ /** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
url: string; url: string;
mode: "editor" | "manager"; mode: "editor" | "manager" | "settings";
/** Current persisted settings — baked into the preload for flash-free paint. */ /** Current persisted settings — baked into the preload for flash-free paint. */
settings: AppSettings; settings: AppSettings;
ctx?: unknown; ctx?: unknown;
@@ -69,22 +90,13 @@ export interface SecondaryWindowOptions {
rpc: NonNullable<ConstructorParameters<typeof BrowserWindow>[0]>["rpc"]; rpc: NonNullable<ConstructorParameters<typeof BrowserWindow>[0]>["rpc"];
} }
/** /** App-chromed secondary window; its frame comes from chromeOptions. */
* App-chromed secondary window.
*
* PORTABILITY: the only spot that branches on platform for secondary-window
* chrome. macOS fuses the titlebar (traffic lights overlay the UI's h-10
* header row, same treatment as the main window); Windows/Linux keep the
* native titlebar and the UI renders a compact non-drag header instead.
*/
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow { export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
const win = new BrowserWindow({ const win = new BrowserWindow({
title: opts.title, title: opts.title,
url: opts.url, url: opts.url,
frame: opts.frame, frame: opts.frame,
...(isMac ...chromeOptions(),
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
: {}),
preload: buildPreload(opts.mode, opts.settings, opts.ctx), preload: buildPreload(opts.mode, opts.settings, opts.ctx),
rpc: opts.rpc, rpc: opts.rpc,
hidden: true, hidden: true,
+18 -16
View File
@@ -23,31 +23,27 @@ export const de = {
manageConnections: "Verbindungen verwalten", manageConnections: "Verbindungen verwalten",
newTab: "Neuer Tab ({mod}T)", newTab: "Neuer Tab ({mod}T)",
closeTab: "Tab schließen ({mod}W)", closeTab: "Tab schließen ({mod}W)",
options: "Optionen", options: "Einstellungen",
update: "Update verfügbar", update: "Update verfügbar",
updating: "Aktualisiere", updating: "Aktualisiere",
restartToUpdate: "Zum Anwenden neu starten", restartToUpdate: "Zum Anwenden neu starten",
updateTooltip: "Update verfügbar: miniSCP {version}", updateTooltip: "Update verfügbar: miniSCP {version}",
}, },
options: { options: {
title: "Optionen", title: "Einstellungen",
sections: {
appearance: "Darstellung",
transfers: "Übertragungen",
editor: "Editor",
fileList: "Dateiliste",
about: "Über",
},
columns: "Sichtbare Spalten",
language: "Sprache", language: "Sprache",
system: "System", system: "System",
theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" }, theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" },
tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" }, tone: { label: "Ton", dark: "Dunkel", darker: "AMOLED" },
accent: { label: "Akzent" }, interface: { label: "Oberfläche", smaller: "Kleiner", larger: "Größer", reset: "Auf 100% zurücksetzen" },
accentNames: {
graphite: "Graphit",
blue: "Blau",
indigo: "Indigo",
purple: "Lila",
rose: "Rosa",
orange: "Orange",
green: "Grün",
teal: "Petrol",
},
fileIcons: { label: "Datei-Symbole", plain: "Einfach", multi: "Farbig" },
interface: { label: "Oberfläche", compact: "Kompakt", default: "Normal", large: "Groß" },
speedUnit: { label: "Geschwindigkeitseinheit" }, speedUnit: { label: "Geschwindigkeitseinheit" },
transfers: { label: "Übertragungen", single: "Einzeln", multi: "Mehrere" }, transfers: { label: "Übertragungen", single: "Einzeln", multi: "Mehrere" },
atOnce: "Gleichzeitig", atOnce: "Gleichzeitig",
@@ -249,6 +245,7 @@ export const de = {
}, },
menu: { menu: {
about: "Über {app}", about: "Über {app}",
settings: "Einstellungen…",
checkForUpdates: "Nach Updates suchen…", checkForUpdates: "Nach Updates suchen…",
quit: "Beenden", quit: "Beenden",
file: "Datei", file: "Datei",
@@ -288,6 +285,11 @@ export const de = {
copyPath: "Pfad kopieren", copyPath: "Pfad kopieren",
delete: "Löschen", delete: "Löschen",
columns: "Spalten", columns: "Spalten",
tabNew: "Neuer Tab",
tabClose: "Tab schließen",
tabCloseOthers: "Andere Tabs schließen",
tabCloseRight: "Tabs rechts schließen",
tabDisconnect: "Trennen",
trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar", trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar",
}, },
errors: { errors: {
+18 -16
View File
@@ -27,31 +27,27 @@ export const en = {
manageConnections: "Manage connections", manageConnections: "Manage connections",
newTab: "New tab ({mod}T)", newTab: "New tab ({mod}T)",
closeTab: "Close tab ({mod}W)", closeTab: "Close tab ({mod}W)",
options: "Options", options: "Settings",
update: "Update available", update: "Update available",
updating: "Updating", updating: "Updating",
restartToUpdate: "Restart to apply", restartToUpdate: "Restart to apply",
updateTooltip: "Update available: miniSCP {version}", updateTooltip: "Update available: miniSCP {version}",
}, },
options: { options: {
title: "Options", title: "Settings",
sections: {
appearance: "Appearance",
transfers: "Transfers",
editor: "Editor",
fileList: "File list",
about: "About",
},
columns: "Visible columns",
language: "Language", language: "Language",
system: "System", system: "System",
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" }, theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
tone: { label: "Tone", dark: "Dark", darker: "Darker" }, tone: { label: "Tone", dark: "Dark", darker: "AMOLED" },
accent: { label: "Accent" }, interface: { label: "Interface", smaller: "Smaller", larger: "Larger", reset: "Reset to 100%" },
accentNames: {
graphite: "Graphite",
blue: "Blue",
indigo: "Indigo",
purple: "Purple",
rose: "Rose",
orange: "Orange",
green: "Green",
teal: "Teal",
},
fileIcons: { label: "File icons", plain: "Plain", multi: "Multi" },
interface: { label: "Interface", compact: "Compact", default: "Default", large: "Large" },
speedUnit: { label: "Speed unit" }, speedUnit: { label: "Speed unit" },
transfers: { label: "Transfers", single: "Single", multi: "Multi" }, transfers: { label: "Transfers", single: "Single", multi: "Multi" },
atOnce: "At once", atOnce: "At once",
@@ -253,6 +249,7 @@ export const en = {
}, },
menu: { menu: {
about: "About {app}", about: "About {app}",
settings: "Settings…",
checkForUpdates: "Check for Updates…", checkForUpdates: "Check for Updates…",
quit: "Quit", quit: "Quit",
file: "File", file: "File",
@@ -292,6 +289,11 @@ export const en = {
copyPath: "Copy Path", copyPath: "Copy Path",
delete: "Delete", delete: "Delete",
columns: "Columns", columns: "Columns",
tabNew: "New Tab",
tabClose: "Close Tab",
tabCloseOthers: "Close Other Tabs",
tabCloseRight: "Close Tabs to the Right",
tabDisconnect: "Disconnect",
trashTooltip: "Finder's Restore is not available for items trashed from here", trashTooltip: "Finder's Restore is not available for items trashed from here",
}, },
errors: { errors: {
+18 -16
View File
@@ -23,31 +23,27 @@ export const es = {
manageConnections: "Gestionar conexiones", manageConnections: "Gestionar conexiones",
newTab: "Nueva pestaña ({mod}T)", newTab: "Nueva pestaña ({mod}T)",
closeTab: "Cerrar pestaña ({mod}W)", closeTab: "Cerrar pestaña ({mod}W)",
options: "Opciones", options: "Ajustes",
update: "Actualización disponible", update: "Actualización disponible",
updating: "Actualizando", updating: "Actualizando",
restartToUpdate: "Reiniciar para aplicar", restartToUpdate: "Reiniciar para aplicar",
updateTooltip: "Actualización disponible: miniSCP {version}", updateTooltip: "Actualización disponible: miniSCP {version}",
}, },
options: { options: {
title: "Opciones", title: "Ajustes",
sections: {
appearance: "Apariencia",
transfers: "Transferencias",
editor: "Editor",
fileList: "Lista de archivos",
about: "Acerca de",
},
columns: "Columnas visibles",
language: "Idioma", language: "Idioma",
system: "Sistema", system: "Sistema",
theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" }, theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" }, tone: { label: "Tono", dark: "Oscuro", darker: "AMOLED" },
accent: { label: "Acento" }, interface: { label: "Interfaz", smaller: "Más pequeña", larger: "Más grande", reset: "Restablecer al 100%" },
accentNames: {
graphite: "Grafito",
blue: "Azul",
indigo: "Índigo",
purple: "Morado",
rose: "Rosa",
orange: "Naranja",
green: "Verde",
teal: "Turquesa",
},
fileIcons: { label: "Iconos", plain: "Simple", multi: "Color" },
interface: { label: "Interfaz", compact: "Compacta", default: "Normal", large: "Grande" },
speedUnit: { label: "Unidad de velocidad" }, speedUnit: { label: "Unidad de velocidad" },
transfers: { label: "Transferencias", single: "Una", multi: "Varias" }, transfers: { label: "Transferencias", single: "Una", multi: "Varias" },
atOnce: "A la vez", atOnce: "A la vez",
@@ -249,6 +245,7 @@ export const es = {
}, },
menu: { menu: {
about: "Acerca de {app}", about: "Acerca de {app}",
settings: "Ajustes…",
checkForUpdates: "Buscar actualizaciones…", checkForUpdates: "Buscar actualizaciones…",
quit: "Salir", quit: "Salir",
file: "Archivo", file: "Archivo",
@@ -288,6 +285,11 @@ export const es = {
copyPath: "Copiar ruta", copyPath: "Copiar ruta",
delete: "Eliminar", delete: "Eliminar",
columns: "Columnas", columns: "Columnas",
tabNew: "Nueva pestaña",
tabClose: "Cerrar pestaña",
tabCloseOthers: "Cerrar las demás pestañas",
tabCloseRight: "Cerrar pestañas a la derecha",
tabDisconnect: "Desconectar",
trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí", trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí",
}, },
errors: { errors: {
+18 -16
View File
@@ -25,31 +25,27 @@ export const eu = {
manageConnections: "Kudeatu konexioak", manageConnections: "Kudeatu konexioak",
newTab: "Fitxa berria ({mod}T)", newTab: "Fitxa berria ({mod}T)",
closeTab: "Itxi fitxa ({mod}W)", closeTab: "Itxi fitxa ({mod}W)",
options: "Aukerak", options: "Ezarpenak",
update: "Eguneratzea eskuragarri", update: "Eguneratzea eskuragarri",
updating: "Eguneratzen", updating: "Eguneratzen",
restartToUpdate: "Berrabiarazi aplikatzeko", restartToUpdate: "Berrabiarazi aplikatzeko",
updateTooltip: "Eguneratzea eskuragarri: miniSCP {version}", updateTooltip: "Eguneratzea eskuragarri: miniSCP {version}",
}, },
options: { options: {
title: "Aukerak", title: "Ezarpenak",
sections: {
appearance: "Itxura",
transfers: "Transferentziak",
editor: "Editorea",
fileList: "Fitxategi-zerrenda",
about: "Honi buruz",
},
columns: "Zutabe ikusgaiak",
language: "Hizkuntza", language: "Hizkuntza",
system: "Sistema", system: "Sistema",
theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" }, theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" },
tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" }, tone: { label: "Tonua", dark: "Iluna", darker: "AMOLED" },
accent: { label: "Azentua" }, interface: { label: "Interfazea", smaller: "Txikiagoa", larger: "Handiagoa", reset: "Berrezarri %100era" },
accentNames: {
graphite: "Grafitoa",
blue: "Urdina",
indigo: "Indigoa",
purple: "Morea",
rose: "Arrosa",
orange: "Laranja",
green: "Berdea",
teal: "Turkesa",
},
fileIcons: { label: "Fitxategi-ikonoak", plain: "Soila", multi: "Koloretsua" },
interface: { label: "Interfazea", compact: "Trinkoa", default: "Lehenetsia", large: "Handia" },
speedUnit: { label: "Abiadura-unitatea" }, speedUnit: { label: "Abiadura-unitatea" },
transfers: { label: "Transferentziak", single: "Banaka", multi: "Anitz" }, transfers: { label: "Transferentziak", single: "Banaka", multi: "Anitz" },
atOnce: "Aldi berean", atOnce: "Aldi berean",
@@ -251,6 +247,7 @@ export const eu = {
}, },
menu: { menu: {
about: "{app} (r)i buruz", about: "{app} (r)i buruz",
settings: "Ezarpenak…",
checkForUpdates: "Bilatu eguneratzeak…", checkForUpdates: "Bilatu eguneratzeak…",
quit: "Irten", quit: "Irten",
file: "Fitxategia", file: "Fitxategia",
@@ -290,6 +287,11 @@ export const eu = {
copyPath: "Kopiatu bidea", copyPath: "Kopiatu bidea",
delete: "Ezabatu", delete: "Ezabatu",
columns: "Zutabeak", columns: "Zutabeak",
tabNew: "Fitxa berria",
tabClose: "Itxi fitxa",
tabCloseOthers: "Itxi beste fitxak",
tabCloseRight: "Itxi eskuineko fitxak",
tabDisconnect: "Deskonektatu",
trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat", trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat",
}, },
errors: { errors: {
+18 -16
View File
@@ -23,31 +23,27 @@ export const fr = {
manageConnections: "Gérer les connexions", manageConnections: "Gérer les connexions",
newTab: "Nouvel onglet ({mod}T)", newTab: "Nouvel onglet ({mod}T)",
closeTab: "Fermer l'onglet ({mod}W)", closeTab: "Fermer l'onglet ({mod}W)",
options: "Options", options: "Réglages",
update: "Mise à jour disponible", update: "Mise à jour disponible",
updating: "Mise à jour", updating: "Mise à jour",
restartToUpdate: "Redémarrer pour appliquer", restartToUpdate: "Redémarrer pour appliquer",
updateTooltip: "Mise à jour disponible : miniSCP {version}", updateTooltip: "Mise à jour disponible : miniSCP {version}",
}, },
options: { options: {
title: "Options", title: "Réglages",
sections: {
appearance: "Apparence",
transfers: "Transferts",
editor: "Éditeur",
fileList: "Liste des fichiers",
about: "À propos",
},
columns: "Colonnes visibles",
language: "Langue", language: "Langue",
system: "Système", system: "Système",
theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" }, theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" }, tone: { label: "Ton", dark: "Sombre", darker: "AMOLED" },
accent: { label: "Accent" }, interface: { label: "Interface", smaller: "Plus petite", larger: "Plus grande", reset: "Réinitialiser à 100%" },
accentNames: {
graphite: "Graphite",
blue: "Bleu",
indigo: "Indigo",
purple: "Violet",
rose: "Rose",
orange: "Orange",
green: "Vert",
teal: "Sarcelle",
},
fileIcons: { label: "Icônes", plain: "Simple", multi: "Couleur" },
interface: { label: "Interface", compact: "Compacte", default: "Normale", large: "Grande" },
speedUnit: { label: "Unité de vitesse" }, speedUnit: { label: "Unité de vitesse" },
transfers: { label: "Transferts", single: "Un", multi: "Plusieurs" }, transfers: { label: "Transferts", single: "Un", multi: "Plusieurs" },
atOnce: "Simultanés", atOnce: "Simultanés",
@@ -249,6 +245,7 @@ export const fr = {
}, },
menu: { menu: {
about: "À propos de {app}", about: "À propos de {app}",
settings: "Réglages…",
checkForUpdates: "Rechercher des mises à jour…", checkForUpdates: "Rechercher des mises à jour…",
quit: "Quitter", quit: "Quitter",
file: "Fichier", file: "Fichier",
@@ -288,6 +285,11 @@ export const fr = {
copyPath: "Copier le chemin", copyPath: "Copier le chemin",
delete: "Supprimer", delete: "Supprimer",
columns: "Colonnes", columns: "Colonnes",
tabNew: "Nouvel onglet",
tabClose: "Fermer l'onglet",
tabCloseOthers: "Fermer les autres onglets",
tabCloseRight: "Fermer les onglets à droite",
tabDisconnect: "Déconnecter",
trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici", trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici",
}, },
errors: { errors: {
+18 -16
View File
@@ -24,31 +24,27 @@ export const ja = {
manageConnections: "接続を管理", manageConnections: "接続を管理",
newTab: "新しいタブ ({mod}T)", newTab: "新しいタブ ({mod}T)",
closeTab: "タブを閉じる ({mod}W)", closeTab: "タブを閉じる ({mod}W)",
options: "オプション", options: "設定",
update: "アップデートあり", update: "アップデートあり",
updating: "更新中", updating: "更新中",
restartToUpdate: "再起動して適用", restartToUpdate: "再起動して適用",
updateTooltip: "アップデートがあります: miniSCP {version}", updateTooltip: "アップデートがあります: miniSCP {version}",
}, },
options: { options: {
title: "オプション", title: "設定",
sections: {
appearance: "外観",
transfers: "転送",
editor: "エディタ",
fileList: "ファイル一覧",
about: "情報",
},
columns: "表示する列",
language: "言語", language: "言語",
system: "システム", system: "システム",
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" }, theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
tone: { label: "トーン", dark: "ダーク", darker: "より暗く" }, tone: { label: "トーン", dark: "ダーク", darker: "AMOLED" },
accent: { label: "アクセント" }, interface: { label: "インターフェース", smaller: "縮小", larger: "拡大", reset: "100%に戻す" },
accentNames: {
graphite: "グラファイト",
blue: "ブルー",
indigo: "インディゴ",
purple: "パープル",
rose: "ローズ",
orange: "オレンジ",
green: "グリーン",
teal: "ティール",
},
fileIcons: { label: "ファイルアイコン", plain: "シンプル", multi: "カラー" },
interface: { label: "インターフェース", compact: "コンパクト", default: "標準", large: "大" },
speedUnit: { label: "速度の単位" }, speedUnit: { label: "速度の単位" },
transfers: { label: "転送", single: "1件ずつ", multi: "複数" }, transfers: { label: "転送", single: "1件ずつ", multi: "複数" },
atOnce: "同時数", atOnce: "同時数",
@@ -249,6 +245,7 @@ export const ja = {
}, },
menu: { menu: {
about: "{app} について", about: "{app} について",
settings: "設定…",
checkForUpdates: "アップデートを確認…", checkForUpdates: "アップデートを確認…",
quit: "終了", quit: "終了",
file: "ファイル", file: "ファイル",
@@ -288,6 +285,11 @@ export const ja = {
copyPath: "パスをコピー", copyPath: "パスをコピー",
delete: "削除", delete: "削除",
columns: "列", columns: "列",
tabNew: "新しいタブ",
tabClose: "タブを閉じる",
tabCloseOthers: "他のタブを閉じる",
tabCloseRight: "右側のタブを閉じる",
tabDisconnect: "切断",
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません", trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません",
}, },
errors: { errors: {
+18 -16
View File
@@ -24,31 +24,27 @@ export const zh = {
manageConnections: "管理连接", manageConnections: "管理连接",
newTab: "新建标签页 ({mod}T)", newTab: "新建标签页 ({mod}T)",
closeTab: "关闭标签页 ({mod}W)", closeTab: "关闭标签页 ({mod}W)",
options: "选项", options: "设置",
update: "有可用更新", update: "有可用更新",
updating: "正在更新", updating: "正在更新",
restartToUpdate: "重启以应用", restartToUpdate: "重启以应用",
updateTooltip: "有可用更新:miniSCP {version}", updateTooltip: "有可用更新:miniSCP {version}",
}, },
options: { options: {
title: "选项", title: "设置",
sections: {
appearance: "外观",
transfers: "传输",
editor: "编辑器",
fileList: "文件列表",
about: "关于",
},
columns: "显示的列",
language: "语言", language: "语言",
system: "系统", system: "系统",
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" }, theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
tone: { label: "色调", dark: "深色", darker: "更深" }, tone: { label: "色调", dark: "深色", darker: "AMOLED" },
accent: { label: "强调色" }, interface: { label: "界面", smaller: "缩小", larger: "放大", reset: "重置为100%" },
accentNames: {
graphite: "石墨",
blue: "蓝色",
indigo: "靛蓝",
purple: "紫色",
rose: "玫红",
orange: "橙色",
green: "绿色",
teal: "青色",
},
fileIcons: { label: "文件图标", plain: "简约", multi: "彩色" },
interface: { label: "界面", compact: "紧凑", default: "默认", large: "大" },
speedUnit: { label: "速度单位" }, speedUnit: { label: "速度单位" },
transfers: { label: "传输", single: "单个", multi: "多个" }, transfers: { label: "传输", single: "单个", multi: "多个" },
atOnce: "同时", atOnce: "同时",
@@ -249,6 +245,7 @@ export const zh = {
}, },
menu: { menu: {
about: "关于 {app}", about: "关于 {app}",
settings: "设置…",
checkForUpdates: "检查更新…", checkForUpdates: "检查更新…",
quit: "退出", quit: "退出",
file: "文件", file: "文件",
@@ -288,6 +285,11 @@ export const zh = {
copyPath: "复制路径", copyPath: "复制路径",
delete: "删除", delete: "删除",
columns: "列", columns: "列",
tabNew: "新建标签页",
tabClose: "关闭标签页",
tabCloseOthers: "关闭其他标签页",
tabCloseRight: "关闭右侧标签页",
tabDisconnect: "断开连接",
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能", trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
}, },
errors: { errors: {
+15 -15
View File
@@ -1,6 +1,5 @@
import type { import type {
AppSettings, AppSettings,
ColumnKey,
ConnectionConfig, ConnectionConfig,
ConnectionsFile, ConnectionsFile,
EntryType, EntryType,
@@ -27,9 +26,9 @@ export interface EditorOpenContext {
// bun.requests = calls the webview makes, handled in src/bun/rpc-handlers.ts // bun.requests = calls the webview makes, handled in src/bun/rpc-handlers.ts
// webview.messages = push events from bun, handled in ui rpc.service.ts // webview.messages = push events from bun, handled in ui rpc.service.ts
// //
// ONE schema serves both window kinds (main + editor): electrobun allows // ONE schema serves every window kind (main, editor, manager, settings):
// partial handler sets, and messages nobody listens to are silently dropped, // electrobun allows partial handler sets, and messages nobody listens to are
// so each side only implements/hears what applies to it. // silently dropped, so each side only implements/hears what applies to it.
export type AppRPC = { export type AppRPC = {
bun: { bun: {
requests: { requests: {
@@ -126,20 +125,18 @@ export type AppRPC = {
response: Result<{ names: string[] }>; response: Result<{ names: string[] }>;
}; };
clearEditLeftovers: { params: Record<string, never>; response: Result }; clearEditLeftovers: { params: Record<string, never>; response: Result };
// context menu + local shell integration // local shell integration
showRowMenu: { /**
params: { * Shell actions the context menu triggers on a LOCAL path. The menu
path: string; * itself is HTML (ui/src/app/core/context-menu.service.ts) — electrobun's native
name: string; * context menu is a no-op on Linux — but these three still need the
type: EntryType; * bun side's Utils.
isLocal: boolean; */
hasEntry: boolean; shellItemAction: {
}; params: { action: "reveal" | "open" | "copyPath"; path: string };
response: Result; response: Result;
}; };
moveToTrash: { params: { path: string }; response: Result }; moveToTrash: { params: { path: string }; response: Result };
/** Native column-picker menu (right-click on the list headers). */
showColumnsMenu: { params: { visible: ColumnKey[] }; response: Result };
/** Native About box (name, Build n, commit, engine versions). */ /** Native About box (name, Build n, commit, engine versions). */
showAbout: { params: Record<string, never>; response: Result }; showAbout: { params: Record<string, never>; response: Result };
/** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */ /** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */
@@ -157,6 +154,9 @@ export type AppRPC = {
managerConnect: { params: { id: string }; response: Result }; managerConnect: { params: { id: string }; response: Result };
managerOpenStack: { params: { id: string }; response: Result }; managerOpenStack: { params: { id: string }; response: Result };
managerClose: { params: Record<string, never>; response: Result }; managerClose: { params: Record<string, never>; response: Result };
// settings window
/** Main window: open (or focus) the single Settings window. */
openSettings: { params: Record<string, never>; response: Result };
// auto-updater (electrobun Updater) // auto-updater (electrobun Updater)
/** Main window: re-run the update check on demand (menu item). */ /** Main window: re-run the update check on demand (menu item). */
checkForUpdate: { checkForUpdate: {
+25 -19
View File
@@ -65,31 +65,22 @@ export interface ConnectionsFile {
/** UI languages the app ships translations for. */ /** UI languages the app ships translations for. */
export type SupportedLocale = "en" | "es" | "eu" | "fr" | "de" | "zh" | "ja"; export type SupportedLocale = "en" | "es" | "eu" | "fr" | "de" | "zh" | "ja";
export type AccentTone =
| "graphite"
| "blue"
| "indigo"
| "purple"
| "rose"
| "orange"
| "green"
| "teal";
export interface AppSettings { export interface AppSettings {
/** UI language; "system" resolves to the OS locale (fallback English). */ /** UI language; "system" resolves to the OS locale (fallback English). */
locale: "system" | SupportedLocale; locale: "system" | SupportedLocale;
theme: "system" | "light" | "dark"; theme: "system" | "light" | "dark";
/** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */ /** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */
darkTone: "dark" | "darker"; darkTone: "dark" | "darker";
/** Accent tone for chrome and selection tint; graphite = neutral zinc. */
accent: AccentTone;
/** File-list icons: plain neutral glyphs, or per-category glyphs and colors. */
iconColors: "plain" | "multi";
/** Visible optional file-list columns (both panels; Name always shows). */ /** Visible optional file-list columns (both panels; Name always shows). */
columns: ColumnKey[]; columns: ColumnKey[];
speedUnit: "MBps" | "Mbps"; speedUnit: "MBps" | "Mbps";
/** Size of the panel interface: file list, path bar, header controls. */ /**
textSize: "compact" | "default" | "large"; * Interface scale factor, 0.5–4 in 0.1 steps (shown as 50%–400%). Works by
* setting the root font-size — every size in the UI is rem, so one lever
* scales the whole UI. Replaced the 3-tier `textSize`
* (compact|default|large); SettingsStore.load migrates old files.
*/
uiScale: number;
/** Run transfer jobs one at a time, or several in parallel. */ /** Run transfer jobs one at a time, or several in parallel. */
transferMode: "single" | "multi"; transferMode: "single" | "multi";
/** How many jobs run at once when transferMode is "multi". */ /** How many jobs run at once when transferMode is "multi". */
@@ -110,11 +101,9 @@ export const DEFAULT_SETTINGS: AppSettings = {
locale: "system", locale: "system",
theme: "system", theme: "system",
darkTone: "dark", darkTone: "dark",
accent: "blue",
iconColors: "multi",
columns: ["size", "mtime"], columns: ["size", "mtime"],
speedUnit: "MBps", speedUnit: "MBps",
textSize: "default", uiScale: 1,
transferMode: "single", transferMode: "single",
transferConcurrency: 3, transferConcurrency: 3,
autoClearCompleted: true, autoClearCompleted: true,
@@ -123,6 +112,23 @@ export const DEFAULT_SETTINGS: AppSettings = {
// autoRefresh: true, // autoRefresh: true,
}; };
export const UI_SCALE_MIN = 0.5;
export const UI_SCALE_MAX = 4;
export const UI_SCALE_STEP = 0.1;
/**
* Clamp + snap a scale factor to the legal range and step grid. Shared by the
* store (a hand-edited settings.json must not be able to render the UI
* unusable), the settings service and the stepper — one definition of "valid",
* same as effectiveConcurrency below.
*/
export function clampUiScale(value: number): number {
if (!Number.isFinite(value)) return 1;
const clamped = Math.min(UI_SCALE_MAX, Math.max(UI_SCALE_MIN, value));
// snap to the step grid; toFixed strips float noise (0.7000000000000001 -> 0.7)
return Number((Math.round(clamped / UI_SCALE_STEP) * UI_SCALE_STEP).toFixed(2));
}
export function effectiveConcurrency(settings: AppSettings): number { export function effectiveConcurrency(settings: AppSettings): number {
if (settings.transferMode === "single") return 1; if (settings.transferMode === "single") return 1;
return Math.max(1, Math.min(8, Math.round(settings.transferConcurrency) || 1)); return Math.max(1, Math.min(8, Math.round(settings.transferConcurrency) || 1));
-3
View File
@@ -1,3 +0,0 @@
// electrobun ships raw .ts sources whose WGPU adapter imports "three" without
// bundled types; we never use that path, so give tsc an any-typed stand-in.
declare module "three";
+2 -1
View File
@@ -1,4 +1,5 @@
{ {
"extends": "./.hutch/devkit/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"useDefineForClassFields": false, "useDefineForClassFields": false,
@@ -16,6 +17,6 @@
"emitDecoratorMetadata": false, "emitDecoratorMetadata": false,
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["src"], "include": ["src", "electrobun.config.ts"],
"exclude": ["node_modules", "dist", "build", "../../package/dist"] "exclude": ["node_modules", "dist", "build", "../../package/dist"]
} }
+1 -1
View File
@@ -14,7 +14,7 @@
"@angular/core": "^22.0.0", "@angular/core": "^22.0.0",
"@angular/platform-browser": "^22.0.0", "@angular/platform-browser": "^22.0.0",
"@ng-icons/core": "^34.0.0", "@ng-icons/core": "^34.0.0",
"@ng-icons/mynaui": "^34.0.0", "@ng-icons/tdesign-icons": "^34.0.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
+177
View File
@@ -0,0 +1,177 @@
import { Injectable, inject, signal } from '@angular/core';
import type { ColumnKey, EntryType } from '@shared/types';
import { I18nService } from './i18n.service';
import { IS_MAC, IS_WINDOWS, MOD_LABEL } from './platform';
import { RpcService } from './rpc.service';
/** Optional list columns, in display order (Name is fixed). */
export const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group'];
export interface MenuItem {
type?: 'separator';
label?: string;
/** Emitted verbatim to the action dispatcher (`ctx-*` / `col:*` / `tab:*`). */
action?: string;
checked?: boolean;
tooltip?: string;
/** Shortcut hint, rendered right-aligned. */
accelerator?: string;
/** Greyed and unclickable. Menus disable rather than hide, so the set of
* commands stays in the same place from one right-click to the next. */
disabled?: boolean;
}
export interface MenuRequest {
x: number;
y: number;
items: MenuItem[];
}
export interface TabMenuContext {
/** The right-clicked tab (NOT necessarily the active one). */
id: number;
/** Position in the strip, for "close to the right". */
index: number;
tabCount: number;
/** Any pane in this tab holds a live remote session. */
isRemote: boolean;
}
export interface RowMenuContext {
path: string;
name: string;
type: EntryType;
isLocal: boolean;
hasEntry: boolean; // false = right-click on empty panel space
}
// PORTABILITY: shell-integration wording per OS (resolved at build time for locale)
const revealKey = (): string =>
IS_MAC ? 'ctx.revealFinder' : IS_WINDOWS ? 'ctx.revealExplorer' : 'ctx.revealFileManager';
const trashKey = (): string => (IS_WINDOWS ? 'ctx.moveToRecycleBin' : 'ctx.moveToTrash');
/**
* Builds and opens the panel right-click menus. This used to be a native
* electrobun ContextMenu on the bun side, but `showContextMenu` is a documented
* no-op on Linux (electrobun's own .hutch/devkit/api/sdks/main/proc/linux.md),
* which left the whole right-click surface dead there. The menu is plain HTML
* now — one code path on every platform — and still emits the exact same
* action strings, so the dispatcher in app.ts is unchanged.
*/
@Injectable({ providedIn: 'root' })
export class ContextMenuService {
private readonly i18n = inject(I18nService);
private readonly rpc = inject(RpcService);
readonly request = signal<MenuRequest | null>(null);
open(event: MouseEvent, items: MenuItem[]): void {
this.request.set({ x: event.clientX, y: event.clientY, items });
}
close(): void {
this.request.set(null);
}
/** A picked item goes through the same dispatcher the app menubar uses. */
onPick(action: string): void {
this.rpc.emitMenuAction(action);
}
/** Row / empty-space menu for a file panel. */
rowItems(ctx: RowMenuContext): MenuItem[] {
const t = (key: string) => this.i18n.t(key);
const items: MenuItem[] = [];
if (!ctx.hasEntry || ctx.name === '..') {
// empty space (or the parent row): folder-level actions
items.push({ label: t('ctx.newFolder'), action: 'ctx-new-folder' });
items.push({ label: t('ctx.newFile'), action: 'ctx-new-file' });
items.push({ type: 'separator' });
items.push({ label: t('ctx.refresh'), action: 'ctx-refresh' });
if (ctx.isLocal) {
items.push({ type: 'separator' });
items.push({ label: t(revealKey()), action: 'ctx-reveal' });
}
return items;
}
items.push({ label: t('ctx.copyToOther'), action: 'ctx-copy' });
if (ctx.type === 'dir') {
items.push({ label: t('ctx.open'), action: 'ctx-open' });
} else {
if (ctx.isLocal) {
items.push({ label: t('ctx.open'), action: 'ctx-open-default', tooltip: t('ctx.openDefault') });
}
items.push({ label: t('ctx.edit'), action: 'ctx-edit', accelerator: 'F4' });
}
items.push({ label: t('ctx.rename'), action: 'ctx-rename' });
// chmod on Windows local files only flips the read-only bit — hide the lie
if (!(ctx.isLocal && IS_WINDOWS)) {
items.push({ label: t('ctx.permissions'), action: 'ctx-chmod' });
}
items.push({ type: 'separator' });
items.push({ label: t('ctx.copyPath'), action: 'ctx-copy-path' });
if (ctx.isLocal) {
items.push({ label: t(revealKey()), action: 'ctx-reveal' });
}
items.push({ type: 'separator' });
if (ctx.isLocal) {
items.push({
label: t(trashKey()),
action: 'ctx-trash',
...(IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}),
});
}
items.push({ label: t('ctx.delete'), action: 'ctx-delete' });
return items;
}
/**
* The workspace-tab menu. Actions carry the target tab's id, because the
* right-clicked tab is not necessarily the active one — nothing here may
* assume `activeWorkspace`.
*
* Everything illegal is DISABLED, not omitted: a menu whose items move
* between right-clicks is worse than a dead row, and "Close Tab" greyed out explains
* why the last tab cannot be closed better than its absence does.
*
* Duplicate/Rename/Save-session are deliberately absent until tabs have an
* identity to duplicate (a stored connection id) — the sessions round.
*/
tabItems(ctx: TabMenuContext): MenuItem[] {
const t = (key: string) => this.i18n.t(key);
const last = ctx.tabCount <= 1;
return [
{ label: t('ctx.tabNew'), action: 'tab:new', accelerator: `${MOD_LABEL}T` },
{ type: 'separator' },
{
label: t('ctx.tabClose'),
action: `tab:close:${ctx.id}`,
accelerator: `${MOD_LABEL}W`,
disabled: last,
},
{ label: t('ctx.tabCloseOthers'), action: `tab:close-others:${ctx.id}`, disabled: last },
{
label: t('ctx.tabCloseRight'),
action: `tab:close-right:${ctx.id}`,
disabled: ctx.index >= ctx.tabCount - 1,
},
{ type: 'separator' },
{
label: t('ctx.tabDisconnect'),
action: `tab:disconnect:${ctx.id}`,
disabled: !ctx.isRemote,
},
];
}
/** Column picker for the list headers: checkable toggles per column. */
columnItems(visible: ColumnKey[]): MenuItem[] {
return COLUMN_KEYS.map((key) => ({
label: this.i18n.t(`columns.${key}`),
action: `col:${key}`,
checked: visible.includes(key),
}));
}
}
@@ -1,5 +1,6 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import type { FileEntry } from '@shared/types'; import type { FileEntry } from '@shared/types';
import { ContextMenuService } from './context-menu.service';
import { DialogsService } from './dialogs.service'; import { DialogsService } from './dialogs.service';
import { joinPath } from './format'; import { joinPath } from './format';
import { type PanelState, type Side } from './panels.service'; import { type PanelState, type Side } from './panels.service';
@@ -26,21 +27,25 @@ export class FileActionsService {
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService); private readonly toast = inject(ToastService);
private readonly dialogs = inject(DialogsService); private readonly dialogs = inject(DialogsService);
private readonly menu = inject(ContextMenuService);
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
menuTarget: MenuTarget | null = null; menuTarget: MenuTarget | null = null;
/** Right-click entry point: remember the target and ask bun for a native menu. */ /** Right-click entry point: remember the target, then open the menu on it. */
async openContextMenu(target: MenuTarget): Promise<void> { openContextMenu(event: MouseEvent, target: MenuTarget): void {
this.menuTarget = target; this.menuTarget = target;
const { panel, entry } = target; const { panel, entry } = target;
await this.rpc.request.showRowMenu({ this.menu.open(
path: entry?.path ?? panel.cwd(), event,
name: entry?.name ?? '', this.menu.rowItems({
type: entry?.type ?? 'dir', path: entry?.path ?? panel.cwd(),
isLocal: panel.sessionId() === 'local', name: entry?.name ?? '',
hasEntry: !!entry, type: entry?.type ?? 'dir',
}); isLocal: panel.sessionId() === 'local',
hasEntry: !!entry,
}),
);
} }
async trash(panel: PanelState, entry: FileEntry): Promise<void> { async trash(panel: PanelState, entry: FileEntry): Promise<void> {
@@ -1,4 +1,4 @@
// File-list glyphs + per-category colors ("Multi" icon mode). Category colors // File-list glyphs + per-category colors. Category colors
// are fixed tokens (never follow the accent) so a folder is always folder-blue. // are fixed tokens (never follow the accent) so a folder is always folder-blue.
// Only the icon glyph is colored; row text stays neutral. Docs and unknown // Only the icon glyph is colored; row text stays neutral. Docs and unknown
// files stay muted on purpose — sparing color is what keeps it scannable. // files stay muted on purpose — sparing color is what keeps it scannable.
@@ -8,33 +8,32 @@ type Category = 'image' | 'audio' | 'video' | 'archive' | 'code' | 'doc';
// Class strings are literals so the Tailwind scanner picks them up. // Class strings are literals so the Tailwind scanner picks them up.
const MUTED = 'shrink-0 text-muted-foreground'; const MUTED = 'shrink-0 text-muted-foreground';
const PRIMARY = 'shrink-0 text-primary';
const FOLDER = 'shrink-0 text-icon-folder'; const FOLDER = 'shrink-0 text-icon-folder';
const LINK = 'shrink-0 text-icon-link'; const LINK = 'shrink-0 text-icon-link';
const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: string[] }> = { const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: string[] }> = {
image: { image: {
glyph: 'mynaImage', glyph: 'tdesignImage',
class: 'shrink-0 text-icon-image', class: 'shrink-0 text-icon-image',
extensions: 'png jpg jpeg gif webp avif svg ico icns bmp tiff tif heic heif raw'.split(' '), extensions: 'png jpg jpeg gif webp avif svg ico icns bmp tiff tif heic heif raw'.split(' '),
}, },
audio: { audio: {
glyph: 'mynaMusic', glyph: 'tdesignMusic',
class: 'shrink-0 text-icon-media', class: 'shrink-0 text-icon-media',
extensions: 'mp3 wav flac aac ogg oga m4a opus wma aiff aif mid midi'.split(' '), extensions: 'mp3 wav flac aac ogg oga m4a opus wma aiff aif mid midi'.split(' '),
}, },
video: { video: {
glyph: 'mynaVideo', glyph: 'tdesignVideo',
class: 'shrink-0 text-icon-media', class: 'shrink-0 text-icon-media',
extensions: 'mp4 mkv mov avi webm m4v mpg mpeg wmv flv 3gp'.split(' '), extensions: 'mp4 mkv mov avi webm m4v mpg mpeg wmv flv 3gp'.split(' '),
}, },
archive: { archive: {
glyph: 'mynaArchive', glyph: 'tdesignFileZip',
class: 'shrink-0 text-icon-archive', class: 'shrink-0 text-icon-archive',
extensions: 'zip tar gz tgz bz2 xz zst 7z rar dmg iso jar deb rpm pkg apk'.split(' '), extensions: 'zip tar gz tgz bz2 xz zst 7z rar dmg iso jar deb rpm pkg apk'.split(' '),
}, },
code: { code: {
glyph: 'mynaCode', glyph: 'tdesignCode',
class: 'shrink-0 text-icon-code', class: 'shrink-0 text-icon-code',
// ".ts" is TypeScript here, not an MPEG transport stream (deliberate) // ".ts" is TypeScript here, not an MPEG transport stream (deliberate)
extensions: ( extensions: (
@@ -46,7 +45,7 @@ const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: s
).split(' '), ).split(' '),
}, },
doc: { doc: {
glyph: 'mynaFileText', glyph: 'tdesignFile1',
class: MUTED, // docs keep the neutral color; only the glyph changes class: MUTED, // docs keep the neutral color; only the glyph changes
extensions: 'md markdown txt rtf pdf doc docx xls xlsx ppt pptx csv tsv log odt ods odp epub'.split(' '), extensions: 'md markdown txt rtf pdf doc docx xls xlsx ppt pptx csv tsv log odt ods odp epub'.split(' '),
}, },
@@ -85,18 +84,15 @@ export function fileKindKey(entry: FileEntry): string {
return category ? KIND_KEYS[category] : 'file'; return category ? KIND_KEYS[category] : 'file';
} }
export function fileGlyph(entry: FileEntry, multi: boolean): string { export function fileGlyph(entry: FileEntry): string {
if (entry.name === '..') return 'mynaArrowUp'; if (entry.name === '..') return 'tdesignArrowUp';
if (entry.type === 'dir') return 'mynaFolder'; if (entry.type === 'dir') return 'tdesignFolder';
if (!multi) return 'mynaFile'; if (entry.type === 'symlink') return 'tdesignLink';
if (entry.type === 'symlink') return 'mynaLink';
const category = categoryOf(entry.name); const category = categoryOf(entry.name);
return category ? CATEGORIES[category].glyph : 'mynaFile'; return category ? CATEGORIES[category].glyph : 'tdesignFile';
} }
export function fileIconClass(entry: FileEntry, multi: boolean): string { export function fileIconClass(entry: FileEntry): string {
// plain mode: exactly the pre-color look ('..' has type 'dir' -> primary)
if (!multi) return entry.type === 'dir' ? PRIMARY : MUTED;
if (entry.name === '..') return MUTED; if (entry.name === '..') return MUTED;
if (entry.type === 'dir') return FOLDER; if (entry.type === 'dir') return FOLDER;
if (entry.type === 'symlink') return LINK; if (entry.type === 'symlink') return LINK;
+73
View File
@@ -0,0 +1,73 @@
// Single icon registry for the whole app, registered once in app.config.ts.
// TDesign's package root exports every icon; named imports tree-shake. Names are
// plain strings in templates — the build never checks them, so every name used
// anywhere must be listed here (an unregistered one renders nothing).
import {
tdesignAdd,
tdesignArrowLeft,
tdesignArrowRight,
tdesignArrowUp,
tdesignCheck,
tdesignCheckCircle,
tdesignChevronDown,
tdesignChevronRight,
tdesignChevronUp,
tdesignClose,
tdesignCloseCircle,
tdesignCode,
tdesignDelete,
tdesignDownload,
tdesignEdit,
tdesignFile,
tdesignFile1,
tdesignFileAdd,
tdesignFileZip,
tdesignFolder,
tdesignFolderAdd,
tdesignHardDrive,
tdesignImage,
tdesignLink,
tdesignLoading,
tdesignLockOn,
tdesignMusic,
tdesignRefresh,
tdesignSearch,
tdesignServer,
tdesignSetting,
tdesignVideo,
} from "@ng-icons/tdesign-icons";
export const APP_ICONS = {
tdesignAdd,
tdesignArrowLeft,
tdesignArrowRight,
tdesignArrowUp,
tdesignCheck,
tdesignCheckCircle,
tdesignChevronDown,
tdesignChevronRight,
tdesignChevronUp,
tdesignClose,
tdesignCloseCircle,
tdesignCode,
tdesignDelete,
tdesignDownload,
tdesignEdit,
tdesignFile,
tdesignFile1,
tdesignFileAdd,
tdesignFileZip,
tdesignFolder,
tdesignFolderAdd,
tdesignHardDrive,
tdesignImage,
tdesignLink,
tdesignLoading,
tdesignLockOn,
tdesignMusic,
tdesignRefresh,
tdesignSearch,
tdesignServer,
tdesignSetting,
tdesignVideo,
};
+27
View File
@@ -0,0 +1,27 @@
import { expect, test } from 'bun:test';
import { stepIndex } from './menu-nav';
// item, separator, item, disabled, item — the shape of a real row menu
const menu = [true, false, true, false, true];
test('a fresh menu: down lands on the first entry, up on the last', () => {
expect(stepIndex(menu, -1, 1)).toBe(0);
expect(stepIndex(menu, -1, -1)).toBe(4);
});
test('separators and disabled entries are skipped', () => {
expect(stepIndex(menu, 0, 1)).toBe(2);
expect(stepIndex(menu, 2, 1)).toBe(4);
expect(stepIndex(menu, 4, -1)).toBe(2);
});
test('wraps both ways', () => {
expect(stepIndex(menu, 4, 1)).toBe(0);
expect(stepIndex(menu, 0, -1)).toBe(4);
});
test('nothing selectable leaves the highlight alone', () => {
expect(stepIndex([false, false], -1, 1)).toBe(-1);
expect(stepIndex([false, false], -1, -1)).toBe(-1);
expect(stepIndex([], -1, -1)).toBe(-1);
});
+15
View File
@@ -0,0 +1,15 @@
/**
* Next highlighted menu index: skips unselectable entries and wraps. From -1
* (nothing highlighted yet), down lands on the first selectable entry and up
* on the LAST — stepping up from -1 directly would land one short of it.
* Returns `from` unchanged when nothing is selectable.
*/
export function stepIndex(selectable: boolean[], from: number, delta: 1 | -1): number {
const count = selectable.length;
let index = from < 0 && delta < 0 ? 0 : from;
for (let hops = 0; hops < count; hops++) {
index = (index + delta + count) % count;
if (selectable[index]) return index;
}
return from;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Rem-to-pixel conversion at the CURRENT root font size.
*
* The interface scale (settings.uiScale) works by setting the root font-size,
* so any TypeScript that positions or measures against rem-sized CSS must
* convert through the LIVE root — never through an assumed 16. Both context
* menus' flip/clamp math is the canonical consumer: their widths and row
* heights are rem in CSS, and hardcoding their px mirrors is exactly the bug
* that made the shadlike menu flip at the wrong edge once scaling landed.
*
* Read per call, not cached: the scale can change mid-session (stepper,
* Ctrl+/-) and a menu opened after that must measure against the new root.
*/
export function remPx(rem: number): number {
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
}
@@ -398,6 +398,46 @@ export class PanelsService {
this.refocus(); this.refocus();
} }
/**
* Close every tab except one — the tab menu's "Close Other Tabs".
*
* Snapshot the ids FIRST: closeWorkspace mutates the list on every call, so
* iterating the live signal would skip half of them. It also refuses the
* last tab, which is exactly the wanted floor here.
*/
closeOtherWorkspaces(id: number): void {
for (const other of this.workspaces().map((w) => w.id)) {
if (other !== id) this.closeWorkspace(other);
}
this.setActiveWorkspace(id);
}
/** Close every tab to the right of one, in visual order. */
closeWorkspacesToRight(id: number): void {
const list = this.workspaces();
const index = list.findIndex((w) => w.id === id);
if (index < 0) return;
for (const target of list.slice(index + 1).map((w) => w.id)) {
this.closeWorkspace(target);
}
}
/**
* Drop both of a tab's remote sessions, keeping the tab itself. Unlike
* `disconnectPanel` this targets a tab by id, because the right-clicked tab
* is not necessarily the active one.
*/
async disconnectWorkspace(id: number): Promise<void> {
const workspace = this.workspaces().find((w) => w.id === id);
if (!workspace) return;
for (const panel of [workspace.left, workspace.right]) {
const sessionId = panel.sessionId();
if (sessionId === 'local') continue;
await this.rpc.request.disconnect({ sessionId });
this.resetToLocal(panel);
}
}
setActiveWorkspace(id: number): void { setActiveWorkspace(id: number): void {
if (this.activeWorkspaceId() !== id) { if (this.activeWorkspaceId() !== id) {
this.activeWorkspaceId.set(id); this.activeWorkspaceId.set(id);
@@ -7,7 +7,7 @@ declare global {
interface Window { interface Window {
__APP_PLATFORM?: string; __APP_PLATFORM?: string;
/** Which root component this window boots. */ /** Which root component this window boots. */
__APP_MODE?: 'main' | 'editor' | 'manager'; __APP_MODE?: 'main' | 'editor' | 'manager' | 'settings';
/** Window-kind specific context (EditorOpenContext for editor windows). */ /** Window-kind specific context (EditorOpenContext for editor windows). */
__APP_CTX?: unknown; __APP_CTX?: unknown;
} }
@@ -15,21 +15,26 @@ declare global {
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : ''; export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
/** True when the window uses macOS hiddenInset chrome (header fused with titlebar). */ export const IS_MAC = PLATFORM === 'darwin';
export const IS_MAC_CHROME = PLATFORM === 'darwin';
export const IS_WINDOWS = PLATFORM === 'win32'; export const IS_WINDOWS = PLATFORM === 'win32';
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */ export const IS_LINUX = PLATFORM === 'linux';
export const MOD_LABEL = IS_MAC_CHROME ? '⌘' : 'Ctrl+';
export const ALT_LABEL = IS_MAC_CHROME ? '⌥' : 'Alt+';
export const APP_MODE: 'main' | 'editor' | 'manager' = /** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
export const APP_MODE: 'main' | 'editor' | 'manager' | 'settings' =
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main'; typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
export const EDITOR_CONTEXT = export const EDITOR_CONTEXT =
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined; typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
/** True when macOS traffic lights overlay the app's own header row (the fused
* hiddenInset layout, see chromeOptions), which reserves `pl-24` for them. */
export const IS_MAC_CHROME = IS_MAC;
/** /**
* Kill the webview's default right-click menu (Reload, Inspect Element…) * Kill the webview's default right-click menu (Reload, Inspect Element…)
* everywhere the app doesn't show its own native menu. Text fields keep the * everywhere the app doesn't show its own native menu. Text fields keep the
@@ -76,9 +76,10 @@ export class RpcService {
}); });
const electroview = new Electroview({ rpc: this.rpc }); const electroview = new Electroview({ rpc: this.rpc });
// Requests fired before the RPC WebSocket finishes connecting are lost, so // Gate everything below on the RPC WebSocket being open (services fire calls
// everything below gates on it being open (services fire calls at startup). // at startup). v1 silently lost pre-open sends; 2.x Electroview queues them
const socket = electroview.bunSocket; // and falls back to the native bridge, so this is now just belt-and-braces.
const socket = electroview.hostSocket;
if (socket && socket.readyState !== WebSocket.OPEN) { if (socket && socket.readyState !== WebSocket.OPEN) {
this.ready = new Promise((resolve) => { this.ready = new Promise((resolve) => {
socket.addEventListener('open', () => resolve(), { once: true }); socket.addEventListener('open', () => resolve(), { once: true });
@@ -99,6 +100,15 @@ export class RpcService {
this.menuListeners.add(listener); this.menuListeners.add(listener);
} }
/**
* Dispatch an action that originated in the UI (the HTML context menu) as if
* it had arrived from bun, so app.ts keeps ONE handler for menu actions
* regardless of whether the app menubar or a right-click produced them.
*/
emitMenuAction(action: string): void {
for (const listener of this.menuListeners) listener(action);
}
onSettingsChanged(listener: (settings: AppSettings) => void): void { onSettingsChanged(listener: (settings: AppSettings) => void): void {
this.settingsListeners.add(listener); this.settingsListeners.add(listener);
} }
@@ -1,6 +1,6 @@
import { Injectable, computed, inject, signal } from '@angular/core'; import { Injectable, computed, inject, signal } from '@angular/core';
import { resolveLocale } from '@shared/i18n'; import { resolveLocale } from '@shared/i18n';
import { DEFAULT_SETTINGS, type AppSettings, type ColumnKey } from '@shared/types'; import { clampUiScale, DEFAULT_SETTINGS, type AppSettings, type ColumnKey } from '@shared/types';
import { I18nService } from './i18n.service'; import { I18nService } from './i18n.service';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
@@ -9,11 +9,9 @@ export class SettingsService {
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale); readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme); readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone); readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
readonly iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors);
readonly columns = signal<ColumnKey[]>(DEFAULT_SETTINGS.columns); readonly columns = signal<ColumnKey[]>(DEFAULT_SETTINGS.columns);
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit); readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
readonly textSize = signal<AppSettings['textSize']>(DEFAULT_SETTINGS.textSize); readonly uiScale = signal<number>(DEFAULT_SETTINGS.uiScale);
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode); readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency); readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency);
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted); readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
@@ -38,8 +36,6 @@ export class SettingsService {
window window
.matchMedia('(prefers-color-scheme: dark)') .matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => this.systemDark.set(e.matches)); .addEventListener('change', (e) => this.systemDark.set(e.matches));
// the default accent is not the CSS default — apply before first paint
this.applyAccent();
if (!this.rpc.available) return; if (!this.rpc.available) return;
void this.rpc.request.getSettings({}).then((res) => { void this.rpc.request.getSettings({}).then((res) => {
if (res.ok) this.applyAll(res.settings); if (res.ok) this.applyAll(res.settings);
@@ -52,11 +48,9 @@ export class SettingsService {
this.locale.set(settings.locale); this.locale.set(settings.locale);
this.theme.set(settings.theme); this.theme.set(settings.theme);
this.darkTone.set(settings.darkTone); this.darkTone.set(settings.darkTone);
this.accent.set(settings.accent);
this.iconColors.set(settings.iconColors);
this.columns.set(settings.columns); this.columns.set(settings.columns);
this.speedUnit.set(settings.speedUnit); this.speedUnit.set(settings.speedUnit);
this.textSize.set(settings.textSize); this.uiScale.set(settings.uiScale);
this.transferMode.set(settings.transferMode); this.transferMode.set(settings.transferMode);
this.transferConcurrency.set(settings.transferConcurrency); this.transferConcurrency.set(settings.transferConcurrency);
this.autoClearCompleted.set(settings.autoClearCompleted); this.autoClearCompleted.set(settings.autoClearCompleted);
@@ -64,8 +58,7 @@ export class SettingsService {
this.editorApp.set(settings.editorApp); this.editorApp.set(settings.editorApp);
this.applyTheme(); this.applyTheme();
this.applyDarkTone(); this.applyDarkTone();
this.applyAccent(); this.applyUiScale();
this.applyTextSize();
this.applyLocale(); this.applyLocale();
} }
@@ -87,18 +80,6 @@ export class SettingsService {
void this.persist(); void this.persist();
} }
setAccent(tone: AppSettings['accent']): void {
this.accent.set(tone);
this.applyAccent();
void this.persist();
}
setIconColors(mode: AppSettings['iconColors']): void {
// no apply step — the file panels read the signal reactively
this.iconColors.set(mode);
void this.persist();
}
toggleColumn(key: ColumnKey): void { toggleColumn(key: ColumnKey): void {
this.columns.update((list) => this.columns.update((list) =>
list.includes(key) ? list.filter((c) => c !== key) : [...list, key], list.includes(key) ? list.filter((c) => c !== key) : [...list, key],
@@ -111,9 +92,9 @@ export class SettingsService {
void this.persist(); void this.persist();
} }
setTextSize(size: AppSettings['textSize']): void { setUiScale(scale: number): void {
this.textSize.set(size); this.uiScale.set(clampUiScale(scale));
this.applyTextSize(); this.applyUiScale();
void this.persist(); void this.persist();
} }
@@ -160,21 +141,17 @@ export class SettingsService {
} }
} }
private applyAccent(): void { /** The whole interface scale is ONE declaration: every size in the app is
const tone = this.accent(); * rem, so moving the root font-size moves the whole UI. 1 = the
if (tone === 'graphite') { * browser-default 16px root; delete-when-default keeps the pristine state
delete document.documentElement.dataset['accent']; * attribute-free like the other axes. buildPreload mirrors this for a
* flash-free first paint. */
private applyUiScale(): void {
const scale = this.uiScale();
if (scale === 1) {
document.documentElement.style.removeProperty('font-size');
} else { } else {
document.documentElement.dataset['accent'] = tone; document.documentElement.style.fontSize = `${16 * scale}px`;
}
}
private applyTextSize(): void {
const size = this.textSize();
if (size === 'default') {
delete document.documentElement.dataset['text'];
} else {
document.documentElement.dataset['text'] = size;
} }
} }
@@ -187,11 +164,9 @@ export class SettingsService {
locale: this.locale(), locale: this.locale(),
theme: this.theme(), theme: this.theme(),
darkTone: this.darkTone(), darkTone: this.darkTone(),
accent: this.accent(),
iconColors: this.iconColors(),
columns: this.columns(), columns: this.columns(),
speedUnit: this.speedUnit(), speedUnit: this.speedUnit(),
textSize: this.textSize(), uiScale: this.uiScale(),
transferMode: this.transferMode(), transferMode: this.transferMode(),
transferConcurrency: this.transferConcurrency(), transferConcurrency: this.transferConcurrency(),
autoClearCompleted: this.autoClearCompleted(), autoClearCompleted: this.autoClearCompleted(),
+114
View File
@@ -0,0 +1,114 @@
import { Injectable } from '@angular/core';
/**
* Every window-level shortcut the app answers to, by name. Components switch
* on these ids instead of key-matching inline, so the whole keymap is ONE
* table — the precondition for the vim-style bindings planned next, which
* will need a mode/leader state machine wrapped around exactly this point.
*/
export type ShortcutId =
// main window (workspace/tab management)
| 'new-tab'
| 'close-tab'
| 'cycle-tab'
| 'cycle-tab-back'
| 'go-to-tab' // 1-9; the digit rides in ShortcutMatch.digit
| 'open-settings'
// editor window
| 'save'
| 'find'
| 'close-window'
| 'toggle-wrap'
// any window
| 'scale-up'
| 'scale-down'
| 'scale-reset';
export interface ShortcutMatch {
id: ShortcutId;
/** Only for go-to-tab: the digit pressed, 1–9. */
digit?: number;
}
/** Which keymap a window consults. 'manager' and 'settings' answer only to the
* any-window shortcuts (scale). Escape/Tab stay component-local: their meaning
* is contextual state (find bar open? close prompt up? which pane?), not a
* global binding. */
export type ShortcutScope = 'main' | 'editor' | 'manager' | 'settings';
/**
* The keyboard layer: modifier helpers + one declarative keymap.
*
* House rules encoded here rather than re-derived per component (they were
* previously copy-pasted inline in six places, drifting apart):
* - `mod` is metaKey OR ctrlKey — ⌘ on macOS, Ctrl elsewhere. The keyboard
* does not change with the window chrome (platform.ts says the same about
* MOD_LABEL).
* - Layout-dependent keys match on `event.code`, not `event.key`: digits
* (AZERTY's digit row shifts), +/- (key varies wildly across layouts), and
* letter chords with Alt (macOS Option produces Ω for ⌥Z). Plain letter
* chords match on `key` so they follow the user's layout.
* - isTypingTarget guards shortcuts that would be hostile mid-typing. It is
* deliberately NOT applied inside resolve(): the existing bindings rely on
* the path bar's stopPropagation and the editor WANTS Mod+S while typing.
* Callers opt in per shortcut (the scale keys do).
*/
@Injectable({ providedIn: 'root' })
export class ShortcutsService {
/** The platform command modifier: ⌘ on macOS, Ctrl elsewhere. Mouse events
* qualify too — Mod+click toggle-select in the panels uses the same rule. */
isMod(event: KeyboardEvent | MouseEvent): boolean {
return event.metaKey || event.ctrlKey;
}
/** True when the event originates from something the user types into. */
isTypingTarget(event: KeyboardEvent): boolean {
const target = event.target as HTMLElement | null;
return !!target?.closest('input, textarea, select, [contenteditable="true"]');
}
/**
* Match an event against the scope's keymap. Returns null for anything the
* table does not claim — callers must then leave the event alone.
*/
resolve(event: KeyboardEvent, scope: ShortcutScope): ShortcutMatch | null {
const mod = this.isMod(event);
const key = event.key.toLowerCase();
// ---- any window: interface scale ----
// event.code, not key: '+' needs Shift on many layouts and '-' moves
// around; the physical Equal/Minus keys (and the numpad pair) are stable.
if (mod && !event.altKey) {
if (event.code === 'Equal' || event.code === 'NumpadAdd') return { id: 'scale-up' };
if (event.code === 'Minus' || event.code === 'NumpadSubtract') return { id: 'scale-down' };
if (event.code === 'Digit0' || event.code === 'Numpad0') return { id: 'scale-reset' };
}
if (scope === 'manager' || scope === 'settings') return null; // any-window shortcuts only
if (scope === 'main') {
// on macOS the app menu's ⌘, intercepts first; this is the Windows/Linux path
if (mod && key === ',') return { id: 'open-settings' };
if (mod && key === 't') return { id: 'new-tab' };
if (mod && key === 'w') return { id: 'close-tab' };
// ctrlKey only, never meta: ⌘Tab is the macOS app switcher
if (event.ctrlKey && event.key === 'Tab') {
return { id: event.shiftKey ? 'cycle-tab-back' : 'cycle-tab' };
}
if (mod && !event.shiftKey && !event.altKey) {
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1];
if (digit) return { id: 'go-to-tab', digit: Number(digit) };
}
return null;
}
// scope === 'editor'
if (mod && key === 's') return { id: 'save' };
if (mod && key === 'f') return { id: 'find' };
// on macOS the app menu intercepts ⌘W before this ever fires; this arm is
// the Windows/Linux path
if (mod && key === 'w') return { id: 'close-window' };
if (event.altKey && event.code === 'KeyZ') return { id: 'toggle-wrap' };
return null;
}
}
-76
View File
@@ -1,76 +0,0 @@
// Single icon registry for the whole app, registered once in app.config.ts.
// NOTE: @ng-icons/mynaui's package root is empty — always import from /outline
// (or /solid, where names get a `Solid` suffix).
import {
mynaArchive,
mynaArrowLeft,
mynaArrowLeftRight,
mynaArrowRight,
mynaArrowUp,
mynaCheck,
mynaCheckCircle,
mynaChevronDown,
mynaChevronRight,
mynaChevronUp,
mynaCode,
mynaCog,
mynaDots,
mynaDownload,
mynaEdit,
mynaFile,
mynaFilePlus,
mynaFileText,
mynaFolder,
mynaFolderPlus,
mynaHardDrive,
mynaImage,
mynaLink,
mynaLock,
mynaMusic,
mynaPlus,
mynaRefresh,
mynaSearch,
mynaServers,
mynaSpinner,
mynaTrash,
mynaVideo,
mynaX,
mynaXCircle,
} from "@ng-icons/mynaui/outline";
export const APP_ICONS = {
mynaArchive,
mynaArrowLeft,
mynaArrowLeftRight,
mynaArrowRight,
mynaArrowUp,
mynaCheck,
mynaCheckCircle,
mynaChevronDown,
mynaChevronRight,
mynaChevronUp,
mynaCode,
mynaCog,
mynaDots,
mynaDownload,
mynaEdit,
mynaFile,
mynaFilePlus,
mynaFileText,
mynaFolder,
mynaFolderPlus,
mynaHardDrive,
mynaImage,
mynaLink,
mynaLock,
mynaMusic,
mynaPlus,
mynaRefresh,
mynaSearch,
mynaServers,
mynaSpinner,
mynaTrash,
mynaVideo,
mynaX,
mynaXCircle,
};
@@ -1,8 +1,8 @@
<div class="flex h-screen flex-col bg-background text-foreground"> <div class="flex h-screen flex-col bg-background text-foreground">
@if (!rpc.available) { @if (!rpc.available) {
<div class="flex flex-1 items-center justify-center"> <div class="flex flex-1 items-center justify-center">
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm"> <div class="max-w-sm border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" /> <ng-icon name="tdesignCloseCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p> <p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground"> <p class="mt-1 text-xs text-muted-foreground">
{{ i18n.t('app.notConnectedBody') }} {{ i18n.t('app.notConnectedBody') }}
@@ -27,11 +27,11 @@
class="fixed inset-0 z-60 flex items-center justify-center bg-black/40" class="fixed inset-0 z-60 flex items-center justify-center bg-black/40"
(mousedown)="focusManager()" (mousedown)="focusManager()"
> >
<div class="rounded-lg border border-border bg-card px-5 py-4 text-center shadow-xl"> <div class="border border-border bg-card px-5 py-4 text-center shadow-xl">
<ng-icon name="mynaServers" size="1.25rem" class="text-muted-foreground" /> <ng-icon name="tdesignServer" size="1.25rem" class="text-muted-foreground" />
<p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p> <p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p>
<button <button
class="mt-3 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90" class="mt-3 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
> >
{{ i18n.t('app.showConnections') }} {{ i18n.t('app.showConnections') }}
</button> </button>
@@ -53,5 +53,6 @@
<app-overwrite-dialog [request]="req" /> <app-overwrite-dialog [request]="req" />
} }
<app-context-menu />
<app-toasts /> <app-toasts />
</div> </div>
@@ -1,27 +1,29 @@
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { I18nService } from './i18n.service'; import { I18nService } from '@core/i18n.service';
import { BRANDING } from '@shared/branding'; import { BRANDING } from '@shared/branding';
import type { ColumnKey } from '@shared/types'; import type { ColumnKey } from '@shared/types';
import { ConnectionsService } from './connections.service'; import { ConnectionsService } from '@core/connections.service';
import { ContextMenu } from './context-menu';
import { ChmodDialog } from './dialogs/chmod-dialog'; import { ChmodDialog } from './dialogs/chmod-dialog';
import { ConfirmDialog } from './dialogs/confirm-dialog'; import { ConfirmDialog } from './dialogs/confirm-dialog';
import { OverwriteDialog } from './dialogs/overwrite-dialog'; import { OverwriteDialog } from './dialogs/overwrite-dialog';
import { PromptDialog } from './dialogs/prompt-dialog'; import { PromptDialog } from './dialogs/prompt-dialog';
import { DialogsService } from './dialogs.service'; import { DialogsService } from '@core/dialogs.service';
import { FileActionsService } from './file-actions.service'; import { FileActionsService } from '@core/file-actions.service';
import { FilePanel } from './file-panel/file-panel'; import { FilePanel } from './file-panel/file-panel';
import { PanelsService } from './panels.service'; import { PanelsService } from '@core/panels.service';
import { blockNativeContextMenu } from './platform'; import { blockNativeContextMenu } from '@core/platform';
import { RpcService } from './rpc.service'; import { RpcService } from '@core/rpc.service';
import { SettingsService } from './settings.service'; import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { StatusBar } from './status-bar/status-bar'; import { StatusBar } from './status-bar/status-bar';
import { TabBar } from './tab-bar/tab-bar'; import { TabBar } from './tab-bar/tab-bar';
import { ToastService } from './toast.service'; import { ToastService } from '@core/toast.service';
import { Toasts } from './toasts/toasts'; import { Toasts } from './toasts/toasts';
import { TransferBar } from './transfer-bar/transfer-bar'; import { TransferBar } from './transfer-bar/transfer-bar';
import { TransfersService } from './transfers.service'; import { TransfersService } from '@core/transfers.service';
import { UpdaterService } from './updater.service'; import { UpdaterService } from '@core/updater.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -36,6 +38,7 @@ import { UpdaterService } from './updater.service';
OverwriteDialog, OverwriteDialog,
Toasts, Toasts,
TransferBar, TransferBar,
ContextMenu,
], ],
templateUrl: './app.html', templateUrl: './app.html',
host: { host: {
@@ -53,6 +56,7 @@ export class App {
readonly connections = inject(ConnectionsService); readonly connections = inject(ConnectionsService);
private readonly transfers = inject(TransfersService); private readonly transfers = inject(TransfersService);
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
private readonly updater = inject(UpdaterService); private readonly updater = inject(UpdaterService);
/** The connection-manager window is open — this window is modal-blocked. */ /** The connection-manager window is open — this window is modal-blocked. */
@@ -117,6 +121,10 @@ export class App {
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey); this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
return; return;
} }
if (action.startsWith('tab:')) {
this.handleTabAction(action.slice('tab:'.length));
return;
}
if (action.startsWith('open-stack:')) { if (action.startsWith('open-stack:')) {
const id = action.slice('open-stack:'.length); const id = action.slice('open-stack:'.length);
let stackConnections = this.connections.connections().filter((c) => c.stackId === id); let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
@@ -152,38 +160,99 @@ export class App {
} }
} }
/** Tab (workspace) shortcuts — panel-level keys live on the panels themselves. */ /** Window shortcuts — matching lives in ShortcutsService (one keymap for the
* app); this switch only maps ids onto actions. Panel-level keys stay on
* the panels themselves. */
onWindowKeydown(event: KeyboardEvent): void { onWindowKeydown(event: KeyboardEvent): void {
if (this.managerOpen()) return; // modal-blocked behind the manager if (this.managerOpen()) return; // modal-blocked behind the manager
const mod = event.metaKey || event.ctrlKey; const match = this.shortcuts.resolve(event, 'main');
if (mod && event.key.toLowerCase() === 't') { if (!match) return;
event.preventDefault(); // scale keys are hostile mid-typing ('-' in a rename prompt must type '-');
this.panels.newWorkspace(); // the tab shortcuts keep firing there, as they always did
} else if (mod && event.key.toLowerCase() === 'w') { if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
event.preventDefault(); event.preventDefault();
this.panels.closeWorkspace(this.panels.activeWorkspaceId()); switch (match.id) {
} else if (event.ctrlKey && event.key === 'Tab') { case 'new-tab':
event.preventDefault(); this.panels.newWorkspace();
this.panels.cycleWorkspace(event.shiftKey ? -1 : 1); break;
} else if (mod && !event.shiftKey && !event.altKey) { case 'close-tab':
// ⌘1–⌘8 jump to that tab, ⌘9 to the last (Ctrl+digit elsewhere), like this.panels.closeWorkspace(this.panels.activeWorkspaceId());
// Firefox. Matched on event.code so the digit row works on any layout. break;
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1]; case 'cycle-tab':
if (!digit) return; this.panels.cycleWorkspace(1);
event.preventDefault(); break;
this.panels.goToWorkspace(digit === '9' ? -1 : Number(digit) - 1); case 'cycle-tab-back':
this.panels.cycleWorkspace(-1);
break;
case 'go-to-tab':
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1);
break;
case 'open-settings':
void this.rpc.request.openSettings({});
break;
case 'scale-up':
this.settings.setUiScale(this.settings.uiScale() + 0.1);
break;
case 'scale-down':
this.settings.setUiScale(this.settings.uiScale() - 0.1);
break;
case 'scale-reset':
this.settings.setUiScale(1);
break;
} }
} }
/** Context-menu actions apply to the row/panel remembered at right-click time. */ /** Context-menu actions apply to the row/panel remembered at right-click time. */
/**
* Workspace-tab menu actions. The target id travels INSIDE the action string
* because the right-clicked tab is not necessarily the active one — nothing
* here may fall back to activeWorkspaceId.
*/
private handleTabAction(action: string): void {
if (action === 'new') {
this.panels.newWorkspace();
return;
}
const parsed = /^([a-z-]+):(\d+)$/.exec(action);
if (!parsed) return;
const id = Number(parsed[2]);
switch (parsed[1]) {
case 'close':
this.panels.closeWorkspace(id);
break;
case 'close-others':
this.panels.closeOtherWorkspaces(id);
break;
case 'close-right':
this.panels.closeWorkspacesToRight(id);
break;
case 'disconnect':
void this.panels.disconnectWorkspace(id);
break;
}
}
private handleContextAction(action: string): void { private handleContextAction(action: string): void {
const target = this.actions.menuTarget; const target = this.actions.menuTarget;
if (!target) return; if (!target) return;
const { panel, side, entry } = target; const { panel, side, entry } = target;
// the empty-space menu acts on the directory itself
const path = entry?.path ?? panel.cwd();
switch (action) { switch (action) {
case 'ctx-open': case 'ctx-open':
if (entry) panel.open(entry); if (entry) panel.open(entry);
break; break;
// local shell integration — only bun can reach the OS shell/clipboard
case 'ctx-reveal':
void this.rpc.request.shellItemAction({ action: 'reveal', path });
break;
case 'ctx-open-default':
void this.rpc.request.shellItemAction({ action: 'open', path });
break;
case 'ctx-copy-path':
void this.rpc.request.shellItemAction({ action: 'copyPath', path });
break;
case 'ctx-edit': case 'ctx-edit':
void this.actions.edit(panel); void this.actions.edit(panel);
break; break;
@@ -9,19 +9,19 @@
[class.h-9]="!isMacChrome" [class.h-9]="!isMacChrome"
[class.pl-3]="!isMacChrome" [class.pl-3]="!isMacChrome"
> >
<ng-icon name="mynaServers" size="0.95rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="tdesignServer" size="0.95rem" class="shrink-0 text-muted-foreground" />
<h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1> <h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1>
<div class="flex-1"></div> <div class="flex-1"></div>
<button <button
(click)="importWinScp()" (click)="importWinScp()"
[disabled]="importing()" [disabled]="importing()"
[title]="i18n.t('connMgr.importWinscpHint')" [title]="i18n.t('connMgr.importWinscpHint')"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50" class="flex items-center gap-1.5 px-2 py-1 text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50"
> >
@if (importing()) { @if (importing()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" /> <ng-icon name="tdesignLoading" size="0.85rem" class="animate-spin" />
} @else { } @else {
<!-- <ng-icon name="mynaArchive" size="0.85rem" /> --> <!-- <ng-icon name="tdesignFileZip" size="0.85rem" /> -->
} }
{{ i18n.t('connMgr.importWinscp') }} {{ i18n.t('connMgr.importWinscp') }}
</button> </button>
@@ -34,16 +34,16 @@
<button <button
(click)="newConnection()" (click)="newConnection()"
[title]="i18n.t('connMgr.newConnectionHint')" [title]="i18n.t('connMgr.newConnectionHint')"
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaPlus" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }} <ng-icon name="tdesignAdd" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }}
</button> </button>
<button <button
(click)="newStack()" (click)="newStack()"
[title]="i18n.t('connMgr.newStackHint')" [title]="i18n.t('connMgr.newStackHint')"
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaFolderPlus" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }} <ng-icon name="tdesignFolderAdd" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }}
</button> </button>
</div> </div>
@@ -54,7 +54,6 @@
(dragover)="onDragOver($event, group.stack.id)" (dragover)="onDragOver($event, group.stack.id)"
(dragleave)="dropTarget() === group.stack.id && dropTarget.set(null)" (dragleave)="dropTarget() === group.stack.id && dropTarget.set(null)"
(drop)="onDrop($event, group.stack.id)" (drop)="onDrop($event, group.stack.id)"
class="rounded-md"
[class.bg-primary/10]="dropTarget() === group.stack.id" [class.bg-primary/10]="dropTarget() === group.stack.id"
[class.ring-1]="dropTarget() === group.stack.id" [class.ring-1]="dropTarget() === group.stack.id"
[class.ring-inset]="dropTarget() === group.stack.id" [class.ring-inset]="dropTarget() === group.stack.id"
@@ -63,22 +62,22 @@
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5"> <div class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
<button <button
(click)="toggleStack(group.stack)" (click)="toggleStack(group.stack)"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground" class="grid size-4 shrink-0 place-items-center text-muted-foreground hover:text-foreground"
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')" [title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
> >
<ng-icon <ng-icon
[name]="group.stack.collapsed ? 'mynaChevronRight' : 'mynaChevronDown'" [name]="group.stack.collapsed ? 'tdesignChevronRight' : 'tdesignChevronDown'"
size="0.8rem" size="0.8rem"
/> />
</button> </button>
<span class="truncate text-[11px] font-medium text-muted-foreground uppercase"> <span class="truncate text-[0.6875rem] font-medium text-muted-foreground uppercase">
{{ group.stack.name }} {{ group.stack.name }}
</span> </span>
<div class="min-w-0 flex-1"></div> <div class="min-w-0 flex-1"></div>
<!-- count and actions swap on hover: they never share the space, <!-- count and actions swap on hover: they never share the space,
so a long stack name can't collide with the buttons --> so a long stack name can't collide with the buttons -->
<span <span
class="shrink-0 text-[10px] text-muted-foreground/70 tabular-nums group-hover:hidden" class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-hover:hidden"
> >
{{ group.connections.length }} {{ group.connections.length }}
</span> </span>
@@ -86,24 +85,24 @@
<button <button
(click)="renameStack(group.stack)" (click)="renameStack(group.stack)"
[title]="i18n.t('connMgr.renameStack')" [title]="i18n.t('connMgr.renameStack')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-foreground" class="grid size-4 place-items-center text-muted-foreground hover:text-foreground"
> >
<ng-icon name="mynaEdit" size="0.75rem" /> <ng-icon name="tdesignEdit" size="0.75rem" />
</button> </button>
<button <button
(click)="deleteStack(group.stack)" (click)="deleteStack(group.stack)"
[title]="i18n.t('connMgr.deleteStack')" [title]="i18n.t('connMgr.deleteStack')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-destructive" class="grid size-4 place-items-center text-muted-foreground hover:text-destructive"
> >
<ng-icon name="mynaTrash" size="0.75rem" /> <ng-icon name="tdesignDelete" size="0.75rem" />
</button> </button>
@if (group.connections.length) { @if (group.connections.length) {
<button <button
(click)="openStack(group.stack.id)" (click)="openStack(group.stack.id)"
[title]="i18n.t('connMgr.openStack')" [title]="i18n.t('connMgr.openStack')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-primary" class="grid size-4 place-items-center text-muted-foreground hover:text-primary"
> >
<ng-icon name="mynaArrowRight" size="0.8rem" /> <ng-icon name="tdesignArrowRight" size="0.8rem" />
</button> </button>
} }
</div> </div>
@@ -119,11 +118,11 @@
[class.bg-accent]="draft()?.id === connection.id" [class.bg-accent]="draft()?.id === connection.id"
[class.opacity-50]="draggedId() === connection.id" [class.opacity-50]="draggedId() === connection.id"
> >
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="tdesignServer" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span> <span class="truncate">{{ connection.name }}</span>
</button> </button>
} @empty { } @empty {
<div class="py-1 pl-6 text-[11px] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div> <div class="py-1 pl-6 text-[0.6875rem] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div>
} }
} }
</div> </div>
@@ -134,14 +133,14 @@
(dragover)="onDragOver($event, 'loose')" (dragover)="onDragOver($event, 'loose')"
(dragleave)="dropTarget() === 'loose' && dropTarget.set(null)" (dragleave)="dropTarget() === 'loose' && dropTarget.set(null)"
(drop)="onDrop($event, null)" (drop)="onDrop($event, null)"
class="min-h-8 rounded-md pb-1" class="min-h-8 pb-1"
[class.bg-primary/10]="dropTarget() === 'loose'" [class.bg-primary/10]="dropTarget() === 'loose'"
[class.ring-1]="dropTarget() === 'loose'" [class.ring-1]="dropTarget() === 'loose'"
[class.ring-inset]="dropTarget() === 'loose'" [class.ring-inset]="dropTarget() === 'loose'"
[class.ring-primary]="dropTarget() === 'loose'" [class.ring-primary]="dropTarget() === 'loose'"
> >
@if (store.grouped().stacks.length && store.grouped().loose.length) { @if (store.grouped().stacks.length && store.grouped().loose.length) {
<div class="px-2 pt-2 pb-0.5 text-[11px] font-medium text-muted-foreground uppercase"> <div class="px-2 pt-2 pb-0.5 text-[0.6875rem] font-medium text-muted-foreground uppercase">
{{ i18n.t('connMgr.connectionsHeader') }} {{ i18n.t('connMgr.connectionsHeader') }}
</div> </div>
} }
@@ -155,17 +154,17 @@
[class.bg-accent]="draft()?.id === connection.id" [class.bg-accent]="draft()?.id === connection.id"
[class.opacity-50]="draggedId() === connection.id" [class.opacity-50]="draggedId() === connection.id"
> >
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="tdesignServer" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span> <span class="truncate">{{ connection.name }}</span>
</button> </button>
} }
</div> </div>
@if (!store.connections().length && !store.stacks().length) { @if (!store.connections().length && !store.stacks().length) {
<p class="px-3 py-2 text-[11px] text-muted-foreground"> <p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('connMgr.noneTitle') }} {{ i18n.t('connMgr.noneTitle') }}
</p> </p>
<p class="px-3 py-2 text-[11px] text-muted-foreground"> <p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('connMgr.noneBody') }} {{ i18n.t('connMgr.noneBody') }}
</p> </p>
} }
@@ -183,7 +182,7 @@
(input)="patch('name', $any($event.target).value)" (input)="patch('name', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.namePlaceholder')" [placeholder]="i18n.t('connMgr.namePlaceholder')"
spellcheck="false" spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
@@ -194,7 +193,7 @@
(input)="patch('host', $any($event.target).value)" (input)="patch('host', $any($event.target).value)"
placeholder="192.168.1.10" placeholder="192.168.1.10"
spellcheck="false" spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
@@ -204,14 +203,14 @@
type="number" type="number"
[value]="d.port" [value]="d.port"
(input)="patch('port', +$any($event.target).value)" (input)="patch('port', +$any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
<label class="flex flex-col gap-1 text-xs"> <label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.stack') }}</span> <span class="text-muted-foreground">{{ i18n.t('connMgr.stack') }}</span>
<select <select
(change)="patch('stackId', $any($event.target).value || null)" (change)="patch('stackId', $any($event.target).value || null)"
class="h-7 rounded-md border border-input bg-background px-1 outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-background pl-1 outline-none focus:ring-2 focus:ring-ring"
> >
<!-- [selected] per option: binding [value] on the <select> runs <!-- [selected] per option: binding [value] on the <select> runs
before the options render and silently shows "(none)" --> before the options render and silently shows "(none)" -->
@@ -231,7 +230,7 @@
[value]="d.username" [value]="d.username"
(input)="patch('username', $any($event.target).value)" (input)="patch('username', $any($event.target).value)"
spellcheck="false" spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
<label class="flex flex-col gap-1 text-xs"> <label class="flex flex-col gap-1 text-xs">
@@ -241,7 +240,7 @@
(input)="patch('initialPath', $any($event.target).value)" (input)="patch('initialPath', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.remotePathPlaceholder')" [placeholder]="i18n.t('connMgr.remotePathPlaceholder')"
spellcheck="false" spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
@@ -253,7 +252,6 @@
name="authKind" name="authKind"
[checked]="d.authKind === 'password'" [checked]="d.authKind === 'password'"
(change)="patch('authKind', 'password')" (change)="patch('authKind', 'password')"
class="accent-primary"
/> />
{{ i18n.t('connMgr.authPassword') }} {{ i18n.t('connMgr.authPassword') }}
</label> </label>
@@ -263,7 +261,6 @@
name="authKind" name="authKind"
[checked]="d.authKind === 'key'" [checked]="d.authKind === 'key'"
(change)="patch('authKind', 'key')" (change)="patch('authKind', 'key')"
class="accent-primary"
/> />
{{ i18n.t('connMgr.authKey') }} {{ i18n.t('connMgr.authKey') }}
</label> </label>
@@ -276,7 +273,7 @@
type="password" type="password"
[value]="d.password" [value]="d.password"
(input)="patch('password', $any($event.target).value)" (input)="patch('password', $any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
} @else { } @else {
@@ -288,12 +285,12 @@
(input)="patch('privateKeyPath', $any($event.target).value)" (input)="patch('privateKeyPath', $any($event.target).value)"
placeholder="~/.ssh/id_ed25519" placeholder="~/.ssh/id_ed25519"
spellcheck="false" spellcheck="false"
class="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" class="h-7 min-w-0 flex-1 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/> />
<button <button
type="button" type="button"
(click)="pickKeyFile()" (click)="pickKeyFile()"
class="h-7 shrink-0 rounded-md border border-border px-2 text-[11px] hover:bg-accent" class="h-7 shrink-0 border border-border px-2 text-[0.6875rem] hover:bg-accent"
> >
{{ i18n.t('common.choose') }} {{ i18n.t('common.choose') }}
</button> </button>
@@ -305,7 +302,7 @@
type="password" type="password"
[value]="d.passphrase" [value]="d.passphrase"
(input)="patch('passphrase', $any($event.target).value)" (input)="patch('passphrase', $any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
} }
@@ -318,9 +315,9 @@
(input)="patch('localDir', $any($event.target).value)" (input)="patch('localDir', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.localDirPlaceholder')" [placeholder]="i18n.t('connMgr.localDirPlaceholder')"
spellcheck="false" spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/> />
<span class="text-[10px] text-muted-foreground/80"> <span class="text-[0.625rem] text-muted-foreground/80">
{{ i18n.t('connMgr.localDirHint') }} {{ i18n.t('connMgr.localDirHint') }}
</span> </span>
</label> </label>
@@ -330,13 +327,13 @@
<div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5"> <div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5">
<button <button
(click)="deleteCurrent()" (click)="deleteCurrent()"
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive" class="flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
> >
<ng-icon name="mynaTrash" size="0.85rem" /> {{ i18n.t('common.delete') }} <ng-icon name="tdesignDelete" size="0.85rem" /> {{ i18n.t('common.delete') }}
</button> </button>
<button <button
(click)="moreOpen.set(!moreOpen())" (click)="moreOpen.set(!moreOpen())"
class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" class="border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
{{ moreOpen() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }} {{ moreOpen() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }}
</button> </button>
@@ -344,17 +341,17 @@
<button <button
(click)="save()" (click)="save()"
[disabled]="!valid()" [disabled]="!valid()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50" class="px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
> >
{{ i18n.t('common.save') }} {{ i18n.t('common.save') }}
</button> </button>
<button <button
(click)="saveAndConnect()" (click)="saveAndConnect()"
[disabled]="!valid() || busy()" [disabled]="!valid() || busy()"
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" class="flex items-center gap-1.5 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
@if (busy()) { @if (busy()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" /> <ng-icon name="tdesignLoading" size="0.85rem" class="animate-spin" />
} }
{{ i18n.t('common.connect') }} {{ i18n.t('common.connect') }}
</button> </button>
@@ -362,7 +359,7 @@
} @else { } @else {
<div class="grid flex-1 place-items-center"> <div class="grid flex-1 place-items-center">
<div class="text-center"> <div class="text-center">
<ng-icon name="mynaServers" size="1.75rem" class="text-muted-foreground" /> <ng-icon name="tdesignServer" size="1.75rem" class="text-muted-foreground" />
<p class="mt-2 text-xs text-muted-foreground"> <p class="mt-2 text-xs text-muted-foreground">
{{ i18n.t('connMgr.selectPrompt') }} {{ i18n.t('connMgr.selectPrompt') }}
</p> </p>
@@ -1,14 +1,15 @@
import { Component, computed, inject, signal } from '@angular/core'; import { Component, computed, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { I18nService } from '../i18n.service'; import { I18nService } from '@core/i18n.service';
import type { SavedConnection, Stack } from '@shared/types'; import type { SavedConnection, Stack } from '@shared/types';
import { ConnectionsService } from '../connections.service'; import { ConnectionsService } from '@core/connections.service';
import { ConfirmDialog } from '../dialogs/confirm-dialog'; import { ConfirmDialog } from '../dialogs/confirm-dialog';
import { PromptDialog } from '../dialogs/prompt-dialog'; import { PromptDialog } from '../dialogs/prompt-dialog';
import { DialogsService } from '../dialogs.service'; import { DialogsService } from '@core/dialogs.service';
import { blockNativeContextMenu, IS_MAC_CHROME } from '../platform'; import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform';
import { RpcService } from '../rpc.service'; import { RpcService } from '@core/rpc.service';
import { SettingsService } from '../settings.service'; import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { Toasts } from '../toasts/toasts'; import { Toasts } from '../toasts/toasts';
interface Draft { interface Draft {
@@ -76,6 +77,7 @@ function fromDraft(d: Draft): SavedConnection {
templateUrl: './connection-manager.html', templateUrl: './connection-manager.html',
host: { host: {
'(window:keydown.escape)': 'onEscape()', '(window:keydown.escape)': 'onEscape()',
'(window:keydown)': 'onWindowKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)', '(window:contextmenu)': 'blockContextMenu($event)',
}, },
}) })
@@ -88,6 +90,7 @@ export class ConnectionManager {
// injected for its side effects: fetches settings and applies theme/accent/ // injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root // text-size data attributes to this window's document root
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
readonly draft = signal<Draft | null>(null); readonly draft = signal<Draft | null>(null);
readonly busy = signal(false); readonly busy = signal(false);
@@ -143,6 +146,16 @@ export class ConnectionManager {
if (!this.dialogs.anyOpen) this.close(); if (!this.dialogs.anyOpen) this.close();
} }
/** Any-window shortcuts (interface scale). Escape has its own binding above. */
onWindowKeydown(event: KeyboardEvent): void {
const match = this.shortcuts.resolve(event, 'manager');
if (!match || this.shortcuts.isTypingTarget(event)) return;
event.preventDefault();
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
else if (match.id === 'scale-reset') this.settings.setUiScale(1);
}
blockContextMenu(event: Event): void { blockContextMenu(event: Event): void {
blockNativeContextMenu(event); blockNativeContextMenu(event);
} }
+175
View File
@@ -0,0 +1,175 @@
import {
Component,
ElementRef,
computed,
effect,
inject,
signal,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
import { FocusService } from '@core/focus.service';
import { stepIndex } from '@core/menu-nav';
import { remPx } from '@core/metrics';
/** Rough per-item metrics in REM, used only to decide whether the menu must
* flip. Converted through remPx() at open time so the math tracks the same
* root font-size the CSS scales with — the old px constants (`MENU_W = 224`)
* were 16px-root mirrors of rem classes, so the box grew with the interface
* scale while the flip math still believed 224 and fired at the wrong edge. */
const ITEM_H_REM = 1.625; // px-3 py-1 row + --path-font line ≈ 26px @16
const SEPARATOR_H_REM = 0.4375; // my-1 + 1px border ≈ 7px @16
const MENU_W_REM = 14; // w-56
const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
/**
* The panel right-click menu. Replaces electrobun's native ContextMenu, which
* is a no-op on Linux — see ContextMenuService for the why. Emits the same
* `ctx-*` / `col:*` action strings the native menu did.
*/
@Component({
selector: 'app-context-menu',
imports: [NgIcon],
template: `
@if (menu.request(); as req) {
<!-- click-outside catcher; mousedown so it beats the panel's own handlers -->
<div class="fixed inset-0 z-50" (mousedown)="close()" (contextmenu)="onBackdropContext($event)"></div>
<div
#menuEl
role="menu"
tabindex="-1"
[attr.aria-activedescendant]="activeIndex() >= 0 ? 'ctx-item-' + activeIndex() : null"
(keydown)="onKeydown($event)"
[style.left.px]="position().x"
[style.top.px]="position().y"
class="fixed z-50 w-56 border border-border/60 bg-chrome py-1 text-foreground shadow-xl outline-none"
>
@for (item of req.items; track $index) {
@if (item.type === 'separator') {
<div class="my-1 border-t border-border/60"></div>
} @else {
<button
type="button"
role="menuitem"
[attr.id]="'ctx-item-' + $index"
[title]="item.tooltip ?? ''"
[disabled]="!!item.disabled"
[class.bg-primary-surface]="$index === activeIndex() && !item.disabled"
[class.text-primary]="$index === activeIndex() && !item.disabled"
(mouseenter)="activeIndex.set($index)"
(click)="pick(item)"
class="flex w-full items-center gap-2 px-3 py-1 text-left text-(length:--path-font) disabled:pointer-events-none disabled:opacity-50"
>
<span class="w-3.5 shrink-0">
@if (item.checked) {
<ng-icon name="tdesignCheck" size="0.85rem" class="text-primary" />
}
</span>
<span class="flex-1 truncate">{{ item.label }}</span>
@if (item.accelerator) {
<span class="shrink-0 text-muted-foreground">{{ item.accelerator }}</span>
}
</button>
}
}
</div>
}
`,
// Closing on scroll/blur matches native menu behavior: the anchor row moves
// out from under a menu that would otherwise float over stale content.
host: {
'(window:blur)': 'close()',
'(window:resize)': 'close()',
'(document:scroll)': 'close()',
},
})
export class ContextMenu {
readonly menu = inject(ContextMenuService);
private readonly focus = inject(FocusService);
private readonly menuEl = viewChild<ElementRef<HTMLDivElement>>('menuEl');
readonly activeIndex = signal(-1);
/** Click point, flipped/clamped so the menu never overflows the viewport.
* All UI metrics convert through the live root font-size; window.inner* is
* viewport CSS px, so both sides of every comparison share units. */
readonly position = computed(() => {
const req = this.menu.request();
if (!req) return { x: 0, y: 0 };
const itemH = remPx(ITEM_H_REM);
const separatorH = remPx(SEPARATOR_H_REM);
const menuW = remPx(MENU_W_REM);
const height =
req.items.reduce((sum, i) => sum + (i.type === 'separator' ? separatorH : itemH), 0) +
remPx(0.5); // the menu's py-1 (0.25rem x2)
const x =
req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : req.x;
const y =
req.y + height > window.innerHeight - EDGE_GAP
? Math.max(EDGE_GAP, req.y - height)
: req.y;
return { x, y };
});
constructor() {
// Focus the menu once it exists so arrow keys / Escape reach it. Keyed on
// the viewChild signal, NOT on request(): under zoneless CD an effect can
// run before the @if has rendered the element.
effect(() => this.menuEl()?.nativeElement.focus());
}
close(): void {
if (!this.menu.request()) return;
this.menu.close();
this.activeIndex.set(-1); // a fresh menu starts with nothing highlighted
this.focus.bump();
}
pick(item: MenuItem): void {
// the keyboard path never consults the disabled attribute — guard here
if (item.disabled) return;
if (!item.action) return;
const action = item.action;
this.close();
this.menu.onPick(action);
}
/** Right-clicking the backdrop closes rather than stacking a second menu. */
onBackdropContext(event: MouseEvent): void {
event.preventDefault();
this.close();
}
onKeydown(event: KeyboardEvent): void {
const items = this.menu.request()?.items ?? [];
switch (event.key) {
case 'Escape':
event.preventDefault();
event.stopPropagation();
this.close();
break;
case 'ArrowDown':
event.preventDefault();
this.step(items, 1);
break;
case 'ArrowUp':
event.preventDefault();
this.step(items, -1);
break;
case 'Enter':
case ' ': {
event.preventDefault();
const item = items[this.activeIndex()];
if (item) this.pick(item);
break;
}
}
}
/** Move the highlight by one selectable item (see stepIndex). */
private step(items: MenuItem[], delta: 1 | -1): void {
const selectable = items.map((i) => i.type !== 'separator' && !i.disabled);
this.activeIndex.set(stepIndex(selectable, this.activeIndex(), delta));
}
}
@@ -1,7 +1,7 @@
import { Component, computed, inject, input, signal } from '@angular/core'; import { Component, computed, inject, input, signal } from '@angular/core';
import { I18nService } from '../i18n.service'; import { I18nService } from '@core/i18n.service';
import { DialogsService, type ChmodRequest } from '../dialogs.service'; import { DialogsService, type ChmodRequest } from '@core/dialogs.service';
import { FocusService } from '../focus.service'; import { FocusService } from '@core/focus.service';
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const; const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
const PERMS = ['r', 'w', 'x'] as const; const PERMS = ['r', 'w', 'x'] as const;
@@ -11,7 +11,7 @@ const PERMS = ['r', 'w', 'x'] as const;
template: ` template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()"> <div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div <div
class="w-80 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl" class="w-80 border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()" (mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()" (keydown.escape)="cancel()"
> >
@@ -37,7 +37,7 @@ const PERMS = ['r', 'w', 'x'] as const;
type="checkbox" type="checkbox"
[checked]="hasBit(r, p)" [checked]="hasBit(r, p)"
(change)="toggleBit(r, p)" (change)="toggleBit(r, p)"
class="size-3.5 accent-primary" class="size-3.5"
/> />
</td> </td>
} }
@@ -53,20 +53,20 @@ const PERMS = ['r', 'w', 'x'] as const;
(input)="setOctal($any($event.target).value)" (input)="setOctal($any($event.target).value)"
maxlength="4" maxlength="4"
spellcheck="false" spellcheck="false"
class="h-7 w-20 rounded-md border border-input bg-transparent px-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring" class="h-7 w-20 border border-input bg-transparent px-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring"
/> />
</div> </div>
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<button <button
(click)="cancel()" (click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent" class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
> >
{{ i18n.t('common.cancel') }} {{ i18n.t('common.cancel') }}
</button> </button>
<button <button
(click)="submit()" (click)="submit()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90" class="bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
> >
{{ i18n.t('common.apply') }} {{ i18n.t('common.apply') }}
</button> </button>
@@ -1,14 +1,14 @@
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core'; import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
import { I18nService } from '../i18n.service'; import { I18nService } from '@core/i18n.service';
import { DialogsService, type ConfirmRequest } from '../dialogs.service'; import { DialogsService, type ConfirmRequest } from '@core/dialogs.service';
import { FocusService } from '../focus.service'; import { FocusService } from '@core/focus.service';
@Component({ @Component({
selector: 'app-confirm-dialog', selector: 'app-confirm-dialog',
template: ` template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()"> <div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div <div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl" class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()" (mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()" (keydown.escape)="cancel()"
> >
@@ -17,7 +17,7 @@ import { FocusService } from '../focus.service';
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<button <button
(click)="cancel()" (click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent" class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
> >
{{ i18n.t('common.cancel') }} {{ i18n.t('common.cancel') }}
</button> </button>
@@ -26,8 +26,8 @@ import { FocusService } from '../focus.service';
(click)="submit()" (click)="submit()"
[class]=" [class]="
request().destructive request().destructive
? 'rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90' ? 'bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90'
: 'rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90' : 'bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90'
" "
> >
{{ request().confirmLabel }} {{ request().confirmLabel }}
@@ -8,9 +8,9 @@ import {
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { I18nService } from '../i18n.service'; import { I18nService } from '@core/i18n.service';
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '../dialogs.service'; import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service';
import { FocusService } from '../focus.service'; import { FocusService } from '@core/focus.service';
@Component({ @Component({
selector: 'app-overwrite-dialog', selector: 'app-overwrite-dialog',
@@ -21,7 +21,7 @@ import { FocusService } from '../focus.service';
(keydown.escape)="pick('cancel')" (keydown.escape)="pick('cancel')"
> >
<div <div
class="w-[26rem] rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl" class="w-[26rem] border border-border bg-card p-4 text-card-foreground shadow-xl"
> >
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<h2 class="min-w-0 text-sm font-semibold"> <h2 class="min-w-0 text-sm font-semibold">
@@ -30,7 +30,7 @@ import { FocusService } from '../focus.service';
</h2> </h2>
@if (request().total > 1) { @if (request().total > 1) {
<span <span
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground" class="shrink-0 bg-muted px-2 py-0.5 text-[0.625rem] font-medium text-muted-foreground"
> >
{{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }} {{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }}
</span> </span>
@@ -47,20 +47,20 @@ import { FocusService } from '../focus.service';
<!-- comparison --> <!-- comparison -->
<div class="mt-3 grid grid-cols-2 gap-2"> <div class="mt-3 grid grid-cols-2 gap-2">
@for (side of comparison(); track side.label) { @for (side of comparison(); track side.label) {
<div class="rounded-md border border-border p-2.5"> <div class="border border-border p-2.5">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-[10px] font-medium tracking-wide text-muted-foreground uppercase"> <span class="text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
{{ i18n.t('dialogs.' + side.label) }} {{ i18n.t('dialogs.' + side.label) }}
</span> </span>
@if (side.newer) { @if (side.newer) {
<span class="rounded-full bg-success/15 px-1.5 text-[10px] font-medium text-success"> <span class="bg-success/15 px-1.5 text-[0.625rem] font-medium text-success">
{{ i18n.t('dialogs.newer') }} {{ i18n.t('dialogs.newer') }}
</span> </span>
} }
</div> </div>
<div class="mt-1.5 flex items-center gap-1.5 text-xs"> <div class="mt-1.5 flex items-center gap-1.5 text-xs">
<ng-icon <ng-icon
[name]="isDir() ? 'mynaFolder' : 'mynaFile'" [name]="isDir() ? 'tdesignFolder' : 'tdesignFile'"
size="0.9rem" size="0.9rem"
class="shrink-0 text-muted-foreground" class="shrink-0 text-muted-foreground"
/> />
@@ -68,7 +68,7 @@ import { FocusService } from '../focus.service';
{{ isDir() ? '—' : i18n.size(side.entry.size) }} {{ isDir() ? '—' : i18n.size(side.entry.size) }}
</span> </span>
</div> </div>
<div class="mt-1 font-mono text-[11px] tabular-nums text-muted-foreground"> <div class="mt-1 font-mono text-[0.6875rem] tabular-nums text-muted-foreground">
{{ i18n.date(side.entry.mtime) }} {{ i18n.date(side.entry.mtime) }}
</div> </div>
</div> </div>
@@ -79,7 +79,7 @@ import { FocusService } from '../focus.service';
<div class="mt-4 flex items-center gap-2"> <div class="mt-4 flex items-center gap-2">
<button <button
(click)="pick('cancel')" (click)="pick('cancel')"
class="rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground" class="px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
> >
{{ i18n.t('common.cancel') }} {{ i18n.t('common.cancel') }}
</button> </button>
@@ -87,13 +87,13 @@ import { FocusService } from '../focus.service';
@if (request().total > 1) { @if (request().total > 1) {
<button <button
(click)="pick('skipAll')" (click)="pick('skipAll')"
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" class="px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
{{ i18n.t('dialogs.skipAll') }} {{ i18n.t('dialogs.skipAll') }}
</button> </button>
<button <button
(click)="pick('overwriteAll')" (click)="pick('overwriteAll')"
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" class="px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
{{ isDir() ? i18n.t('dialogs.mergeAll') : i18n.t('dialogs.replaceAll') }} {{ isDir() ? i18n.t('dialogs.mergeAll') : i18n.t('dialogs.replaceAll') }}
</button> </button>
@@ -101,14 +101,14 @@ import { FocusService } from '../focus.service';
} }
<button <button
(click)="pick('skip')" (click)="pick('skip')"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent" class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
> >
{{ i18n.t('dialogs.skip') }} {{ i18n.t('dialogs.skip') }}
</button> </button>
<button <button
#primaryBtn #primaryBtn
(click)="pick('overwrite')" (click)="pick('overwrite')"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90" class="bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
> >
{{ isDir() ? i18n.t('dialogs.merge') : i18n.t('dialogs.replace') }} {{ isDir() ? i18n.t('dialogs.merge') : i18n.t('dialogs.replace') }}
</button> </button>
@@ -7,16 +7,16 @@ import {
signal, signal,
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { I18nService } from '../i18n.service'; import { I18nService } from '@core/i18n.service';
import { DialogsService, type PromptRequest } from '../dialogs.service'; import { DialogsService, type PromptRequest } from '@core/dialogs.service';
import { FocusService } from '../focus.service'; import { FocusService } from '@core/focus.service';
@Component({ @Component({
selector: 'app-prompt-dialog', selector: 'app-prompt-dialog',
template: ` template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()"> <div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div <div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl" class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()" (mousedown)="$event.stopPropagation()"
> >
<h2 class="text-sm font-semibold">{{ request().title }}</h2> <h2 class="text-sm font-semibold">{{ request().title }}</h2>
@@ -28,19 +28,19 @@ import { FocusService } from '../focus.service';
(keydown.escape)="cancel()" (keydown.escape)="cancel()"
[placeholder]="request().placeholder ?? ''" [placeholder]="request().placeholder ?? ''"
spellcheck="false" spellcheck="false"
class="mt-3 h-8 w-full rounded-md border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring" class="mt-3 h-8 w-full border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring"
/> />
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<button <button
(click)="cancel()" (click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent" class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
> >
{{ i18n.t('common.cancel') }} {{ i18n.t('common.cancel') }}
</button> </button>
<button <button
(click)="submit()" (click)="submit()"
[disabled]="!value().trim()" [disabled]="!value().trim()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" class="bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{{ request().confirmLabel }} {{ request().confirmLabel }}
</button> </button>
@@ -1,8 +1,8 @@
<div class="flex h-screen flex-col bg-background text-foreground"> <div class="flex h-screen flex-col bg-background text-foreground">
@if (!ctx) { @if (!ctx) {
<div class="flex flex-1 items-center justify-center"> <div class="flex flex-1 items-center justify-center">
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm"> <div class="max-w-sm border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" /> <ng-icon name="tdesignCloseCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p> <p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground"> <p class="mt-1 text-xs text-muted-foreground">
{{ i18n.t('editor.noFileBody') }} {{ i18n.t('editor.noFileBody') }}
@@ -20,16 +20,16 @@
[class.h-9]="!isMacChrome" [class.h-9]="!isMacChrome"
[class.pl-3]="!isMacChrome" [class.pl-3]="!isMacChrome"
> >
<ng-icon name="mynaFileText" size="0.95rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="tdesignFile1" size="0.95rem" class="shrink-0 text-muted-foreground" />
<h1 class="min-w-0 truncate font-mono text-sm font-semibold"> <h1 class="min-w-0 truncate font-mono text-sm font-semibold">
{{ ctx.name }}{{ dirty() ? ' •' : '' }} {{ ctx.name }}{{ dirty() ? ' •' : '' }}
</h1> </h1>
<span class="shrink-0 text-[11px] text-muted-foreground">{{ ctx.sessionTitle }}</span> <span class="shrink-0 text-[0.6875rem] text-muted-foreground">{{ ctx.sessionTitle }}</span>
<div class="flex-1"></div> <div class="flex-1"></div>
<button <button
(click)="toggleWrap()" (click)="toggleWrap()"
[title]="i18n.t('editor.wrapHint', { alt: altLabel })" [title]="i18n.t('editor.wrapHint', { alt: altLabel })"
class="rounded-md px-2 py-1 text-[11px] electrobun-webkit-app-region-no-drag hover:bg-accent" class="px-2 py-1 text-[0.6875rem] electrobun-webkit-app-region-no-drag hover:bg-accent"
[class.bg-accent]="wrap()" [class.bg-accent]="wrap()"
[class.text-foreground]="wrap()" [class.text-foreground]="wrap()"
[class.text-muted-foreground]="!wrap()" [class.text-muted-foreground]="!wrap()"
@@ -39,15 +39,15 @@
<button <button
(click)="openFind()" (click)="openFind()"
[title]="i18n.t('editor.findHint', { mod: modLabel })" [title]="i18n.t('editor.findHint', { mod: modLabel })"
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground" class="grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaSearch" size="0.85rem" /> <ng-icon name="tdesignSearch" size="0.85rem" />
</button> </button>
</header> </header>
@if (findOpen()) { @if (findOpen()) {
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome px-3 py-1.5"> <div class="flex shrink-0 items-center gap-2 border-b border-border bg-toolbar px-3 py-1.5">
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="tdesignSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
<input <input
#findBox #findBox
[value]="findQuery()" [value]="findQuery()"
@@ -55,10 +55,10 @@
(keydown)="onFindKeydown($event)" (keydown)="onFindKeydown($event)"
[placeholder]="i18n.t('editor.findPlaceholder')" [placeholder]="i18n.t('editor.findPlaceholder')"
spellcheck="false" spellcheck="false"
class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring" class="h-6 w-56 border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
/> />
<span <span
class="text-[11px] tabular-nums" class="text-[0.6875rem] tabular-nums"
[class.text-destructive]="findQuery() && !matches().length" [class.text-destructive]="findQuery() && !matches().length"
[class.text-muted-foreground]="!findQuery() || matches().length" [class.text-muted-foreground]="!findQuery() || matches().length"
> >
@@ -67,50 +67,50 @@
<button <button
(click)="findStep(-1)" (click)="findStep(-1)"
[title]="i18n.t('editor.prevMatch')" [title]="i18n.t('editor.prevMatch')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaChevronUp" size="0.8rem" /> <ng-icon name="tdesignChevronUp" size="0.8rem" />
</button> </button>
<button <button
(click)="findStep(1)" (click)="findStep(1)"
[title]="i18n.t('editor.nextMatch')" [title]="i18n.t('editor.nextMatch')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaChevronDown" size="0.8rem" /> <ng-icon name="tdesignChevronDown" size="0.8rem" />
</button> </button>
<div class="flex-1"></div> <div class="flex-1"></div>
<button <button
(click)="closeFind()" (click)="closeFind()"
[title]="i18n.t('editor.closeHint')" [title]="i18n.t('editor.closeHint')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaX" size="0.8rem" /> <ng-icon name="tdesignClose" size="0.8rem" />
</button> </button>
</div> </div>
} }
@if (sessionDead()) { @if (sessionDead()) {
<div <div
class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive" class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[0.6875rem] text-destructive"
> >
<ng-icon name="mynaXCircle" size="0.85rem" class="shrink-0" /> <ng-icon name="tdesignCloseCircle" size="0.85rem" class="shrink-0" />
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }} {{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
</div> </div>
} }
@if (loading()) { @if (loading()) {
<div class="flex flex-1 items-center justify-center"> <div class="flex flex-1 items-center justify-center">
<ng-icon name="mynaSpinner" size="1.25rem" class="animate-spin text-primary" /> <ng-icon name="tdesignLoading" size="1.25rem" class="animate-spin text-primary" />
</div> </div>
} @else if (loadError(); as error) { } @else if (loadError(); as error) {
<div class="flex flex-1 items-center justify-center"> <div class="flex flex-1 items-center justify-center">
<div class="max-w-md rounded-lg border border-border bg-card p-6 text-center shadow-sm"> <div class="max-w-md border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" /> <ng-icon name="tdesignCloseCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p> <p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ error }}</p> <p class="mt-1 text-xs text-muted-foreground">{{ error }}</p>
<button <button
(click)="discardAndClose()" (click)="discardAndClose()"
class="mt-4 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent" class="mt-4 border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
> >
{{ i18n.t('editor.closeWindow') }} {{ i18n.t('editor.closeWindow') }}
</button> </button>
@@ -141,25 +141,25 @@
</div> </div>
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2"> <footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2">
<span class="text-[11px] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span> <span class="text-[0.6875rem] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
<span class="text-[11px] text-muted-foreground">·</span> <span class="text-[0.6875rem] text-muted-foreground">·</span>
<span class="text-[11px] tabular-nums text-muted-foreground"> <span class="text-[0.6875rem] tabular-nums text-muted-foreground">
{{ i18n.t('editor.lines', { count: lineCount() }) }} {{ i18n.t('editor.lines', { count: lineCount() }) }}
</span> </span>
<div class="flex-1"></div> <div class="flex-1"></div>
<button <button
(click)="requestClose()" (click)="requestClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent" class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
> >
{{ i18n.t('editor.close') }} {{ i18n.t('editor.close') }}
</button> </button>
<button <button
(click)="save()" (click)="save()"
[disabled]="saving() || !dirty()" [disabled]="saving() || !dirty()"
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" class="flex items-center gap-1.5 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
@if (saving()) { @if (saving()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" /> <ng-icon name="tdesignLoading" size="0.85rem" class="animate-spin" />
} }
{{ i18n.t('common.save') }} {{ i18n.t('common.save') }}
</button> </button>
@@ -171,7 +171,7 @@
@if (closePrompt()) { @if (closePrompt()) {
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()"> <div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()">
<div <div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl" class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()" (mousedown)="$event.stopPropagation()"
> >
<h2 class="text-sm font-semibold">{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}</h2> <h2 class="text-sm font-semibold">{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}</h2>
@@ -181,21 +181,21 @@
<div class="mt-4 flex items-center gap-2"> <div class="mt-4 flex items-center gap-2">
<button <button
(click)="discardAndClose()" (click)="discardAndClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent" class="px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent"
> >
{{ i18n.t('editor.dontSave') }} {{ i18n.t('editor.dontSave') }}
</button> </button>
<div class="flex-1"></div> <div class="flex-1"></div>
<button <button
(click)="cancelClose()" (click)="cancelClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent" class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
> >
{{ i18n.t('common.cancel') }} {{ i18n.t('common.cancel') }}
</button> </button>
<button <button
(click)="saveAndClose()" (click)="saveAndClose()"
[disabled]="saving()" [disabled]="saving()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" class="bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{{ i18n.t('common.save') }} {{ i18n.t('common.save') }}
</button> </button>
@@ -10,11 +10,12 @@ import {
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { I18nService } from '../i18n.service'; import { I18nService } from '@core/i18n.service';
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '../platform'; import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
import { RpcService } from '../rpc.service'; import { RpcService } from '@core/rpc.service';
import { SettingsService } from '../settings.service'; import { SettingsService } from '@core/settings.service';
import { ToastService } from '../toast.service'; import { ShortcutsService } from '@core/shortcuts.service';
import { ToastService } from '@core/toast.service';
import { Toasts } from '../toasts/toasts'; import { Toasts } from '../toasts/toasts';
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */ /** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
@@ -94,12 +95,14 @@ export class EditorWindow {
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter'); private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox'); private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
private cachedCharWidth = 0; private cachedCharWidth = 0;
private cachedCharFont = '';
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService); private readonly toast = inject(ToastService);
private readonly document = inject(DOCUMENT); private readonly document = inject(DOCUMENT);
// injected for its side effects: fetches settings and applies theme/accent/ // injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root // text-size data attributes to this window's document root
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
private draftTimer: ReturnType<typeof setTimeout> | null = null; private draftTimer: ReturnType<typeof setTimeout> | null = null;
private lastSentDirty = false; private lastSentDirty = false;
@@ -214,7 +217,11 @@ export class EditorWindow {
el.setSelectionRange(start, start + this.findQuery().length); el.setSelectionRange(start, start + this.findQuery().length);
const text = this.text(); const text = this.text();
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 20; // Fallback derives from the element's own font size, not a hardcoded 20 —
// the interface scale moves the root font-size, so any px literal here
// would be a 16px-root assumption that miscentres at every other scale.
const style = getComputedStyle(el);
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.625;
let line = 0; let line = 0;
let i = text.indexOf('\n'); let i = text.indexOf('\n');
while (i !== -1 && i < start) { while (i !== -1 && i < start) {
@@ -229,12 +236,17 @@ export class EditorWindow {
this.onScroll(); this.onScroll();
} }
/** Monospace advance width, measured once per window. */ /** Monospace advance width, cached per computed FONT rather than per window:
* the interface scale changes the root font-size live, and a cache measured
* once would keep positioning finds with the old advance width. Keying on
* the font string makes it self-invalidating under any font change. */
private charWidth(el: HTMLTextAreaElement): number { private charWidth(el: HTMLTextAreaElement): number {
if (!this.cachedCharWidth) { const font = getComputedStyle(el).font;
if (font !== this.cachedCharFont) {
const canvas = this.document.createElement('canvas').getContext('2d')!; const canvas = this.document.createElement('canvas').getContext('2d')!;
canvas.font = getComputedStyle(el).font; canvas.font = font;
this.cachedCharWidth = canvas.measureText('0').width || 7; this.cachedCharWidth = canvas.measureText('0').width || parseFloat(getComputedStyle(el).fontSize) * 0.6;
this.cachedCharFont = font;
} }
return this.cachedCharWidth; return this.cachedCharWidth;
} }
@@ -307,22 +319,43 @@ export class EditorWindow {
// ---- keyboard ---- // ---- keyboard ----
onKeydown(event: KeyboardEvent): void { onKeydown(event: KeyboardEvent): void {
const mod = event.metaKey || event.ctrlKey; // Chord shortcuts resolve through the shared keymap (ShortcutsService);
if (mod && event.key.toLowerCase() === 's') { // Escape and Tab stay below because their meaning is THIS window's state
// (close prompt up? find bar open? caret in the textarea?), not a binding.
const match = this.shortcuts.resolve(event, 'editor');
if (match) {
// scale keys must not fire while typing — but the editor's own chords
// (save/find/wrap) are WANTED mid-typing, so the guard is per-shortcut
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
event.preventDefault(); event.preventDefault();
void this.save(); switch (match.id) {
} else if (mod && event.key.toLowerCase() === 'f') { case 'save':
event.preventDefault(); void this.save();
this.openFind(); break;
} else if (mod && event.key.toLowerCase() === 'w') { case 'find':
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W and this.openFind();
// bun routes it back as editorCloseRequest break;
event.preventDefault(); case 'close-window':
this.requestClose(); // reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W
} else if (event.altKey && event.code === 'KeyZ') { // and bun routes it back as editorCloseRequest
event.preventDefault(); this.requestClose();
this.toggleWrap(); break;
} else if (event.key === 'Escape' && this.closePrompt()) { case 'toggle-wrap':
this.toggleWrap();
break;
case 'scale-up':
this.settings.setUiScale(this.settings.uiScale() + 0.1);
break;
case 'scale-down':
this.settings.setUiScale(this.settings.uiScale() - 0.1);
break;
case 'scale-reset':
this.settings.setUiScale(1);
break;
}
return;
}
if (event.key === 'Escape' && this.closePrompt()) {
event.preventDefault(); event.preventDefault();
this.cancelClose(); this.cancelClose();
} else if (event.key === 'Escape' && this.findOpen()) { } else if (event.key === 'Escape' && this.findOpen()) {
@@ -1,22 +1,25 @@
<!-- session strip --> <!-- session strip -->
<div class="relative flex items-center gap-1.5 border-b border-border px-2 py-1"> <div
class="relative flex items-center gap-1.5 border-b border-border bg-toolbar px-2 py-1"
[style.box-shadow]="activeMarker()"
>
<button <button
(click)="toggleMenu()" (click)="toggleMenu()"
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent" class="flex min-w-0 items-center gap-1.5 px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent"
[title]="i18n.t('panel.choosePanel')" [title]="i18n.t('panel.choosePanel')"
> >
@if (panel().connecting()) { @if (panel().connecting()) {
<ng-icon name="mynaSpinner" class="shrink-0 animate-spin text-primary" size="var(--action-icon)" /> <ng-icon name="tdesignLoading" class="shrink-0 animate-spin text-primary" size="var(--action-icon)" />
} @else { } @else {
<ng-icon <ng-icon
[name]="isRemote() ? 'mynaServers' : 'mynaHardDrive'" [name]="isRemote() ? 'tdesignServer' : 'tdesignHardDrive'"
[class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'" [class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
size="var(--action-icon)" size="var(--action-icon)"
/> />
} }
<span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span> <span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span>
<ng-icon <ng-icon
name="mynaChevronDown" name="tdesignChevronDown"
class="shrink-0 text-muted-foreground" class="shrink-0 text-muted-foreground"
size="calc(var(--action-icon) - 0.1rem)" size="calc(var(--action-icon) - 0.1rem)"
/> />
@@ -28,7 +31,7 @@
<button <button
(click)="copyToOther()" (click)="copyToOther()"
[title]="side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft')" [title]="side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-primary" class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-primary"
> >
<ng-icon [name]="copyIcon()" size="var(--action-icon)" /> <ng-icon [name]="copyIcon()" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.copy') }}</span> <span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.copy') }}</span>
@@ -36,64 +39,64 @@
<button <button
(click)="run('edit')" (click)="run('edit')"
[title]="i18n.t('panel.editFile')" [title]="i18n.t('panel.editFile')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground" class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaFileText" size="var(--action-icon)" /> <ng-icon name="tdesignFile1" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span> <span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span>
</button> </button>
<button <button
(click)="run('rename')" (click)="run('rename')"
[title]="i18n.t('panel.rename')" [title]="i18n.t('panel.rename')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground" class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaEdit" size="var(--action-icon)" /> <ng-icon name="tdesignEdit" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span> <span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span>
</button> </button>
<button <button
(click)="run('mkdir')" (click)="run('mkdir')"
[title]="i18n.t('panel.newFolder')" [title]="i18n.t('panel.newFolder')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaFolderPlus" size="var(--action-icon)" /> <ng-icon name="tdesignFolderAdd" size="var(--action-icon)" />
</button> </button>
<button <button
(click)="run('createFile')" (click)="run('createFile')"
[title]="i18n.t('panel.newFile')" [title]="i18n.t('panel.newFile')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaFilePlus" size="var(--action-icon)" /> <ng-icon name="tdesignFileAdd" size="var(--action-icon)" />
</button> </button>
@if (chmodAvailable()) { @if (chmodAvailable()) {
<button <button
(click)="run('chmod')" (click)="run('chmod')"
[title]="i18n.t('panel.permissions')" [title]="i18n.t('panel.permissions')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaLock" size="var(--action-icon)" /> <ng-icon name="tdesignLockOn" size="var(--action-icon)" />
</button> </button>
} }
<button <button
(click)="run('delete')" (click)="run('delete')"
[title]="i18n.t('panel.deleteHint')" [title]="i18n.t('panel.deleteHint')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-destructive" class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-destructive"
> >
<ng-icon name="mynaTrash" size="var(--action-icon)" /> <ng-icon name="tdesignDelete" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span> <span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span>
</button> </button>
@if (menuOpen()) { @if (menuOpen()) {
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div> <div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
<div <div
class="absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl" class="absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto border border-border bg-card py-1 text-card-foreground shadow-xl"
> >
<button <button
(click)="pickLocal()" (click)="pickLocal()"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent" class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
> >
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" /> <ng-icon name="tdesignHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1">{{ i18n.t('common.local') }}</span> <span class="flex-1">{{ i18n.t('common.local') }}</span>
@if (!isRemote()) { @if (!isRemote()) {
<ng-icon name="mynaCheck" size="0.85rem" class="shrink-0 text-primary" /> <ng-icon name="tdesignCheck" size="0.85rem" class="shrink-0 text-primary" />
} }
</button> </button>
@@ -103,7 +106,7 @@
(click)="pickDrive(drive)" (click)="pickDrive(drive)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent" class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent"
> >
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" /> <ng-icon name="tdesignHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 font-mono">{{ drive }}</span> <span class="flex-1 font-mono">{{ drive }}</span>
</button> </button>
} }
@@ -116,12 +119,12 @@
(click)="pickConnection(connection)" (click)="pickConnection(connection)"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent" class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
> >
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" /> <ng-icon name="tdesignServer" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ connection.name }}</span> <span class="flex-1 truncate">{{ connection.name }}</span>
</button> </button>
} }
@for (group of connections.grouped().stacks; track group.stack.id) { @for (group of connections.grouped().stacks; track group.stack.id) {
<div class="px-3 pt-1.5 pb-0.5 text-[10px] font-medium tracking-wide text-muted-foreground uppercase"> <div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
{{ group.stack.name }} {{ group.stack.name }}
</div> </div>
@for (connection of group.connections; track connection.id) { @for (connection of group.connections; track connection.id) {
@@ -129,7 +132,7 @@
(click)="pickConnection(connection)" (click)="pickConnection(connection)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent" class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent"
> >
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" /> <ng-icon name="tdesignServer" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ connection.name }}</span> <span class="flex-1 truncate">{{ connection.name }}</span>
</button> </button>
} }
@@ -137,7 +140,7 @@
@if (connections.sshHosts().length) { @if (connections.sshHosts().length) {
<div class="my-1 border-t border-border"></div> <div class="my-1 border-t border-border"></div>
<div class="px-3 pt-1.5 pb-0.5 text-[10px] font-medium tracking-wide text-muted-foreground uppercase"> <div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
~/.ssh/config ~/.ssh/config
</div> </div>
@for (host of connections.sshHosts(); track host.name) { @for (host of connections.sshHosts(); track host.name) {
@@ -147,7 +150,7 @@
[title]="sshHostTitle(host)" [title]="sshHostTitle(host)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent disabled:opacity-60 disabled:hover:bg-transparent" class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent disabled:opacity-60 disabled:hover:bg-transparent"
> >
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" /> <ng-icon name="tdesignServer" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ host.name }}</span> <span class="flex-1 truncate">{{ host.name }}</span>
</button> </button>
} }
@@ -158,14 +161,14 @@
</div> </div>
<!-- path bar --> <!-- path bar -->
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5"> <div class="flex items-center gap-1 border-b border-border bg-toolbar-2 px-2 py-1.5">
<button <button
(click)="panel().up()" (click)="panel().up()"
[disabled]="blocked()" [disabled]="blocked()"
[title]="i18n.t('panel.parentDir')" [title]="i18n.t('panel.parentDir')"
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent" class="grid size-(--path-ctl-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
> >
<ng-icon name="mynaArrowUp" size="1rem" /> <ng-icon name="tdesignArrowUp" size="1rem" />
</button> </button>
<input <input
[value]="pathDraft()" [value]="pathDraft()"
@@ -175,18 +178,18 @@
(keydown)="$event.stopPropagation()" (keydown)="$event.stopPropagation()"
[disabled]="blocked()" [disabled]="blocked()"
spellcheck="false" spellcheck="false"
class="h-(--path-ctl-h) min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50" class="h-(--path-ctl-h) min-w-0 flex-1 border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
/> />
<button <button
(click)="panel().refresh()" (click)="panel().refresh()"
[disabled]="blocked()" [disabled]="blocked()"
[title]="i18n.t('panel.refresh')" [title]="i18n.t('panel.refresh')"
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent" class="grid size-(--path-ctl-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
> >
@if (panel().loading()) { @if (panel().loading()) {
<ng-icon name="mynaSpinner" size="1rem" class="animate-spin" /> <ng-icon name="tdesignLoading" size="1rem" class="animate-spin" />
} @else { } @else {
<ng-icon name="mynaRefresh" size="1rem" /> <ng-icon name="tdesignRefresh" size="1rem" />
} }
</button> </button>
</div> </div>
@@ -195,7 +198,7 @@
<div <div
(contextmenu)="headerContextMenu($event)" (contextmenu)="headerContextMenu($event)"
[style.grid-template-columns]="gridTemplate()" [style.grid-template-columns]="gridTemplate()"
class="grid border-b border-border text-[11px] font-medium text-muted-foreground" class="grid border-b border-border bg-toolbar-3 text-[0.6875rem] font-medium text-muted-foreground"
> >
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')"> <button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')">
{{ i18n.t('columns.name') }} {{ sortIndicator('name') }} {{ i18n.t('columns.name') }} {{ sortIndicator('name') }}
@@ -215,17 +218,17 @@
<!-- type-to-filter bar (appears while a query is active) --> <!-- type-to-filter bar (appears while a query is active) -->
@if (panel().filter(); as query) { @if (panel().filter(); as query) {
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1"> <div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="tdesignSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span> <span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
<span class="shrink-0 text-[11px] text-muted-foreground"> <span class="shrink-0 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('panel.matches', { count: filterMatches() }) }} {{ i18n.t('panel.matches', { count: filterMatches() }) }}
</span> </span>
<button <button
(click)="clearFilter()" (click)="clearFilter()"
[title]="i18n.t('panel.clearFilter')" [title]="i18n.t('panel.clearFilter')"
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-5 shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaX" size="0.8rem" /> <ng-icon name="tdesignClose" size="0.8rem" />
</button> </button>
</div> </div>
} }
@@ -243,13 +246,13 @@
@if (panel().connecting()) { @if (panel().connecting()) {
<div class="panel-fade-in grid h-full place-items-center"> <div class="panel-fade-in grid h-full place-items-center">
<div class="flex flex-col items-center gap-3"> <div class="flex flex-col items-center gap-3">
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" /> <ng-icon name="tdesignLoading" size="1.4rem" class="animate-spin text-primary" />
<span class="text-xs text-muted-foreground"> <span class="text-xs text-muted-foreground">
{{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }} {{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }}
</span> </span>
<button <button
(click)="cancelConnect()" (click)="cancelConnect()"
class="rounded-md border border-border px-3 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" class="border border-border px-3 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
{{ i18n.t('common.cancel') }} {{ i18n.t('common.cancel') }}
</button> </button>
@@ -264,6 +267,7 @@
(contextmenu)="rowContextMenu($event, i)" (contextmenu)="rowContextMenu($event, i)"
[style.grid-template-columns]="gridTemplate()" [style.grid-template-columns]="gridTemplate()"
class="grid h-(--row-h) items-center text-(length:--list-font)" class="grid h-(--row-h) items-center text-(length:--list-font)"
[class.bg-row-alt]="i % 2 === 1 && i !== panel().cursor() && !isSelected(entry)"
[class.bg-cursor-tint]="i === panel().cursor() && !isSelected(entry)" [class.bg-cursor-tint]="i === panel().cursor() && !isSelected(entry)"
[class.bg-primary/15]="isSelected(entry)" [class.bg-primary/15]="isSelected(entry)"
[class.ring-1]="i === panel().cursor() && isActive()" [class.ring-1]="i === panel().cursor() && isActive()"
@@ -321,7 +325,7 @@
</div> </div>
@if (panel().slowLoad() && !panel().connecting()) { @if (panel().slowLoad() && !panel().connecting()) {
<div class="panel-fade-in absolute inset-0 z-10 grid place-items-center bg-background/60"> <div class="panel-fade-in absolute inset-0 z-10 grid place-items-center bg-background/60">
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" /> <ng-icon name="tdesignLoading" size="1.4rem" class="animate-spin text-primary" />
</div> </div>
} }
</div> </div>
@@ -9,18 +9,20 @@ import {
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { I18nService } from '../i18n.service'; import { I18nService } from '@core/i18n.service';
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types'; import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
import { ConnectionsService } from '../connections.service'; import { ConnectionsService } from '@core/connections.service';
import { DialogsService } from '../dialogs.service'; import { ContextMenuService } from '@core/context-menu.service';
import { FileActionsService } from '../file-actions.service'; import { DialogsService } from '@core/dialogs.service';
import { fileGlyph, fileIconClass, fileKindKey } from '../file-icons'; import { FileActionsService } from '@core/file-actions.service';
import { formatMode } from '../format'; import { fileGlyph, fileIconClass, fileKindKey } from '@core/file-icons';
import { PanelsService, type Side, type SortKey } from '../panels.service'; import { formatMode } from '@core/format';
import { IS_WINDOWS } from '../platform'; import { PanelsService, type Side, type SortKey } from '@core/panels.service';
import { RpcService } from '../rpc.service'; import { IS_WINDOWS } from '@core/platform';
import { SettingsService } from '../settings.service'; import { RpcService } from '@core/rpc.service';
import { TransfersService } from '../transfers.service'; import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { TransfersService } from '@core/transfers.service';
/** Optional columns in fixed display order, with their track widths. */ /** Optional columns in fixed display order, with their track widths. */
const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [ const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
@@ -47,15 +49,22 @@ export class FilePanel {
readonly transfers = inject(TransfersService); readonly transfers = inject(TransfersService);
readonly connections = inject(ConnectionsService); readonly connections = inject(ConnectionsService);
readonly dialogs = inject(DialogsService); readonly dialogs = inject(DialogsService);
private readonly menu = inject(ContextMenuService);
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
readonly panel = computed(() => this.panels.get(this.side())); readonly panel = computed(() => this.panels.get(this.side()));
readonly isActive = computed(() => this.panels.activeSide() === this.side()); readonly isActive = computed(() => this.panels.activeSide() === this.side());
/** The active panel's marker: an accent line along its top edge. Inset, so
* switching panels never shifts the layout. */
readonly activeMarker = computed(() =>
this.isActive() ? 'inset 0 0.125rem 0 var(--primary)' : null,
);
readonly isRemote = computed(() => this.panel().sessionId() !== 'local'); readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
/** Input is ignored while the panel is connecting or a listing is in flight. */ /** Input is ignored while the panel is connecting or a listing is in flight. */
readonly blocked = computed(() => this.panel().busy()); readonly blocked = computed(() => this.panel().busy());
/** The copy button points toward the other panel. */ /** The copy button points toward the other panel. */
readonly copyIcon = computed(() => (this.side() === 'left' ? 'mynaArrowRight' : 'mynaArrowLeft')); readonly copyIcon = computed(() => (this.side() === 'left' ? 'tdesignArrowRight' : 'tdesignArrowLeft'));
readonly pathDraft = signal(''); readonly pathDraft = signal('');
readonly menuOpen = signal(false); readonly menuOpen = signal(false);
@@ -88,7 +97,7 @@ export class FilePanel {
headerContextMenu(event: MouseEvent): void { headerContextMenu(event: MouseEvent): void {
event.preventDefault(); event.preventDefault();
void this.rpc.request.showColumnsMenu({ visible: this.settings.columns() }); this.menu.open(event, this.menu.columnItems(this.settings.columns()));
} }
pickDrive(drive: string): void { pickDrive(drive: string): void {
@@ -173,11 +182,11 @@ export class FilePanel {
} }
iconFor(entry: FileEntry): string { iconFor(entry: FileEntry): string {
return fileGlyph(entry, this.settings.iconColors() === 'multi'); return fileGlyph(entry);
} }
iconClass(entry: FileEntry): string { iconClass(entry: FileEntry): string {
return fileIconClass(entry, this.settings.iconColors() === 'multi'); return fileIconClass(entry);
} }
isSelected(entry: FileEntry): boolean { isSelected(entry: FileEntry): boolean {
@@ -218,7 +227,7 @@ export class FilePanel {
const panel = this.panel(); const panel = this.panel();
if (event.shiftKey) { if (event.shiftKey) {
panel.selectRange(this.lastClickedIndex, index); panel.selectRange(this.lastClickedIndex, index);
} else if (event.metaKey || event.ctrlKey) { } else if (this.shortcuts.isMod(event)) {
const entry = panel.sorted()[index]; const entry = panel.sorted()[index];
if (entry) panel.toggleSelect(entry.name); if (entry) panel.toggleSelect(entry.name);
} else { } else {
@@ -238,14 +247,14 @@ export class FilePanel {
// standard behavior: keep the selection when right-clicking inside it // standard behavior: keep the selection when right-clicking inside it
if (entry && !panel.selected().has(entry.name)) panel.clearSelection(); if (entry && !panel.selected().has(entry.name)) panel.clearSelection();
panel.cursor.set(index); panel.cursor.set(index);
void this.actions.openContextMenu({ side: this.side(), panel, entry }); this.actions.openContextMenu(event, { side: this.side(), panel, entry });
} }
listContextMenu(event: MouseEvent): void { listContextMenu(event: MouseEvent): void {
event.preventDefault(); event.preventDefault();
if (this.blocked()) return; if (this.blocked()) return;
this.activate(); this.activate();
void this.actions.openContextMenu({ side: this.side(), panel: this.panel(), entry: null }); this.actions.openContextMenu(event, { side: this.side(), panel: this.panel(), entry: null });
} }
openAt(index: number): void { openAt(index: number): void {
@@ -264,7 +273,7 @@ export class FilePanel {
this.panels.setActiveSide(this.panels.otherSide(this.side())); this.panels.setActiveSide(this.panels.otherSide(this.side()));
return; return;
} }
if ((event.metaKey || event.ctrlKey) && key.toLowerCase() === 'a') { if (this.shortcuts.isMod(event) && key.toLowerCase() === 'a') {
event.preventDefault(); event.preventDefault();
panel.selectAll(); panel.selectAll();
return; return;
+16
View File
@@ -0,0 +1,16 @@
import { App } from './app';
import { ConnectionManager } from './connection-manager/connection-manager';
import { EditorWindow } from './editor-window/editor-window';
import { SettingsWindow } from './settings-window/settings-window';
/**
* The four window roots, keyed by APP_MODE — main.ts picks one. There is no
* title-bar component: the native window frame is kept (hiddenInset on macOS,
* which is what IS_MAC_CHROME accounts for).
*/
export const SHAD_ROOTS = {
main: App,
manager: ConnectionManager,
editor: EditorWindow,
settings: SettingsWindow,
} as const;
@@ -0,0 +1,284 @@
<div class="flex h-screen flex-col bg-background text-foreground">
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
elsewhere the native titlebar sits above and this is a compact strip -->
<header
class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
[class.h-10]="isMacChrome"
[class.pl-24]="isMacChrome"
[class.electrobun-webkit-app-region-drag]="isMacChrome"
[class.h-9]="!isMacChrome"
[class.pl-3]="!isMacChrome"
>
<ng-icon name="tdesignSetting" size="0.95rem" class="shrink-0 text-muted-foreground" />
<h1 class="text-sm font-semibold">{{ i18n.t('options.title') }}</h1>
</header>
<div class="flex min-h-0 flex-1">
<!-- section list -->
<nav class="flex w-44 shrink-0 flex-col gap-0.5 border-r border-border bg-chrome p-2">
@for (s of sections; track s) {
<button
(click)="section.set(s)"
class="px-2.5 py-1.5 text-left text-xs hover:bg-accent hover:text-foreground"
[class.bg-accent]="section() === s"
[class.font-medium]="section() === s"
[class.text-foreground]="section() === s"
[class.text-muted-foreground]="section() !== s"
>
{{ i18n.t('options.sections.' + s) }}
</button>
}
</nav>
<!-- the chosen section's rows -->
<main class="min-w-0 flex-1 overflow-y-auto px-6 py-5">
<h2 class="text-sm font-semibold">{{ i18n.t('options.sections.' + section()) }}</h2>
<div class="mt-4 flex max-w-md flex-col gap-3">
@switch (section()) {
@case ('appearance') {
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.language') }}</span>
<select
(change)="settings.setLocale($any($event.target).value)"
class="h-7 border border-input bg-background pl-1.5 text-[0.6875rem] outline-none focus:ring-2 focus:ring-ring"
>
<option value="system" [selected]="settings.locale() === 'system'">
{{ i18n.t('options.system') }}
</option>
@for (loc of localeOptions; track loc) {
<option [value]="loc" [selected]="settings.locale() === loc">
{{ localeLabels[loc] }}
</option>
}
</select>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.theme.label') }}</span>
<div class="flex border border-border p-0.5">
@for (theme of themes; track theme) {
<button
(click)="settings.setTheme(theme)"
class="px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.theme() === theme"
[class.text-foreground]="settings.theme() === theme"
[class.text-muted-foreground]="settings.theme() !== theme"
>
{{ i18n.t('options.theme.' + theme) }}
</button>
}
</div>
</div>
@if (settings.effectiveDark()) {
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.tone.label') }}</span>
<div class="flex border border-border p-0.5">
@for (tone of darkTones; track tone) {
<button
(click)="settings.setDarkTone(tone)"
class="px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.darkTone() === tone"
[class.text-foreground]="settings.darkTone() === tone"
[class.text-muted-foreground]="settings.darkTone() !== tone"
>
{{ i18n.t('options.tone.' + tone) }}
</button>
}
</div>
</div>
}
<!-- Interface scale: a continuous stepper, 50%..400% in 10-point
steps. One lever moves the whole UI (root font-size; everything
is rem), this window included. -->
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.interface.label') }}</span>
<div class="flex items-center border border-border p-0.5">
<button
(click)="settings.setUiScale(settings.uiScale() - 0.1)"
[disabled]="settings.uiScale() <= 0.5"
[title]="i18n.t('options.interface.smaller')"
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
−
</button>
<button
(click)="settings.setUiScale(1)"
[title]="i18n.t('options.interface.reset')"
class="w-11 px-1 py-0.5 text-center text-[0.6875rem] tabular-nums hover:bg-accent"
>
{{ scalePercent() }}
</button>
<button
(click)="settings.setUiScale(settings.uiScale() + 0.1)"
[disabled]="settings.uiScale() >= 4"
[title]="i18n.t('options.interface.larger')"
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
+
</button>
</div>
</div>
}
@case ('transfers') {
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.speedUnit.label') }}</span>
<div class="flex border border-border p-0.5">
@for (unit of speedUnits; track unit.value) {
<button
(click)="settings.setSpeedUnit(unit.value)"
class="px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.speedUnit() === unit.value"
[class.text-foreground]="settings.speedUnit() === unit.value"
[class.text-muted-foreground]="settings.speedUnit() !== unit.value"
>
{{ unit.label }}
</button>
}
</div>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.transfers.label') }}</span>
<div class="flex border border-border p-0.5">
@for (mode of transferModes; track mode) {
<button
(click)="settings.setTransferMode(mode)"
class="px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.transferMode() === mode"
[class.text-foreground]="settings.transferMode() === mode"
[class.text-muted-foreground]="settings.transferMode() !== mode"
>
{{ i18n.t('options.transfers.' + mode) }}
</button>
}
</div>
</div>
@if (settings.transferMode() === 'multi') {
<div class="flex items-center justify-between gap-2 pl-3">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.atOnce') }}</span>
<div class="flex border border-border p-0.5">
@for (count of concurrencyChoices; track count) {
<button
(click)="settings.setTransferConcurrency(count)"
class="px-2 py-0.5 text-[0.6875rem] tabular-nums"
[class.bg-accent]="settings.transferConcurrency() === count"
[class.text-foreground]="settings.transferConcurrency() === count"
[class.text-muted-foreground]="settings.transferConcurrency() !== count"
>
{{ count }}
</button>
}
</div>
</div>
}
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.finishedTransfers.label') }}</span>
<div class="flex border border-border p-0.5">
<button
(click)="settings.setAutoClearCompleted(true)"
class="px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.autoClearCompleted()"
[class.text-foreground]="settings.autoClearCompleted()"
[class.text-muted-foreground]="!settings.autoClearCompleted()"
>
{{ i18n.t('options.finishedTransfers.autoClear') }}
</button>
<button
(click)="settings.setAutoClearCompleted(false)"
class="px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="!settings.autoClearCompleted()"
[class.text-foreground]="!settings.autoClearCompleted()"
[class.text-muted-foreground]="settings.autoClearCompleted()"
>
{{ i18n.t('options.finishedTransfers.keep') }}
</button>
</div>
</div>
}
@case ('editor') {
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.editor.label') }}</span>
<div class="flex border border-border p-0.5">
<button
(click)="settings.setEditor('builtin')"
class="px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.editor() === 'builtin'"
[class.text-foreground]="settings.editor() === 'builtin'"
[class.text-muted-foreground]="settings.editor() !== 'builtin'"
>
{{ i18n.t('options.editor.builtin') }}
</button>
<button
(click)="settings.setEditor('custom')"
class="px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.editor() === 'custom'"
[class.text-foreground]="settings.editor() === 'custom'"
[class.text-muted-foreground]="settings.editor() !== 'custom'"
>
{{ i18n.t('options.editor.custom') }}
</button>
</div>
</div>
@if (settings.editor() === 'custom') {
<div class="flex items-center justify-between gap-2 pl-3">
<span class="min-w-0 truncate text-xs text-muted-foreground" [title]="settings.editorApp()">
{{ editorAppName() }}
</span>
<div class="flex shrink-0 items-center gap-1">
<button
(click)="pickEditorApp()"
class="border border-border px-2 py-0.5 text-[0.6875rem] hover:bg-accent"
>
{{ i18n.t('common.choose') }}
</button>
@if (settings.editorApp()) {
<button
(click)="settings.setEditorApp('')"
[title]="i18n.t('options.resetDefault')"
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="tdesignClose" size="0.7rem" />
</button>
}
</div>
</div>
}
}
@case ('fileList') {
<span class="text-xs">{{ i18n.t('options.columns') }}</span>
<div class="flex flex-col gap-2 pl-3">
@for (key of columnKeys; track key) {
<label class="flex items-center gap-2 text-xs">
<input
type="checkbox"
[checked]="settings.columns().includes(key)"
(change)="settings.toggleColumn(key)"
/>
{{ i18n.t('columns.' + key) }}
</label>
}
</div>
}
@case ('about') {
<!-- the old popover footer: opens the native About box -->
<button
(click)="showAbout()"
class="self-start border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ appName }} · {{ buildLabel }}
</button>
}
}
</div>
</main>
</div>
</div>
@@ -0,0 +1,90 @@
import { Component, computed, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { BRANDING } from '@shared/branding';
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
import { COLUMN_KEYS } from '@core/context-menu.service';
import { I18nService } from '@core/i18n.service';
import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
const SECTIONS = ['appearance', 'transfers', 'editor', 'fileList', 'about'] as const;
type Section = (typeof SECTIONS)[number];
const THEMES = ['system', 'light', 'dark'] as const;
const DARK_TONES = ['dark', 'darker'] as const;
const SPEED_UNITS = [
{ value: 'MBps', label: 'MB/s' },
{ value: 'Mbps', label: 'Mbps' },
] as const;
const TRANSFER_MODES = ['single', 'multi'] as const;
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
/**
* Root of the Settings window (APP_MODE 'settings'). Every control is a plain
* SettingsService setter: the save goes to bun, which rebroadcasts it, so the
* other windows follow live while this one stays open.
*/
@Component({
selector: 'app-root',
imports: [NgIcon],
templateUrl: './settings-window.html',
host: {
'(window:keydown)': 'onWindowKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)',
},
})
export class SettingsWindow {
readonly settings = inject(SettingsService);
readonly i18n = inject(I18nService);
private readonly rpc = inject(RpcService);
private readonly shortcuts = inject(ShortcutsService);
readonly isMacChrome = IS_MAC_CHROME;
readonly sections = SECTIONS;
readonly section = signal<Section>('appearance');
readonly localeOptions = SUPPORTED;
readonly localeLabels = LOCALE_LABELS;
readonly themes = THEMES;
readonly darkTones = DARK_TONES;
readonly speedUnits = SPEED_UNITS;
readonly transferModes = TRANSFER_MODES;
readonly concurrencyChoices = CONCURRENCY_CHOICES;
readonly columnKeys = COLUMN_KEYS;
readonly appName = BRANDING.appName;
readonly buildLabel = BRANDING.buildLabel;
/** "100%" for the stepper readout; the value itself lives in settings. */
readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`);
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
editorAppName(): string {
const path = this.settings.editorApp();
if (!path) return this.i18n.t('options.systemDefault');
const base = path.split('/').filter(Boolean).pop() ?? path;
return base.replace(/\.app$/i, '');
}
async pickEditorApp(): Promise<void> {
const res = await this.rpc.request.pickEditorApp({});
if (res.ok && res.path) this.settings.setEditorApp(res.path);
}
showAbout(): void {
void this.rpc.request.showAbout({});
}
/** Any-window shortcuts (interface scale); ⌘W arrives as a menu action bun-side. */
onWindowKeydown(event: KeyboardEvent): void {
const match = this.shortcuts.resolve(event, 'settings');
if (!match || this.shortcuts.isTypingTarget(event)) return;
event.preventDefault();
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
else if (match.id === 'scale-reset') this.settings.setUiScale(1);
}
blockContextMenu(event: Event): void {
blockNativeContextMenu(event);
}
}
@@ -1,18 +1,18 @@
import { Component, computed, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { I18nService } from '../i18n.service'; import { I18nService } from '@core/i18n.service';
import type { TransferSnapshot } from '@shared/types'; import type { TransferSnapshot } from '@shared/types';
import { formatSpeed } from '../format'; import { formatSpeed } from '@core/format';
import { PanelsService } from '../panels.service'; import { PanelsService } from '@core/panels.service';
import { SettingsService } from '../settings.service'; import { SettingsService } from '@core/settings.service';
import { TransfersService } from '../transfers.service'; import { TransfersService } from '@core/transfers.service';
@Component({ @Component({
selector: 'app-status-bar', selector: 'app-status-bar',
imports: [NgIcon], imports: [NgIcon],
template: ` template: `
<div <div
class="flex h-6 items-center justify-between gap-4 border-t border-border bg-chrome px-3 text-[11px] text-muted-foreground select-none" class="flex h-6 items-center justify-between gap-4 border-t border-border bg-chrome px-3 text-[0.6875rem] text-muted-foreground select-none"
> >
<!-- transfers summary; click toggles the detailed list --> <!-- transfers summary; click toggles the detailed list -->
<button <button
@@ -21,7 +21,7 @@ import { TransfersService } from '../transfers.service';
[title]="i18n.t('transfer.showDetails')" [title]="i18n.t('transfer.showDetails')"
> >
@if (transfers.currentJob(); as job) { @if (transfers.currentJob(); as job) {
<ng-icon name="mynaSpinner" size="0.8rem" class="shrink-0 animate-spin text-primary" /> <ng-icon name="tdesignLoading" size="0.8rem" class="shrink-0 animate-spin text-primary" />
<span class="truncate"> <span class="truncate">
{{ i18n.t('transfer.copying', { name: job.name, percent: percent(job), speed: speedSuffix(job) }) }} {{ i18n.t('transfer.copying', { name: job.name, percent: percent(job), speed: speedSuffix(job) }) }}
@if (transfers.queuedCount(); as queued) { @if (transfers.queuedCount(); as queued) {
@@ -29,12 +29,12 @@ import { TransfersService } from '../transfers.service';
} }
</span> </span>
} @else if (transfers.failedJobs().length; as failed) { } @else if (transfers.failedJobs().length; as failed) {
<ng-icon name="mynaXCircle" size="0.8rem" class="shrink-0 text-destructive" /> <ng-icon name="tdesignCloseCircle" size="0.8rem" class="shrink-0 text-destructive" />
<span class="text-destructive"> <span class="text-destructive">
{{ i18n.t('transfer.failed', { count: failed }) }} {{ i18n.t('transfer.failed', { count: failed }) }}
</span> </span>
} @else if (transfers.hasJobs()) { } @else if (transfers.hasJobs()) {
<ng-icon name="mynaCheckCircle" size="0.8rem" class="shrink-0 text-success" /> <ng-icon name="tdesignCheckCircle" size="0.8rem" class="shrink-0 text-success" />
<span>{{ i18n.t('transfer.finished') }}</span> <span>{{ i18n.t('transfer.finished') }}</span>
} @else { } @else {
<span>{{ i18n.t('transfer.ready') }}</span> <span>{{ i18n.t('transfer.ready') }}</span>
@@ -44,7 +44,7 @@ import { TransfersService } from '../transfers.service';
<!-- active panel info --> <!-- active panel info -->
<div class="flex shrink-0 items-center gap-1.5"> <div class="flex shrink-0 items-center gap-1.5">
<ng-icon <ng-icon
[name]="activePanel().sessionId() === 'local' ? 'mynaHardDrive' : 'mynaServers'" [name]="activePanel().sessionId() === 'local' ? 'tdesignHardDrive' : 'tdesignServer'"
size="0.8rem" size="0.8rem"
[class.text-primary]="activePanel().sessionId() !== 'local'" [class.text-primary]="activePanel().sessionId() !== 'local'"
/> />
@@ -0,0 +1,264 @@
import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { ContextMenuService } from '@core/context-menu.service';
import { I18nService } from '@core/i18n.service';
import { PanelsService, type Workspace } from '@core/panels.service';
import { IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { UpdaterService } from '@core/updater.service';
@Component({
selector: 'app-tab-bar',
imports: [NgIcon],
template: `
<!-- On macOS this row IS the titlebar (hiddenInset): taller, padded past the
traffic lights, and draggable. Elsewhere it's a normal compact row. -->
<div
class="relative flex items-center gap-0.5 border-b border-border bg-chrome pr-1.5 electrobun-webkit-app-region-drag"
[class.h-10]="isMacChrome"
[class.pl-24]="isMacChrome"
[class.pl-1.5]="!isMacChrome"
[class.pt-1]="!isMacChrome"
>
<div
#tabScroll
role="tablist"
class="flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
@for (ws of panels.workspaces(); track ws.id; let i = $index) {
<div
[attr.data-tab-id]="ws.id"
class="group relative flex max-w-52 shrink-0 items-center electrobun-webkit-app-region-no-drag"
[class.bg-accent]="ws.id === panels.activeWorkspaceId()"
[class.opacity-50]="draggedId() === ws.id"
[style.box-shadow]="dropIndicatorShadow(i)"
(contextmenu)="onTabContextMenu($event, ws, i)"
(mousedown)="onTabMouseDown($event)"
(auxclick)="onAuxClick($event, ws.id)"
draggable="true"
(dragstart)="onDragStart($event, ws.id)"
(dragover)="onDragOver($event, i)"
(drop)="onDrop($event, i)"
(dragend)="onDragEnd()"
>
<button
role="tab"
[attr.aria-selected]="ws.id === panels.activeWorkspaceId()"
(click)="panels.setActiveWorkspace(ws.id)"
class="flex min-w-0 items-center gap-1.5 py-1.5 pr-1 pl-2.5 text-xs"
[class.text-foreground]="ws.id === panels.activeWorkspaceId()"
[class.text-muted-foreground]="ws.id !== panels.activeWorkspaceId()"
[title]="panels.workspaceName(ws)"
>
<ng-icon
[name]="panels.workspaceIsRemote(ws) ? 'tdesignServer' : 'tdesignHardDrive'"
size="0.85rem"
class="shrink-0"
[class.text-primary]="panels.workspaceIsRemote(ws)"
/>
<span class="truncate">{{ panels.workspaceName(ws) }}</span>
</button>
@if (panels.workspaces().length > 1) {
<button
(click)="close($event, ws.id)"
tabindex="-1"
aria-hidden="true"
[title]="i18n.t('tabBar.closeTab', { mod: modLabel })"
class="mr-1 grid size-4 shrink-0 place-items-center text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground"
>
<ng-icon name="tdesignClose" size="0.7rem" />
</button>
} @else {
<span class="mr-1 size-4 shrink-0"></span>
}
@if (ws.id === panels.activeWorkspaceId()) {
<div class="absolute inset-x-0 -bottom-px h-0.5 bg-primary"></div>
}
</div>
}
</div>
<button
(click)="panels.newWorkspace()"
[title]="i18n.t('tabBar.newTab', { mod: modLabel })"
class="ml-0.5 grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="tdesignAdd" size=".8rem" />
</button>
<div class="flex-1"></div>
<!-- auto-update tool: one accent pill that announces the update, becomes its
own progress bar while downloading, then turns into "Restart to apply" -->
@if (updater.state() !== 'idle' && updater.state() !== 'error') {
<button
(click)="onUpdateClick()"
[disabled]="updater.state() === 'downloading'"
[title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })"
class="relative mr-1 grid h-6 place-items-center overflow-hidden px-2.5 text-[0.6875rem] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default"
>
@if (updater.state() === 'downloading') {
<span
class="absolute inset-y-0 left-0 bg-primary-foreground/25 transition-[width] duration-200"
[style.width.%]="updater.progress()"
></span>
}
<span class="relative flex items-center gap-1.5">
@switch (updater.state()) {
@case ('available') {
<ng-icon name="tdesignDownload" size=".8rem" />{{ i18n.t('tabBar.update') }}
}
@case ('downloading') {
<ng-icon name="tdesignLoading" size=".8rem" class="animate-spin" />{{ i18n.t('tabBar.updating') }} {{ updater.progress() }}%
}
@case ('ready') {
<ng-icon name="tdesignRefresh" size=".8rem" />{{ i18n.t('tabBar.restartToUpdate') }}
}
}
</span>
</button>
}
<!-- connection manager (own native window) -->
<button
(click)="openManager()"
[title]="i18n.t('tabBar.manageConnections')"
class="grid h-6 px-2 py-1 place-items-center text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<!-- <ng-icon name="tdesignSetting" size="0.9rem" /> -->
{{ i18n.t('tabBar.connections') }}
</button>
<!-- settings (own native window) -->
<button
(click)="openSettings()"
[title]="i18n.t('tabBar.options')"
class="grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="tdesignSetting" size="0.9rem" />
</button>
</div>
`,
})
export class TabBar {
readonly panels = inject(PanelsService);
readonly i18n = inject(I18nService);
readonly updater = inject(UpdaterService);
private readonly contextMenu = inject(ContextMenuService);
private readonly rpc = inject(RpcService);
readonly isMacChrome = IS_MAC_CHROME;
readonly modLabel = MOD_LABEL;
// drag & drop reordering
readonly draggedId = signal<number | null>(null);
readonly dropTarget = signal<{ index: number; before: boolean } | null>(null);
private readonly tabScroll = viewChild.required<ElementRef<HTMLDivElement>>('tabScroll');
constructor() {
// keep the active tab visible when the strip overflows
effect(() => {
const id = this.panels.activeWorkspaceId();
this.tabScroll()
.nativeElement.querySelector(`[data-tab-id="${id}"]`)
?.scrollIntoView({ inline: 'nearest', block: 'nearest' });
});
}
close(event: MouseEvent, id: number): void {
event.stopPropagation();
this.panels.closeWorkspace(id);
}
/**
* Middle-click closes the tab, as in browsers. `auxclick` also fires for the
* right button, hence the button check; the last tab is refused by
* closeWorkspace itself.
*/
onAuxClick(event: MouseEvent, id: number): void {
if (event.button !== 1) return;
event.preventDefault();
this.close(event, id);
}
/** A middle press over the scrollable strip would start WebView2's autoscroll. */
onTabMouseDown(event: MouseEvent): void {
if (event.button === 1) event.preventDefault();
}
/**
* Right-click a tab. The menu targets THIS tab, not the active one — its
* actions all carry the id — so right-clicking a background tab does not
* yank focus to it first, the way Explorer and every browser behave.
*/
onTabContextMenu(event: MouseEvent, workspace: Workspace, index: number): void {
event.preventDefault();
this.contextMenu.open(
event,
this.contextMenu.tabItems({
id: workspace.id,
index,
tabCount: this.panels.workspaces().length,
isRemote: this.panels.workspaceIsRemote(workspace),
}),
);
}
openManager(): void {
void this.rpc.request.openConnectionManager({});
}
openSettings(): void {
void this.rpc.request.openSettings({});
}
/** Update pill: first click downloads, second click (when ready) applies + relaunches. */
onUpdateClick(): void {
const state = this.updater.state();
if (state === 'available') void this.updater.download();
else if (state === 'ready') this.updater.apply();
}
/** Insertion line at the drop position (inset shadow: no layout shift). */
dropIndicatorShadow(index: number): string | null {
const target = this.dropTarget();
if (!target || target.index !== index || this.draggedId() === null) return null;
return target.before
? 'inset 2px 0 0 var(--primary)'
: 'inset -2px 0 0 var(--primary)';
}
onDragStart(event: DragEvent, id: number): void {
this.draggedId.set(id);
// WebKit refuses to start a drag without data
event.dataTransfer?.setData('text/plain', String(id));
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
}
onDragOver(event: DragEvent, index: number): void {
if (this.draggedId() === null) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
const el = event.currentTarget as HTMLElement;
const before = event.clientX < el.getBoundingClientRect().left + el.offsetWidth / 2;
this.dropTarget.set({ index, before });
}
onDrop(event: DragEvent, index: number): void {
event.preventDefault();
const id = this.draggedId();
const target = this.dropTarget();
this.onDragEnd();
if (id === null || !target) return;
const list = this.panels.workspaces();
const fromIndex = list.findIndex((w) => w.id === id);
if (fromIndex < 0) return;
let toIndex = target.before ? index : index + 1;
if (fromIndex < toIndex) toIndex--; // account for removal before insert
if (toIndex !== fromIndex) this.panels.moveWorkspace(id, toIndex);
}
onDragEnd(): void {
this.draggedId.set(null);
this.dropTarget.set(null);
}
}
@@ -1,6 +1,6 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { ToastService } from '../toast.service'; import { ToastService } from '@core/toast.service';
/** Toast stack overlay; shared by the main window and editor windows. */ /** Toast stack overlay; shared by the main window and editor windows. */
@Component({ @Component({
@@ -12,7 +12,7 @@ import { ToastService } from '../toast.service';
<div <div
(mouseenter)="toast.pause(t.id)" (mouseenter)="toast.pause(t.id)"
(mouseleave)="toast.resume(t.id)" (mouseleave)="toast.resume(t.id)"
class="group pointer-events-auto relative flex items-start gap-2 overflow-hidden rounded-lg border border-border bg-card p-3 text-xs text-card-foreground shadow-lg" class="group pointer-events-auto relative flex items-start gap-2 overflow-hidden border border-border bg-card p-3 text-xs text-card-foreground shadow-lg"
> >
<ng-icon <ng-icon
[name]="toastIcon(t.kind)" [name]="toastIcon(t.kind)"
@@ -24,7 +24,7 @@ import { ToastService } from '../toast.service';
(click)="toast.dismiss(t.id)" (click)="toast.dismiss(t.id)"
class="shrink-0 text-muted-foreground hover:text-foreground" class="shrink-0 text-muted-foreground hover:text-foreground"
> >
<ng-icon name="mynaX" size="0.9rem" /> <ng-icon name="tdesignClose" size="0.9rem" />
</button> </button>
<!-- auto-dismiss countdown; freezes with the timer while hovered --> <!-- auto-dismiss countdown; freezes with the timer while hovered -->
<div <div
@@ -43,8 +43,8 @@ export class Toasts {
readonly toast = inject(ToastService); readonly toast = inject(ToastService);
toastIcon(kind: 'error' | 'info' | 'success'): string { toastIcon(kind: 'error' | 'info' | 'success'): string {
if (kind === 'error') return 'mynaXCircle'; if (kind === 'error') return 'tdesignCloseCircle';
if (kind === 'success') return 'mynaCheckCircle'; if (kind === 'success') return 'tdesignCheckCircle';
return 'mynaCheck'; return 'tdesignCheck';
} }
} }
@@ -1,14 +1,14 @@
@if (transfers.expanded() && transfers.hasJobs()) { @if (transfers.expanded() && transfers.hasJobs()) {
<div class="flex items-center justify-between border-t border-border bg-chrome px-3 py-1 text-[11px] text-muted-foreground"> <div class="flex items-center justify-between border-t border-border bg-chrome px-3 py-1 text-[0.6875rem] text-muted-foreground">
<span> <span>
{{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }} {{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }}
</span> </span>
<button <button
(click)="transfers.clearFinished()" (click)="transfers.clearFinished()"
[disabled]="!transfers.finishedJobs().length" [disabled]="!transfers.finishedJobs().length"
class="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent" class="flex items-center gap-1 px-1.5 py-0.5 hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
> >
<ng-icon name="mynaTrash" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }} <ng-icon name="tdesignDelete" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }}
</button> </button>
</div> </div>
<div class="max-h-40 overflow-y-auto border-t border-border bg-chrome"> <div class="max-h-40 overflow-y-auto border-t border-border bg-chrome">
@@ -17,11 +17,18 @@
<ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" /> <ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" />
<span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span> <span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="truncate text-[11px] text-muted-foreground">{{ progressLabel(job) }}</div> <div class="truncate text-[0.6875rem] text-muted-foreground">{{ progressLabel(job) }}</div>
@if (isActive(job)) { @if (isActive(job)) {
<div class="mt-0.5 h-1 overflow-hidden rounded-full bg-muted"> <div
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
[attr.aria-valuenow]="jobPercent(job)"
[attr.aria-label]="job.name"
class="mt-0.5 h-1 overflow-hidden bg-muted"
>
<div <div
class="h-full rounded-full bg-primary transition-[width] duration-100" class="h-full bg-primary transition-[width] duration-100"
[style.width.%]="jobPercent(job)" [style.width.%]="jobPercent(job)"
></div> ></div>
</div> </div>
@@ -31,17 +38,17 @@
<button <button
(click)="transfers.cancel(job.id)" (click)="transfers.cancel(job.id)"
[title]="i18n.t('transfer.cancel')" [title]="i18n.t('transfer.cancel')"
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive" class="shrink-0 p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
> >
<ng-icon name="mynaX" size="0.9rem" /> <ng-icon name="tdesignClose" size="0.9rem" />
</button> </button>
} @else { } @else {
<button <button
(click)="transfers.dismiss(job.id)" (click)="transfers.dismiss(job.id)"
[title]="i18n.t('transfer.dismiss')" [title]="i18n.t('transfer.dismiss')"
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground" class="shrink-0 p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaX" size="0.9rem" /> <ng-icon name="tdesignClose" size="0.9rem" />
</button> </button>
} }
</div> </div>
@@ -1,10 +1,10 @@
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import type { TransferSnapshot } from '@shared/types'; import type { TransferSnapshot } from '@shared/types';
import { formatEta } from '../format'; import { formatEta } from '@core/format';
import { I18nService } from '../i18n.service'; import { I18nService } from '@core/i18n.service';
import { SettingsService } from '../settings.service'; import { SettingsService } from '@core/settings.service';
import { TransfersService } from '../transfers.service'; import { TransfersService } from '@core/transfers.service';
@Component({ @Component({
selector: 'app-transfer-bar', selector: 'app-transfer-bar',
@@ -33,12 +33,12 @@ export class TransferBar {
stateIcon(job: TransferSnapshot): string { stateIcon(job: TransferSnapshot): string {
switch (job.state) { switch (job.state) {
case 'completed': case 'completed':
return 'mynaCheckCircle'; return 'tdesignCheckCircle';
case 'failed': case 'failed':
case 'cancelled': case 'cancelled':
return 'mynaXCircle'; return 'tdesignCloseCircle';
default: default:
return 'mynaSpinner'; return 'tdesignLoading';
} }
} }
-540
View File
@@ -1,540 +0,0 @@
import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { BRANDING } from '@shared/branding';
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
import type { AccentTone } from '@shared/types';
import { I18nService } from '../i18n.service';
import { PanelsService } from '../panels.service';
import { IS_MAC_CHROME, MOD_LABEL } from '../platform';
import { RpcService } from '../rpc.service';
import { SettingsService } from '../settings.service';
import { UpdaterService } from '../updater.service';
const THEMES = ['system', 'light', 'dark'] as const;
const DARK_TONES = ['dark', 'darker'] as const;
// Swatch colors are fixed mid-tones so every dot reads on both light and dark
// cards (the live --primary values would vanish against one of them). Names are
// translated via options.accentNames.<value>.
const ACCENTS: readonly { value: AccentTone; swatch: string }[] = [
{ value: 'graphite', swatch: 'oklch(0.552 0.016 285.9)' },
{ value: 'blue', swatch: 'oklch(0.65 0.17 255)' },
{ value: 'indigo', swatch: 'oklch(0.65 0.19 277)' },
{ value: 'purple', swatch: 'oklch(0.65 0.17 305)' },
{ value: 'rose', swatch: 'oklch(0.65 0.17 8)' },
{ value: 'orange', swatch: 'oklch(0.68 0.17 50)' },
{ value: 'green', swatch: 'oklch(0.65 0.15 150)' },
{ value: 'teal', swatch: 'oklch(0.65 0.12 185)' },
];
const SPEED_UNITS = [
{ value: 'MBps', label: 'MB/s' },
{ value: 'Mbps', label: 'Mbps' },
] as const;
const TEXT_SIZES = ['compact', 'default', 'large'] as const;
const ICON_COLOR_MODES = ['plain', 'multi'] as const;
const TRANSFER_MODES = ['single', 'multi'] as const;
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@Component({
selector: 'app-tab-bar',
imports: [NgIcon],
template: `
<!-- On macOS this row IS the titlebar (hiddenInset): taller, padded past the
traffic lights, and draggable. Elsewhere it's a normal compact row. -->
<div
class="relative flex items-center gap-0.5 border-b border-border bg-chrome pr-1.5 electrobun-webkit-app-region-drag"
[class.h-10]="isMacChrome"
[class.pl-24]="isMacChrome"
[class.pl-1.5]="!isMacChrome"
[class.pt-1]="!isMacChrome"
>
<div
#tabScroll
class="flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
@for (ws of panels.workspaces(); track ws.id; let i = $index) {
<div
[attr.data-tab-id]="ws.id"
class="group relative flex max-w-52 shrink-0 items-center rounded-md electrobun-webkit-app-region-no-drag"
[class.bg-accent]="ws.id === panels.activeWorkspaceId()"
[class.opacity-50]="draggedId() === ws.id"
[style.box-shadow]="dropIndicatorShadow(i)"
draggable="true"
(dragstart)="onDragStart($event, ws.id)"
(dragover)="onDragOver($event, i)"
(drop)="onDrop($event, i)"
(dragend)="onDragEnd()"
>
<button
(click)="panels.setActiveWorkspace(ws.id)"
class="flex min-w-0 items-center gap-1.5 py-1.5 pr-1 pl-2.5 text-xs"
[class.text-foreground]="ws.id === panels.activeWorkspaceId()"
[class.text-muted-foreground]="ws.id !== panels.activeWorkspaceId()"
[title]="panels.workspaceName(ws)"
>
<ng-icon
[name]="panels.workspaceIsRemote(ws) ? 'mynaServers' : 'mynaHardDrive'"
size="0.85rem"
class="shrink-0"
[class.text-primary]="panels.workspaceIsRemote(ws)"
/>
<span class="truncate">{{ panels.workspaceName(ws) }}</span>
</button>
@if (panels.workspaces().length > 1) {
<button
(click)="close($event, ws.id)"
[title]="i18n.t('tabBar.closeTab', { mod: modLabel })"
class="mr-1 grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground"
>
<ng-icon name="mynaX" size="0.7rem" />
</button>
} @else {
<span class="mr-1 size-4 shrink-0"></span>
}
@if (ws.id === panels.activeWorkspaceId()) {
<div class="absolute inset-x-0 -bottom-px h-0.5 rounded-full bg-primary"></div>
}
</div>
}
</div>
<button
(click)="panels.newWorkspace()"
[title]="i18n.t('tabBar.newTab', { mod: modLabel })"
class="ml-0.5 grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaPlus" size=".8rem" />
</button>
<div class="flex-1"></div>
<!-- auto-update tool: one accent pill that announces the update, becomes its
own progress bar while downloading, then turns into "Restart to apply" -->
@if (updater.state() !== 'idle' && updater.state() !== 'error') {
<button
(click)="onUpdateClick()"
[disabled]="updater.state() === 'downloading'"
[title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })"
class="relative mr-1 grid h-6 place-items-center overflow-hidden rounded-md px-2.5 text-[11px] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default"
>
@if (updater.state() === 'downloading') {
<span
class="absolute inset-y-0 left-0 bg-primary-foreground/25 transition-[width] duration-200"
[style.width.%]="updater.progress()"
></span>
}
<span class="relative flex items-center gap-1.5">
@switch (updater.state()) {
@case ('available') {
<ng-icon name="mynaDownload" size=".8rem" />{{ i18n.t('tabBar.update') }}
}
@case ('downloading') {
<ng-icon name="mynaSpinner" size=".8rem" class="animate-spin" />{{ i18n.t('tabBar.updating') }} {{ updater.progress() }}%
}
@case ('ready') {
<ng-icon name="mynaRefresh" size=".8rem" />{{ i18n.t('tabBar.restartToUpdate') }}
}
}
</span>
</button>
}
<!-- connection manager (own native window) -->
<button
(click)="openManager()"
[title]="i18n.t('tabBar.manageConnections')"
class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<!-- <ng-icon name="mynaCog" size="0.9rem" /> -->
{{ i18n.t('tabBar.connections') }}
</button>
<!-- global options -->
<button
(click)="settingsOpen.set(!settingsOpen())"
[title]="i18n.t('tabBar.options')"
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaDots" size="1.5rem" />
</button>
@if (settingsOpen()) {
<div class="fixed inset-0 z-20" (click)="settingsOpen.set(false)"></div>
<div
class="absolute top-full right-1.5 z-30 mt-1 w-70 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-xl electrobun-webkit-app-region-no-drag"
>
<h3 class="text-xs font-semibold">{{ i18n.t('options.title') }}</h3>
<div class="mt-3 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.language') }}</span>
<select
(change)="settings.setLocale($any($event.target).value)"
class="h-7 rounded-md border border-input bg-background px-1.5 text-[11px] outline-none focus:ring-2 focus:ring-ring"
>
<option value="system" [selected]="settings.locale() === 'system'">
{{ i18n.t('options.system') }}
</option>
@for (loc of localeOptions; track loc) {
<option [value]="loc" [selected]="settings.locale() === loc">
{{ localeLabels[loc] }}
</option>
}
</select>
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.theme.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (theme of themes; track theme) {
<button
(click)="settings.setTheme(theme)"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.theme() === theme"
[class.text-foreground]="settings.theme() === theme"
[class.text-muted-foreground]="settings.theme() !== theme"
>
{{ i18n.t('options.theme.' + theme) }}
</button>
}
</div>
</div>
@if (settings.effectiveDark()) {
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.tone.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (tone of darkTones; track tone) {
<button
(click)="settings.setDarkTone(tone)"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.darkTone() === tone"
[class.text-foreground]="settings.darkTone() === tone"
[class.text-muted-foreground]="settings.darkTone() !== tone"
>
{{ i18n.t('options.tone.' + tone) }}
</button>
}
</div>
</div>
}
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.accent.label') }}</span>
<div class="flex items-center gap-1.5">
@for (tone of accents; track tone.value) {
<button
(click)="settings.setAccent(tone.value)"
[title]="i18n.t('options.accentNames.' + tone.value)"
class="size-3 rounded-full border border-foreground/15"
[style.background-color]="tone.swatch"
[class.ring-2]="settings.accent() === tone.value"
[class.ring-foreground]="settings.accent() === tone.value"
[class.ring-offset-1]="settings.accent() === tone.value"
[class.ring-offset-card]="settings.accent() === tone.value"
></button>
}
</div>
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.fileIcons.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (mode of iconColorModes; track mode) {
<button
(click)="settings.setIconColors(mode)"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.iconColors() === mode"
[class.text-foreground]="settings.iconColors() === mode"
[class.text-muted-foreground]="settings.iconColors() !== mode"
>
{{ i18n.t('options.fileIcons.' + mode) }}
</button>
}
</div>
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.interface.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (size of textSizes; track size) {
<button
(click)="settings.setTextSize(size)"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.textSize() === size"
[class.text-foreground]="settings.textSize() === size"
[class.text-muted-foreground]="settings.textSize() !== size"
>
{{ i18n.t('options.interface.' + size) }}
</button>
}
</div>
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.speedUnit.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (unit of speedUnits; track unit.value) {
<button
(click)="settings.setSpeedUnit(unit.value)"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.speedUnit() === unit.value"
[class.text-foreground]="settings.speedUnit() === unit.value"
[class.text-muted-foreground]="settings.speedUnit() !== unit.value"
>
{{ unit.label }}
</button>
}
</div>
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.transfers.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (mode of transferModes; track mode) {
<button
(click)="settings.setTransferMode(mode)"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.transferMode() === mode"
[class.text-foreground]="settings.transferMode() === mode"
[class.text-muted-foreground]="settings.transferMode() !== mode"
>
{{ i18n.t('options.transfers.' + mode) }}
</button>
}
</div>
</div>
@if (settings.transferMode() === 'multi') {
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.atOnce') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (count of concurrencyChoices; track count) {
<button
(click)="settings.setTransferConcurrency(count)"
class="rounded px-2 py-0.5 text-[11px] tabular-nums"
[class.bg-accent]="settings.transferConcurrency() === count"
[class.text-foreground]="settings.transferConcurrency() === count"
[class.text-muted-foreground]="settings.transferConcurrency() !== count"
>
{{ count }}
</button>
}
</div>
</div>
}
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.editor.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
<button
(click)="settings.setEditor('builtin')"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.editor() === 'builtin'"
[class.text-foreground]="settings.editor() === 'builtin'"
[class.text-muted-foreground]="settings.editor() !== 'builtin'"
>
{{ i18n.t('options.editor.builtin') }}
</button>
<button
(click)="settings.setEditor('custom')"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.editor() === 'custom'"
[class.text-foreground]="settings.editor() === 'custom'"
[class.text-muted-foreground]="settings.editor() !== 'custom'"
>
{{ i18n.t('options.editor.custom') }}
</button>
</div>
</div>
@if (settings.editor() === 'custom') {
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
<span class="min-w-0 truncate text-xs text-muted-foreground" [title]="settings.editorApp()">
{{ editorAppName() }}
</span>
<div class="flex shrink-0 items-center gap-1">
<button
(click)="pickEditorApp()"
class="rounded-md border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
>
{{ i18n.t('common.choose') }}
</button>
@if (settings.editorApp()) {
<button
(click)="settings.setEditorApp('')"
[title]="i18n.t('options.resetDefault')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.7rem" />
</button>
}
</div>
</div>
}
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.finishedTransfers.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
<button
(click)="settings.setAutoClearCompleted(true)"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.autoClearCompleted()"
[class.text-foreground]="settings.autoClearCompleted()"
[class.text-muted-foreground]="!settings.autoClearCompleted()"
>
{{ i18n.t('options.finishedTransfers.autoClear') }}
</button>
<button
(click)="settings.setAutoClearCompleted(false)"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="!settings.autoClearCompleted()"
[class.text-foreground]="!settings.autoClearCompleted()"
[class.text-muted-foreground]="settings.autoClearCompleted()"
>
{{ i18n.t('options.finishedTransfers.keep') }}
</button>
</div>
</div>
<!-- Auto-refresh is always on for now. To expose a toggle, uncomment
the breadcrumbs in types.ts + settings.service, then this row:
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">Auto-refresh</span>
<div class="flex rounded-md border border-border p-0.5">
<button (click)="settings.setAutoRefresh(true)" class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.autoRefresh()"
[class.text-foreground]="settings.autoRefresh()"
[class.text-muted-foreground]="!settings.autoRefresh()">On</button>
<button (click)="settings.setAutoRefresh(false)" class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="!settings.autoRefresh()"
[class.text-foreground]="!settings.autoRefresh()"
[class.text-muted-foreground]="settings.autoRefresh()">Off</button>
</div>
</div>
-->
<!-- about footer -->
<button
(click)="showAbout()"
class="mt-3 w-full border-t border-border pt-2 text-center text-[11px] text-muted-foreground hover:text-foreground"
>
{{ appName }} · {{ buildLabel }}
</button>
</div>
}
</div>
`,
})
export class TabBar {
readonly panels = inject(PanelsService);
readonly settings = inject(SettingsService);
readonly i18n = inject(I18nService);
readonly updater = inject(UpdaterService);
private readonly rpc = inject(RpcService);
readonly settingsOpen = signal(false);
readonly localeOptions = SUPPORTED;
readonly localeLabels = LOCALE_LABELS;
readonly isMacChrome = IS_MAC_CHROME;
readonly modLabel = MOD_LABEL;
readonly appName = BRANDING.appName;
readonly buildLabel = BRANDING.buildLabel;
readonly themes = THEMES;
readonly darkTones = DARK_TONES;
readonly accents = ACCENTS;
readonly iconColorModes = ICON_COLOR_MODES;
readonly speedUnits = SPEED_UNITS;
readonly textSizes = TEXT_SIZES;
readonly transferModes = TRANSFER_MODES;
readonly concurrencyChoices = CONCURRENCY_CHOICES;
// drag & drop reordering
readonly draggedId = signal<number | null>(null);
readonly dropTarget = signal<{ index: number; before: boolean } | null>(null);
private readonly tabScroll = viewChild.required<ElementRef<HTMLDivElement>>('tabScroll');
constructor() {
// keep the active tab visible when the strip overflows
effect(() => {
const id = this.panels.activeWorkspaceId();
this.tabScroll()
.nativeElement.querySelector(`[data-tab-id="${id}"]`)
?.scrollIntoView({ inline: 'nearest', block: 'nearest' });
});
}
close(event: MouseEvent, id: number): void {
event.stopPropagation();
this.panels.closeWorkspace(id);
}
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
editorAppName(): string {
const path = this.settings.editorApp();
if (!path) return this.i18n.t('options.systemDefault');
const base = path.split('/').filter(Boolean).pop() ?? path;
return base.replace(/\.app$/i, '');
}
openManager(): void {
void this.rpc.request.openConnectionManager({});
}
/** Update pill: first click downloads, second click (when ready) applies + relaunches. */
onUpdateClick(): void {
const state = this.updater.state();
if (state === 'available') void this.updater.download();
else if (state === 'ready') this.updater.apply();
}
showAbout(): void {
void this.rpc.request.showAbout({});
}
async pickEditorApp(): Promise<void> {
const res = await this.rpc.request.pickEditorApp({});
if (res.ok && res.path) this.settings.setEditorApp(res.path);
}
/** Insertion line at the drop position (inset shadow: no layout shift). */
dropIndicatorShadow(index: number): string | null {
const target = this.dropTarget();
if (!target || target.index !== index || this.draggedId() === null) return null;
return target.before
? 'inset 2px 0 0 var(--primary)'
: 'inset -2px 0 0 var(--primary)';
}
onDragStart(event: DragEvent, id: number): void {
this.draggedId.set(id);
// WebKit refuses to start a drag without data
event.dataTransfer?.setData('text/plain', String(id));
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
}
onDragOver(event: DragEvent, index: number): void {
if (this.draggedId() === null) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
const el = event.currentTarget as HTMLElement;
const before = event.clientX < el.getBoundingClientRect().left + el.offsetWidth / 2;
this.dropTarget.set({ index, before });
}
onDrop(event: DragEvent, index: number): void {
event.preventDefault();
const id = this.draggedId();
const target = this.dropTarget();
this.onDragEnd();
if (id === null || !target) return;
const list = this.panels.workspaces();
const fromIndex = list.findIndex((w) => w.id === id);
if (fromIndex < 0) return;
let toIndex = target.before ? index : index + 1;
if (fromIndex < toIndex) toIndex--; // account for removal before insert
if (toIndex !== fromIndex) this.panels.moveWorkspace(id, toIndex);
}
onDragEnd(): void {
this.draggedId.set(null);
this.dropTarget.set(null);
}
}
+3 -3
View File
@@ -13,11 +13,11 @@
attributes are baked in by the bun preload (buildPreload in attributes are baked in by the bun preload (buildPreload in
src/bun/windows.ts) so forced themes are correct at first paint. --> src/bun/windows.ts) so forced themes are correct at first paint. -->
<style> <style>
html { background: oklch(1 0 0); } html { background: oklch(0.995 0.0001 260); }
html[data-theme='dark'] { background: oklch(0.2178 0.002 107); } html[data-theme='dark'] { background: oklch(0.195 0.009 260); }
html[data-theme='dark'][data-dark-tone='darker'] { background: oklch(0 0 0); } html[data-theme='dark'][data-dark-tone='darker'] { background: oklch(0 0 0); }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
html:not([data-theme='light']) { background: oklch(0.2178 0.002 107); } html:not([data-theme='light']) { background: oklch(0.195 0.009 260); }
html:not([data-theme='light'])[data-dark-tone='darker'] { background: oklch(0 0 0); } html:not([data-theme='light'])[data-dark-tone='darker'] { background: oklch(0 0 0); }
} }
</style> </style>
+5 -8
View File
@@ -1,10 +1,7 @@
import { bootstrapApplication } from '@angular/platform-browser'; import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config'; import { appConfig } from '@core/app.config';
import { App } from './app/app'; import { APP_MODE } from '@core/platform';
import { ConnectionManager } from './app/connection-manager/connection-manager'; import { SHAD_ROOTS } from './app/skins/shadlike';
import { EditorWindow } from './app/editor-window/editor-window';
import { APP_MODE } from './app/platform';
// One bundle, three window kinds: the preload global picks the root component. // One bundle, four window kinds: the preload global picks the root component.
const root = APP_MODE === 'editor' ? EditorWindow : APP_MODE === 'manager' ? ConnectionManager : App; bootstrapApplication(SHAD_ROOTS[APP_MODE], appConfig).catch((err) => console.error(err));
bootstrapApplication(root, appConfig).catch((err) => console.error(err));
+228 -179
View File
@@ -1,42 +1,56 @@
@import "tailwindcss"; @import "tailwindcss";
/* shadcn-style theme tokens. Light stays the zinc family; the dark neutrals are a /* Theme tokens, adapted from Oxide's design system (docs.oxide.computer): a cool
warm gray family (hue ~107) anchored on #1a1a19 — see the dark blocks below. neutral scale at hue 260 with almost no chroma, one green accent, opaque
The app follows the OS light/dark preference automatically. strokes. Step names in the comments (n0…n1300, green-800, …) are Oxide's.
The app follows the OS light/dark preference unless a theme is forced.
Surface elevation ramp (both themes): --chrome (frame strips: tab/transfer/status Surface layers, each its own shade so the window reads as parts: --chrome
bars, window toolbars) sits BELOW --background (content panes), which sits below (frame strips: tab/transfer/status bars, window header/footer strips and
--card (popovers, dialogs), which sits below --accent (hover). */ sidebars), --toolbar (the top row of a file panel's header strip, and
secondary toolbars) stepping through --toolbar-2/-3 toward --background
(content: file lists, editor text, forms; --row-alt stripes every other file
row), and --card (popovers, dialogs). Dark climbs frame < list < header <
card; light steps greys out from near-white content: frame < header < list
= card. --accent is a translucent overlay, not a surface: hover/selected
states must read on every layer, and an opaque hover vanishes on a surface
of its own lightness. The design is square: there are no radius tokens. */
:root { :root {
/* Native UI (scrollbars, form controls) follows the OS unless a theme is forced. */ /* Native UI (form controls) follows the OS unless a theme is forced. */
color-scheme: light dark; color-scheme: light dark;
--background: oklch(1 0 0); --background: oklch(0.995 0.0001 260); /* n1300 */
--chrome: oklch(0.977 0.001 286.375); --chrome: oklch(0.925 0.0001 260); /* n1000 */
--foreground: oklch(0.141 0.005 285.823); --toolbar: oklch(0.95 0.0001 260); /* n1100 */
--card: oklch(1 0 0); --row-alt: oklch(0.975 0.0001 260); /* n1200 */
--card-foreground: oklch(0.141 0.005 285.823); --foreground: oklch(0.162 0.01 260); /* n0 */
--primary: oklch(0.21 0.006 285.885); --card: oklch(0.995 0.0001 260);
--primary-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.162 0.01 260);
--secondary: oklch(0.967 0.001 286.375); --primary: oklch(0.474 0.1481 173.7); /* green-500 */
--secondary-foreground: oklch(0.21 0.006 285.885); --primary-foreground: oklch(0.995 0.0001 260);
--muted: oklch(0.967 0.001 286.375); /* Oxide's surface-accent: the fill of a selected/highlighted item (green-1200) */
--muted-foreground: oklch(0.552 0.016 285.938); --primary-surface: oklch(0.922 0.0586 163.7);
--accent: oklch(0.967 0.001 286.375); --muted: oklch(0.95 0.0001 260); /* n1100 */
--accent-foreground: oklch(0.21 0.006 285.885); --muted-foreground: oklch(0.483 0.0041 260); /* n500 */
--destructive: oklch(0.577 0.245 27.325); --accent: oklch(0.162 0.01 260 / 5%);
--destructive-foreground: oklch(0.985 0 0); --destructive: oklch(0.474 0.1285 21.3); /* red-500 */
--success: oklch(0.627 0.17 149.2); --destructive-foreground: oklch(0.995 0.0001 260);
--border: oklch(0.92 0.004 286.32); --success: oklch(0.474 0.1481 173.7); /* green-500 */
--input: oklch(0.92 0.004 286.32); --border: oklch(0.9 0.0001 260); /* n900 */
--ring: oklch(0.705 0.015 286.067); --input: oklch(0.79 0.0011 260); /* n800 */
--radius: 0.5rem; --ring: oklch(0.563 0.1714 170.4); /* green-600 */
/* file-type icon colors ("Multi" mode) — fixed category hues, muted chroma */ --cursor-tint: color-mix(in oklab, var(--primary) 8%, transparent);
--icon-folder: oklch(0.58 0.13 250); /* The file panel's header strip steps toward the list, a third per row.
--icon-link: oklch(0.56 0.1 190); Declared once: the var()s resolve on :root, so dark and AMOLED (where both
--icon-image: oklch(0.58 0.13 295); ends are black) follow without their own lines. */
--icon-media: oklch(0.6 0.13 10); --toolbar-2: color-mix(in oklab, var(--toolbar) 66%, var(--background));
--icon-archive: oklch(0.62 0.13 60); --toolbar-3: color-mix(in oklab, var(--toolbar) 33%, var(--background));
--icon-code: oklch(0.56 0.12 155); /* file-type icon colors — fixed category hues from Oxide's scales (600 steps) */
--icon-folder: oklch(0.563 0.1139 278.7); /* blue-600 */
--icon-link: oklch(0.67 0.1216 275.3); /* blue-700 */
--icon-image: oklch(0.563 0.1328 312.1); /* purple-600 */
--icon-media: oklch(0.563 0.1487 18); /* red-600 */
--icon-archive: oklch(0.563 0.125 81.7); /* yellow-600 */
--icon-code: oklch(0.563 0.1714 170.4); /* green-600 */
} }
/* Dark tokens apply when the user forces dark, or follows a dark system theme. /* Dark tokens apply when the user forces dark, or follows a dark system theme.
@@ -47,110 +61,111 @@
:root[data-theme='dark'] { :root[data-theme='dark'] {
color-scheme: dark; color-scheme: dark;
--background: oklch(0.2178 0.002 107); --background: oklch(0.195 0.009 260); /* n50 */
--chrome: oklch(0.1953 0.002 107); --chrome: oklch(0.162 0.01 260); /* n0 */
--foreground: oklch(0.982 0.003 107); --toolbar: oklch(0.275 0.0065 260); /* between n200 and n300 */
--card: oklch(0.245 0.002 107); --row-alt: oklch(0.205 0.0085 260); /* between n50 and n100 */
--card-foreground: oklch(0.982 0.003 107); --foreground: oklch(0.9 0.0001 260); /* n900 */
--primary: oklch(0.92 0.004 107); /* above --toolbar, so a popover never reads darker than the strip it covers */
--primary-foreground: oklch(0.21 0.002 107); --card: oklch(0.29 0.0065 260);
--secondary: oklch(0.302 0.003 107); --card-foreground: oklch(0.9 0.0001 260);
--secondary-foreground: oklch(0.982 0.003 107); --primary: oklch(0.77 0.1919 163.7); /* green-800 */
--muted: oklch(0.302 0.003 107); --primary-foreground: oklch(0.162 0.01 260);
--muted-foreground: oklch(0.715 0.012 107); --primary-surface: oklch(0.24 0.0722 183.7); /* green-200 */
--accent: oklch(0.302 0.003 107); --muted: oklch(0.316 0.006 260); /* n300 */
--accent-foreground: oklch(0.982 0.003 107); --muted-foreground: oklch(0.717 0.0021 260); /* n700 */
--destructive: oklch(0.704 0.191 22.216); --accent: oklch(1 0 0 / 7%);
--destructive-foreground: oklch(0.982 0.003 107); --destructive: oklch(0.712 0.185 11.3); /* red-800 */
--success: oklch(0.696 0.17 149.2); --destructive-foreground: oklch(0.162 0.01 260);
--border: oklch(1 0 0 / 14%); --success: oklch(0.77 0.1919 163.7); /* green-800 */
--input: oklch(1 0 0 / 18%); --border: oklch(0.316 0.006 260); /* n300 */
--ring: oklch(0.552 0.01 107); --input: oklch(0.389 0.005 260); /* n400 */
--icon-folder: oklch(0.75 0.11 250); --ring: oklch(0.563 0.1714 170.4); /* green-600 */
--icon-link: oklch(0.74 0.09 190); --cursor-tint: color-mix(in oklab, var(--primary) 12%, transparent);
--icon-image: oklch(0.76 0.11 295); --icon-folder: oklch(0.71 0.15 272); /* blue-800 */
--icon-media: oklch(0.76 0.11 10); --icon-link: oklch(0.67 0.1216 275.3); /* blue-700 */
--icon-archive: oklch(0.77 0.11 65); --icon-image: oklch(0.74 0.175 305.4); /* purple-800 */
--icon-code: oklch(0.75 0.1 155); --icon-media: oklch(0.712 0.185 11.3); /* red-800 */
--icon-archive: oklch(0.837 0.14 75); /* yellow-800 */
--icon-code: oklch(0.77 0.1919 163.7); /* green-800 */
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) { :root:not([data-theme='light']) {
--background: oklch(0.2178 0.002 107); --background: oklch(0.195 0.009 260);
--chrome: oklch(0.1953 0.002 107); --chrome: oklch(0.162 0.01 260);
--foreground: oklch(0.982 0.003 107); --toolbar: oklch(0.275 0.0065 260);
--card: oklch(0.245 0.002 107); --row-alt: oklch(0.205 0.0085 260);
--card-foreground: oklch(0.982 0.003 107); --foreground: oklch(0.9 0.0001 260);
--primary: oklch(0.92 0.004 107); --card: oklch(0.29 0.0065 260);
--primary-foreground: oklch(0.21 0.002 107); --card-foreground: oklch(0.9 0.0001 260);
--secondary: oklch(0.302 0.003 107); --primary: oklch(0.77 0.1919 163.7);
--secondary-foreground: oklch(0.982 0.003 107); --primary-foreground: oklch(0.162 0.01 260);
--muted: oklch(0.302 0.003 107); --primary-surface: oklch(0.24 0.0722 183.7);
--muted-foreground: oklch(0.715 0.012 107); --muted: oklch(0.316 0.006 260);
--accent: oklch(0.302 0.003 107); --muted-foreground: oklch(0.717 0.0021 260);
--accent-foreground: oklch(0.982 0.003 107); --accent: oklch(1 0 0 / 7%);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.712 0.185 11.3);
--destructive-foreground: oklch(0.982 0.003 107); --destructive-foreground: oklch(0.162 0.01 260);
--success: oklch(0.696 0.17 149.2); --success: oklch(0.77 0.1919 163.7);
--border: oklch(1 0 0 / 14%); --border: oklch(0.316 0.006 260);
--input: oklch(1 0 0 / 18%); --input: oklch(0.389 0.005 260);
--ring: oklch(0.552 0.01 107); --ring: oklch(0.563 0.1714 170.4);
--icon-folder: oklch(0.75 0.11 250); --cursor-tint: color-mix(in oklab, var(--primary) 12%, transparent);
--icon-link: oklch(0.74 0.09 190); --icon-folder: oklch(0.71 0.15 272);
--icon-image: oklch(0.76 0.11 295); --icon-link: oklch(0.67 0.1216 275.3);
--icon-media: oklch(0.76 0.11 10); --icon-image: oklch(0.74 0.175 305.4);
--icon-archive: oklch(0.77 0.11 65); --icon-media: oklch(0.712 0.185 11.3);
--icon-code: oklch(0.75 0.1 155); --icon-archive: oklch(0.837 0.14 75);
--icon-code: oklch(0.77 0.1919 163.7);
} }
} }
/* Dark tone "darker" (data-dark-tone set by SettingsService; absent = grey "dark"). /* Dark tone "darker" (data-dark-tone set by SettingsService; absent = grey "dark").
True-black AMOLED palette: --chrome collapses into --background on purpose, so True-black AMOLED palette: every surface layer (--chrome, --toolbar and the
the frame/content step of the default tone disappears and every large surface is steps derived from it, --background, --row-alt) collapses to #000 on purpose
#000 (OLED pixels actually off). Elevation survives only through --card and — OLED pixels actually off — so every NEW surface token needs a line here.
--accent; --border/--input are raised to 16%/18% because on pure black those Elevation survives only through --card, --muted and --accent (opaque here:
lines are the only thing defining panes, bars and rows. Duplicated for an overlay on black is too faint); the opaque strokes already read on black.
forced-dark and system-dark like the token blocks above — the extra attribute Duplicated for forced-dark and system-dark like the token blocks above — the
outranks them. */ extra attribute outranks them. */
:root[data-theme='dark'][data-dark-tone='darker'] { :root[data-theme='dark'][data-dark-tone='darker'] {
--background: oklch(0 0 0); --background: oklch(0 0 0);
--chrome: oklch(0 0 0); --chrome: oklch(0 0 0);
--card: oklch(0.21 0.002 107); --toolbar: oklch(0 0 0);
--secondary: oklch(0.274 0.003 107); --row-alt: oklch(0 0 0);
--muted: oklch(0.274 0.003 107); --card: oklch(0.216 0.008 260); /* n100 */
--muted-foreground: oklch(0.705 0.012 107); --muted: oklch(0.247 0.007 260); /* n200 */
--accent: oklch(0.274 0.003 107); --accent: oklch(0.247 0.007 260); /* n200 */
--border: oklch(1 0 0 / 16%);
--input: oklch(1 0 0 / 18%);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme='light'])[data-dark-tone='darker'] { :root:not([data-theme='light'])[data-dark-tone='darker'] {
--background: oklch(0 0 0); --background: oklch(0 0 0);
--chrome: oklch(0 0 0); --chrome: oklch(0 0 0);
--card: oklch(0.21 0.002 107); --toolbar: oklch(0 0 0);
--secondary: oklch(0.274 0.003 107); --row-alt: oklch(0 0 0);
--muted: oklch(0.274 0.003 107); --card: oklch(0.216 0.008 260);
--muted-foreground: oklch(0.705 0.012 107); --muted: oklch(0.247 0.007 260);
--accent: oklch(0.274 0.003 107); --accent: oklch(0.247 0.007 260);
--border: oklch(1 0 0 / 16%);
--input: oklch(1 0 0 / 18%);
} }
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-chrome: var(--chrome); --color-chrome: var(--chrome);
--color-toolbar: var(--toolbar);
--color-toolbar-2: var(--toolbar-2);
--color-toolbar-3: var(--toolbar-3);
--color-row-alt: var(--row-alt);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary); --color-primary-surface: var(--primary-surface);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success); --color-success: var(--success);
@@ -164,45 +179,26 @@
--color-icon-media: var(--icon-media); --color-icon-media: var(--icon-media);
--color-icon-archive: var(--icon-archive); --color-icon-archive: var(--icon-archive);
--color-icon-code: var(--icon-code); --color-icon-code: var(--icon-code);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
} }
/* Interface-size setting (data-text set by SettingsService). Scales the whole /* Panel-chrome metrics: file list, path bar, action buttons, source picker.
panel chrome: file list, path bar, action buttons, and the source picker ALL REM, deliberately — the interface scale (settings.uiScale) works by
with its dropdown. The tab bar (including the options button) stays fixed. setting the root font-size, so every rem value in the app scales from that
Default-tier values match the old hardcoded classes exactly. */ one lever. A px value here would freeze while everything around it grew.
There is only one tier now: the old data-text compact/large tiers are gone, replaced
by the continuous scale. Values are the old default tier verbatim
(13px -> 0.8125rem etc. at the 16px root), so scale 1.0 is pixel-identical
to the pre-scale app. */
:root { :root {
--list-font: 13px; --list-font: 0.8125rem;
--list-meta-font: 12px; --list-meta-font: 0.75rem;
--path-font: 12px; --path-font: 0.75rem;
--row-h: 28px; --row-h: 1.75rem;
--path-ctl-h: 28px; --path-ctl-h: 1.75rem;
--action-h: 24px; --action-h: 1.5rem;
--action-font: 11px; --action-font: 0.6875rem;
--action-icon: 0.9rem; --action-icon: 0.9rem;
} }
:root[data-text='compact'] {
--list-font: 12px;
--list-meta-font: 11px;
--path-font: 11px;
--row-h: 24px;
--path-ctl-h: 24px;
--action-h: 22px;
--action-font: 10px;
--action-icon: 0.85rem;
}
:root[data-text='large'] {
--list-font: 14px;
--list-meta-font: 13px;
--path-font: 13px;
--row-h: 32px;
--path-ctl-h: 32px;
--action-h: 28px;
--action-font: 12px;
--action-icon: 1.05rem;
}
/* Soft entrance for the panel busy states (connecting placeholder, load overlay). */ /* Soft entrance for the panel busy states (connecting placeholder, load overlay). */
@keyframes panel-fade-in { @keyframes panel-fade-in {
@@ -229,43 +225,6 @@
animation-play-state: paused; animation-play-state: paused;
} }
/* Accent tone (data-accent set by SettingsService; absent = graphite/zinc).
Each tone is one hue+chroma line; the formula blocks below derive --primary,
--ring and the cursor-row tint for light and both dark states. The dark
formula blocks (0,3,0) outrank the base dark token blocks and the light
formula block (0,2,0) in every dark state; graphite matches no [data-accent]
selector and keeps the zinc tokens untouched. */
:root {
/* graphite: cursor row identical to the old bg-accent highlight */
--cursor-tint: var(--accent);
}
:root[data-accent='blue'] { --tone-h: 255; --tone-c: 0.17; }
:root[data-accent='indigo'] { --tone-h: 277; --tone-c: 0.19; }
:root[data-accent='purple'] { --tone-h: 305; --tone-c: 0.17; }
:root[data-accent='rose'] { --tone-h: 8; --tone-c: 0.17; }
/* darkest orange that still reads orange; white button text lands ≈ 4.3:1 */
:root[data-accent='orange'] { --tone-h: 50; --tone-c: 0.18; --tone-l: 0.58; }
:root[data-accent='green'] { --tone-h: 150; --tone-c: 0.15; }
:root[data-accent='teal'] { --tone-h: 185; --tone-c: 0.12; }
:root[data-accent] {
--primary: oklch(var(--tone-l, 0.55) var(--tone-c) var(--tone-h));
--ring: oklch(0.62 calc(var(--tone-c) * 0.55) var(--tone-h));
--cursor-tint: color-mix(in oklab, var(--primary) 8%, transparent);
}
:root[data-theme='dark'][data-accent] {
--primary: oklch(0.72 calc(var(--tone-c) * 0.85) var(--tone-h));
--ring: oklch(0.62 calc(var(--tone-c) * 0.55) var(--tone-h));
--cursor-tint: color-mix(in oklab, var(--primary) 12%, transparent);
}
@media (prefers-color-scheme: dark) {
:root[data-accent]:not([data-theme='light']) {
--primary: oklch(0.72 calc(var(--tone-c) * 0.85) var(--tone-h));
--ring: oklch(0.62 calc(var(--tone-c) * 0.55) var(--tone-h));
--cursor-tint: color-mix(in oklab, var(--primary) 12%, transparent);
}
}
html, html,
body { body {
height: 100%; height: 100%;
@@ -276,3 +235,93 @@ body {
user-select: none; user-select: none;
cursor: default; cursor: default;
} }
/* Native controls, restyled square to match the rest. WebKit draws checkboxes,
radios and selects natively and ignores border-radius/background until
appearance: none. In @layer base so utilities (size-3.5, pl-1 …) still win —
which is also why the selects use pl-*, never px-*: a px utility would
override the padding-right the caret needs. */
@layer base {
input[type='checkbox'],
input[type='radio'] {
appearance: none;
display: inline-grid;
place-content: center;
flex-shrink: 0;
width: 0.875rem;
height: 0.875rem;
margin: 0;
border: 1px solid var(--input);
background-color: var(--background);
}
input[type='checkbox']:checked,
input[type='radio']:checked {
border-color: var(--primary);
background-color: var(--primary);
}
/* the check: an L of two borders, rotated */
input[type='checkbox']:checked::after {
content: '';
width: 0.5rem;
height: 0.28rem;
border: solid var(--primary-foreground);
border-width: 0 0 0.125rem 0.125rem;
transform: translateY(-0.06rem) rotate(-45deg);
}
/* radios are square too; a square dot tells them apart from checkboxes */
input[type='radio']:checked::after {
content: '';
width: 0.375rem;
height: 0.375rem;
background-color: var(--primary-foreground);
}
input[type='checkbox']:focus-visible,
input[type='radio']:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 1px;
}
input[type='checkbox']:disabled,
input[type='radio']:disabled {
opacity: 0.5;
}
/* No native arrow: a caret drawn from two currentColor triangles follows the
theme (a data-URI svg can't). The opened option list stays native — macOS
draws that popup itself. */
select {
appearance: none;
padding-right: 1.5rem;
background-image:
linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position:
calc(100% - 0.8rem) 55%,
calc(100% - 0.5rem) 55%;
background-size: 0.3rem 0.3rem;
background-repeat: no-repeat;
}
/* number inputs (the manager's port field): no native spin buttons */
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
appearance: none;
margin: 0;
}
/* Square scrollbars. Styling them replaces macOS's auto-hiding overlay
scrollbars with always-visible ones — the inherent cost. */
::-webkit-scrollbar {
width: 0.625rem;
height: 0.625rem;
}
::-webkit-scrollbar-track,
::-webkit-scrollbar-corner {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--border);
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--input);
}
}
+14 -2
View File
@@ -4,12 +4,24 @@
"compileOnSave": false, "compileOnSave": false,
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
/* @shared -> the bun/UI shared code (src/shared). @core -> the skin-agnostic
UI layer (services, platform, formatters). @core is an ALIAS rather than a
relative path on purpose: skin components live at varying depths under
app/skins/<skin>/, and an alias keeps their imports identical wherever a
component is moved to.
electrobun/view -> the SDK Hutch projects into .hutch/devkit (the npm
`electrobun` package only exports a throwing stub). Exact target from the
devkit's export map; `bunx electrobun prepare` must have run. */
"paths": { "paths": {
"@shared/*": ["../src/shared/*"] "@shared/*": ["../src/shared/*"],
"@core/*": ["./src/app/core/*"],
"electrobun/view": ["../.hutch/devkit/api/browser/index.ts"]
}, },
"noImplicitOverride": true, "noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true, "noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true, /* Off because the devkit's browser SDK is raw TS compiled inside this
program, and 2.0.2's transport send() returns on only one path. */
"noImplicitReturns": false,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"skipLibCheck": true, "skipLibCheck": true,
"isolatedModules": true, "isolatedModules": true,