Compare commits

..
Author SHA1 Message Date
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
jgamboaandClaude fc03fa6216 Make the darker tone true-black AMOLED
The darker tone sat at #090908 for --background while --chrome was already
pure black, which both inverted the elevation ramp (frame strips darker than
the content beside them) and missed the point of an AMOLED tone — only a
true #000000 pixel is actually off on OLED.

--background now collapses into --chrome at pure black, so the main window,
connection manager and editor all render black end to end: panes, bars,
sidebar, gutter and textarea. Elevation is kept where it earns its place —
dialogs, popovers and toasts stay #181817, hover and selection #272726 — and
--border/--input go from 12%/15% to 16%/18% white, since on pure black those
lines are the only thing left defining panes, bars and rows.

No plumbing needed: buildPreload already bakes data-dark-tone onto every
window pre-paint, and tone changes broadcast live to open windows.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-14 14:00:01 +02:00
jgamboaandClaude e4ba846719 Retone dark theme to warm gray #1a1a19 with layered surfaces
The dark palette was shadcn's cool zinc (#202023, hue ~286). Anchor it on
#1a1a19 instead — cds gray-830, a warm near-neutral (hue 107) — and retone
the whole dark neutral family to match, since a warm background under cool
panels clashes. Lightness ramp is preserved; chromatic tokens (destructive,
success, icon hues, accent formulas) and the light palette are untouched.

Also introduce --chrome so the app isn't one flat tone: frame strips (tab
bar, status bar, transfer bar, editor/manager header+footer, connection
sidebar) now sit one step below the content panes, with popovers and hover
above them — the standard dark-UI elevation order.

Dark ramp: #151514 chrome, #1a1a19 content, #20201f card, #2e2e2d hover;
the "darker" tone drops chrome to true black. Light gains only a zinc-50
chrome step.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-14 13:20:47 +02:00
jgamboaandClaude 697f7f22c8 Rename Copy to Send and put it first in the row context menu
The main feature (copy selection to the other panel, F5) is now labeled
"Send" in the toolbar button, tooltips, and context menu across all 7
locales. Internal identifiers (copyToOtherPanel, ctx-copy, i18n keys)
and secondary strings (toasts, status bar, errors) are unchanged.
The context-menu entry moves ahead of Open/Edit as the first row item.
README, marketing site, and the Windows QA checklist updated to match.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-14 13:12:05 +02:00
jgamboaandClaude Fable 5 f87077b2b7 Document expected memory footprint
~200 MB macOS / ~300 MB Windows is normal for a Bun + system-WebView app;
record the process breakdown, why Windows (WebView2/Chromium) is higher,
that it's not a bug, and the only real growth levers, so it isn't
re-investigated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:17:34 +02:00
jgamboa b5b1320776 sections reordered 2026-07-15 13:09:29 +02:00
Jokin 3d8c410a82 little text v2 2026-07-14 20:23:43 +02:00
jgamboaandClaude Fable 5 b2cf000293 Fix ⌘Q not quitting on macOS
The Quit item was role:"quit" with no accelerator, so ⌘Q was never
bound. Make it a custom action + accelerator "q" dispatched to
Utils.quit(), which emits before-quit so the existing guard (active
transfers / unsaved editors, native confirm dialog) runs instead of the
OS terminating directly. Handled before the manager-open check so ⌘Q
works with the connection manager open too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:51:29 +02:00
jgamboa f86b10335e little text 2026-07-14 17:08:32 +02:00
jgamboa 6ecb1b8339 disable analytics 2026-07-14 16:50:22 +02:00
jgamboaandClaude a40433b99e Add built-in auto-updater with tab-bar update pill
Wires electrobun's Updater to the /dl download host. The packaged app checks
https://miniscp.com/dl on launch + every 6h; when a newer build exists, an
accent-colored "update tool" appears next to Connections in the tab bar. It
announces the update, becomes its own inline progress bar while downloading,
then flips to "Restart to apply" (two-step: download, then user-triggered
relaunch).

- electrobun.config.ts: release.baseUrl = https://miniscp.com/dl (baked into
  version.json at build; miniscp.com fronted by an external proxy for TLS).
- rpc.ts: checkForUpdate/downloadUpdate/applyUpdate requests + updateStatus push.
- rpc-handlers.ts + index.ts: call Updater; poll on launch+6h; stream
  onStatusChange progress. checkForUpdate self-guards the dev channel.
- rpc.service.ts + new updater.service.ts: signal-based state
  (available -> downloading -> ready, ready derived from downloadUpdate ok).
- tab-bar.ts: single bg-primary pill; progress fills the pill itself.
- icons.ts: register mynaDownload. i18n: tabBar + menu keys across all locales.
- menu.ts + app.ts: "Check for Updates…" app-menu item -> UpdaterService.check().
- docs: build-and-release + architecture updated.

Unsigned builds still update (applyUpdate strips the macOS quarantine xattr).
Verified: tsc --noEmit (exit 0) + build:ui AOT (exit 0). Live pill + full
download/apply flow require a two-build test on the host.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 16:43:40 +02:00
jgamboaandClaude 43f1168d6a Add landing page + Caddy download host (site/)
Self-contained, no-JS landing page (tinygrad-minimal) that doubles as the
artifact download host, served by Caddy on an internal :80 box.

- site/index.html: static page, "Same tool, only a little different", feature
  highlights (remote->remote, symmetric panels, ssh_config awareness, edit-in-
  place, self-copy guard, ...), fixed stable download links, FAQ (unsigned notes).
- site/Caddyfile: one flat /dl/ folder with browse + range requests and per-type
  Cache-Control (patches immutable, update.json no-cache, installers short).
- site/deploy.sh: drop a build's artifacts/stable-* into /dl/ (renames the win
  portable zip to a stable name).
- Flat folder matches electrobun's artifacts/ layout, so it's also the future
  updater baseUrl; per-build folders/symlink dropped in favor of it.
- docs/build-and-release.md: document the download host + deploy flow.

Verified: caddy validate + live smoke test (links, cache headers, 206 range,
404 fallback, deploy.sh copy/rename) against a temp root.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 15:59:05 +02:00
jgamboaandClaude Fable 5 a0f56822cd Internationalization: 7 languages with live switching
Custom signal-based runtime (no i18n dependency): shared src/shared/i18n
dictionaries, ui I18nService.t() reactive by construction so language
switches live, and a backend i18n.ts for native menus + thrown errors.
Every user-facing string keyed across templates, toasts, dialogs, file
kinds, columns, macOS menu/context menus, and errors. Language picker in
Options (auto-detects OS locale, falls back to English); switching
relabels the webview and native chrome instantly. English/Spanish/French/
German fully drafted; Chinese/Japanese/Basque drafted, flagged for native
review. Docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:52:54 +02:00
jgamboaandClaude d8cfc54049 Add CLAUDE.md working agreements
Codify three standing behaviors so they persist across sessions: ask
clarifying questions in plan mode until unambiguous; verify then
auto-commit + push after an approved plan; keep docs/ up to date in the
same change.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 14:18:15 +02:00
jgamboaandClaude Fable 5 ee5a9e13e6 Auto-refresh the active tab's panels (silent, non-disturbing)
Local panels are fs.watched (LocalDirWatcher + setWatchedDirs -> pathRefresh);
remote panels poll every 3s. New refresh({silent}) skips the loading/busy
flags and touches no DOM, snapshotting sessionId+cwd to discard results if the
user navigated mid-flight. Only the active tab is live; switching tabs
refreshes the new pair. A future on/off setting is left as commented
breadcrumbs. Docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:03:35 +02:00
jgamboa 260e2d64b2 new icon and assets 2026-07-14 14:00:36 +02:00
137 changed files with 12571 additions and 899 deletions
+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/
# headless screenshot workspace (scripts/shot.ts)
.shot/
+40
View File
@@ -0,0 +1,40 @@
# Working agreements for miniSCP
## Orientation
`docs/` is the source of truth — read the relevant file before touching adjacent code:
[architecture.md](docs/architecture.md) (process/window model, bun modules, UI services, theming),
[gotchas.md](docs/gotchas.md) (landmines already hit — read before changing nearby code),
[build-and-release.md](docs/build-and-release.md), [platform-notes.md](docs/platform-notes.md).
## Planning
In plan mode, use `AskUserQuestion` to resolve **every** ambiguity before finalizing a
plan. Never guess at intent or silently pick behavior the user didn't specify — keep asking
until the approach is unambiguous, then exit planning.
## After an approved plan is executed
1. **Verify first** — backend: `bun run ./node_modules/typescript/bin/tsc --noEmit -p tsconfig.json`;
UI: `bun run build:ui`; plus any smoke test that fits the change. (`bun run build:ui` is the
full AOT type-check for Angular — trust it over IDE squiggles.)
2. **Then commit + push automatically, no need to ask** — a descriptive message ending with:
```
Co-Authored-By: Claude <noreply@anthropic.com>
```
Push to the current branch (this repo works directly on `main`).
3. If verification fails, fix it or report back — do **not** commit broken work.
## Keep the live documentation current
`docs/` is living, not a one-time write. When a change alters the architecture/process model,
a module's or service's responsibility, the RPC surface, theming, or introduces a new landmine,
update the matching doc **in the same change, before the auto-commit**:
- `docs/architecture.md` — structure, bun modules, UI services, theming.
- `docs/gotchas.md` — anything that caused a real bug or debugging spiral.
- `docs/build-and-release.md` / `docs/platform-notes.md` — when build or platform behavior changes.
Match the existing dense, bulleted house style.
+45 -7
View File
@@ -15,14 +15,15 @@ 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
- Dual-panel commander UI; either panel can be Local or any saved connection. - Dual-panel commander UI; either panel can be Local or any saved connection.
- **Session tabs**: each tab is an independent left/right workspace; remote - **Session tabs**: each tab is an independent left/right workspace; remote
sessions stay connected and transfers keep running in background tabs. sessions stay connected and transfers keep running in background tabs.
- Per-panel action toolbar with a direction-explicit copy button, plus a - Per-panel action toolbar with a direction-explicit send button, plus a
VSCode-style status bar showing live transfer progress (click to expand the VSCode-style status bar showing live transfer progress (click to expand the
transfer queue with per-file speed and ETA). transfer queue with per-file speed and ETA).
- **Overwrite confirmation** on name collisions, with a source-vs-destination - **Overwrite confirmation** on name collisions, with a source-vs-destination
@@ -42,7 +43,7 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu
cleanup at the next start. cleanup at the next start.
- Browse, sort (name/size/modified, folders first), keyboard navigation. - Browse, sort (name/size/modified, folders first), keyboard navigation.
- Create folder / file, rename, delete (recursive), change permissions (chmod). - Create folder / file, rename, delete (recursive), change permissions (chmod).
- Upload, download, and remote→remote copy — files **and** whole folders — - Upload, download, and remote→remote send — files **and** whole folders —
with a transfer queue, per-job progress, and cancel. with a transfer queue, per-job progress, and cancel.
- Connection manager: saved connections organised into folders, password or - Connection manager: saved connections organised into folders, password or
private-key auth (with passphrase). private-key auth (with passphrase).
@@ -75,6 +76,11 @@ 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.
@@ -107,11 +113,14 @@ cross-compile between OSes:
bun run release -- --target mac-arm64 # on the Mac (native) bun run release -- --target mac-arm64 # on the Mac (native)
bun run release -- --target mac-x64 # on the same arm64 Mac, via Rosetta 2 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` per mac arch; 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 a portable `.tar.gz` (unpack and run
`miniSCP.sh`, which sets the NVIDIA workaround then execs `bin/launcher`).
The mac-x64 build fetches electrobun's x64 CLI once
into `~/.cache/miniscp/` and runs it under Rosetta so the whole pipeline into `~/.cache/miniscp/` and runs it under Rosetta so the whole pipeline
believes it is on an Intel Mac. CI: `.gitea/workflows/release.yml` runs the believes it is on an Intel Mac. 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
@@ -170,7 +179,7 @@ Saved connections live in a plain JSON file in the OS app-data directory
`Tab` switch panel · `↑`/`↓`/`PgUp`/`PgDn`/`Home`/`End` move cursor · `Tab` switch panel · `↑`/`↓`/`PgUp`/`PgDn`/`Home`/`End` move cursor ·
`Space` select · `Enter` open folder · `Backspace` parent · `Space` select · `Enter` open folder · `Backspace` parent ·
`Ctrl/Cmd+A` select all · `F5` copy 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.
@@ -210,6 +219,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/context-menu.ts](ui/src/app/context-menu.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
@@ -231,6 +246,29 @@ 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 builds a portable tarball.
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 unpackaged**: no AppImage, `.deb`, desktop entry or icon
installation — unpack the tarball and run `miniSCP.sh`.
- **NVIDIA GPUs** need `WEBKIT_DISABLE_DMABUF_RENDERER=1` or the window renders
black. Every launch path (dev scripts and the packaged `miniSCP.sh` wrapper)
sets it automatically when an NVIDIA driver is detected — see
[gotchas](docs/gotchas.md#linux) if you add a new way to launch the app.
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
@@ -246,5 +284,5 @@ portable zip runs from any folder.
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 desktop integration (AppImage/.deb/.desktop entry —
packaging is wired up). the release is a portable tarball).
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 619 B

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 24 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Bunny icon">
<!--
Front-facing bunny app icon.
• Absolute background: TRANSPARENT (nothing drawn outside the rounded square).
• ICON BACKGROUND = #0d0d0d (the rounded square)
• BUNNY (fur) = #f7f7f5 (soft white; use #ffffff for pure white)
Recolor the fur by editing the one fill on the <g>.
• Parts: ears = ellipses (rotate = tilt) | head+body = circle |
feet = two rounded ellipses (rx = width, ry = height; rotate = splay).
Only change from the previous version: the feet are now chubby, gently
splayed paws instead of pointy ovals.
-->
<rect x="24" y="24" width="464" height="464" rx="108" fill="#0d0d0d"/>
<g fill="#f7f7f5">
<ellipse cx="205" cy="185" rx="34" ry="105" transform="rotate(-9 205 185)"/> <!-- left ear -->
<ellipse cx="307" cy="185" rx="34" ry="105" transform="rotate(9 307 185)"/> <!-- right ear -->
<circle cx="256" cy="305" r="98"/> <!-- head + body -->
<ellipse cx="203" cy="400" rx="32" ry="24" transform="rotate(-12 203 400)"/> <!-- left foot (cute) -->
<ellipse cx="309" cy="400" rx="32" ry="24" transform="rotate(12 309 400)"/> <!-- right foot (cute) -->
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+92 -27
View File
@@ -1,29 +1,94 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- BunSCP master logo. Glyph: the two diagonal arrows of mynaui <svg
arrow-diagonal-one with the mini-lock of mynaui lock-square centered viewBox="0 0 512 512"
between them; the squircle itself plays the role of lock-square's role="img"
outer square. Regenerate all raster assets with scripts/gen-icons.ts. --> aria-label="Bunny icon"
<defs> version="1.1"
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"> id="svg4"
<stop offset="0" stop-color="#818cf8"/> sodipodi:docname="logo.svg"
<stop offset="0.5" stop-color="#4f46e5"/> inkscape:version="1.4.4 (dcaf3e7, 2026-05-05)"
<stop offset="1" stop-color="#1e1b4b"/> xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
</linearGradient> xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
</defs> xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<rect x="100" y="100" width="824" height="824" rx="186" fill="url(#bg)"/> <defs
id="defs4" />
<g transform="translate(232 232) scale(23.333)" <sodipodi:namedview
fill="none" stroke="#fafafa" stroke-width="1.5" id="namedview4"
stroke-linecap="round" stroke-linejoin="round"> pagecolor="#ffffff"
<!-- arrow to top-left --> bordercolor="#000000"
<path d="M8.2 8.2 4.6 4.6m0 0v4.2m0-4.2h4.2"/> borderopacity="0.25"
<!-- arrow to bottom-right --> inkscape:showpageshadow="2"
<path d="m15.8 15.8 3.6 3.6m0 0v-4.2m0 4.2h-4.2"/> inkscape:pageopacity="0.0"
<!-- lock: shackle --> inkscape:pagecheckerboard="0"
<path d="M13.3 11.2V9.9c0-1.95-2.6-1.95-2.6 0v1.3"/> inkscape:deskcolor="#d1d1d1"
<!-- lock: body (amber accent) --> inkscape:zoom="1.71875"
<rect x="9.4" y="11.2" width="5.2" height="4.6" rx="1.1" inkscape:cx="256.29091"
fill="#fbbf24" stroke="#fafafa"/> inkscape:cy="256.29091"
inkscape:window-width="2560"
inkscape:window-height="1382"
inkscape:window-x="2560"
inkscape:window-y="242"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<!--
Front-facing bunny app icon.
• Absolute background: TRANSPARENT (nothing drawn outside the rounded square).
• ICON BACKGROUND = #0d0d0d (the rounded square)
• BUNNY (fur) = #f7f7f5 (soft white; use #ffffff for pure white)
Recolor the fur by editing the one fill on the <g>.
• Parts: ears = ellipses (rotate = tilt) | head+body = circle |
feet = two rounded ellipses (rx = width, ry = height; rotate = splay).
Only change from the previous version: the feet are now chubby, gently
splayed paws instead of pointy ovals.
-->
<g
fill="#f7f7f5"
id="g4"
style="fill:#0e0e0c;fill-opacity:1"
transform="matrix(1.3118908,0,0,1.3118908,-79.844041,-78.839213)">
<ellipse
cx="205"
cy="185"
rx="34"
ry="105"
transform="rotate(-9,205,185)"
id="ellipse1"
style="fill:#0e0e0c;fill-opacity:1" />
<!-- left ear -->
<ellipse
cx="307"
cy="185"
rx="34"
ry="105"
transform="rotate(9,307,185)"
id="ellipse2"
style="fill:#0e0e0c;fill-opacity:1" />
<!-- right ear -->
<circle
cx="256"
cy="305"
r="98"
id="circle2"
style="fill:#0e0e0c;fill-opacity:1" />
<!-- head + body -->
<ellipse
cx="203"
cy="400"
rx="32"
ry="24"
transform="rotate(-12,203,400)"
id="ellipse3"
style="fill:#0e0e0c;fill-opacity:1" />
<!-- left foot (cute) -->
<ellipse
cx="309"
cy="400"
rx="32"
ry="24"
transform="rotate(12,309,400)"
id="ellipse4"
style="fill:#0e0e0c;fill-opacity:1" />
<!-- right foot (cute) -->
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 B

After

Width:  |  Height:  |  Size: 855 B

+92 -10
View File
@@ -1,12 +1,94 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- BunSCP tray glyph: black + alpha only (macOS template image). <svg
Same geometry as logo.svg, slightly bolder for menu-bar sizes. --> viewBox="0 0 512 512"
<g fill="none" stroke="#000" stroke-width="1.5" role="img"
stroke-linecap="round" stroke-linejoin="round"> aria-label="Bunny icon"
<path d="M8.2 8.2 4.6 4.6m0 0v4.2m0-4.2h4.2"/> version="1.1"
<path d="m15.8 15.8 3.6 3.6m0 0v-4.2m0 4.2h-4.2"/> id="svg4"
<!-- lock: outlined so the shackle + body silhouette stays readable small --> sodipodi:docname="tray-glyph.svg"
<path d="M13.55 11V9.7c0-2.1-3.1-2.1-3.1 0V11"/> inkscape:version="1.4.4 (dcaf3e7, 2026-05-05)"
<rect x="9.1" y="11" width="5.8" height="5" rx="1.2"/> xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs4" />
<sodipodi:namedview
id="namedview4"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.71875"
inkscape:cx="256.29091"
inkscape:cy="256.29091"
inkscape:window-width="2560"
inkscape:window-height="1382"
inkscape:window-x="2560"
inkscape:window-y="242"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<!--
Front-facing bunny app icon.
• Absolute background: TRANSPARENT (nothing drawn outside the rounded square).
• ICON BACKGROUND = #0d0d0d (the rounded square)
• BUNNY (fur) = #f7f7f5 (soft white; use #ffffff for pure white)
Recolor the fur by editing the one fill on the <g>.
• Parts: ears = ellipses (rotate = tilt) | head+body = circle |
feet = two rounded ellipses (rx = width, ry = height; rotate = splay).
Only change from the previous version: the feet are now chubby, gently
splayed paws instead of pointy ovals.
-->
<g
fill="#f7f7f5"
id="g4"
style="fill:#ecece8;fill-opacity:1"
transform="matrix(1.3118908,0,0,1.3118908,-79.844041,-78.839213)">
<ellipse
cx="205"
cy="185"
rx="34"
ry="105"
transform="rotate(-9,205,185)"
id="ellipse1"
style="fill:#ecece8;fill-opacity:1" />
<!-- left ear -->
<ellipse
cx="307"
cy="185"
rx="34"
ry="105"
transform="rotate(9,307,185)"
id="ellipse2"
style="fill:#ecece8;fill-opacity:1" />
<!-- right ear -->
<circle
cx="256"
cy="305"
r="98"
id="circle2"
style="fill:#ecece8;fill-opacity:1" />
<!-- head + body -->
<ellipse
cx="203"
cy="400"
rx="32"
ry="24"
transform="rotate(-12,203,400)"
id="ellipse3"
style="fill:#ecece8;fill-opacity:1" />
<!-- left foot (cute) -->
<ellipse
cx="309"
cy="400"
rx="32"
ry="24"
transform="rotate(12,309,400)"
id="ellipse4"
style="fill:#ecece8;fill-opacity:1" />
<!-- right foot (cute) -->
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 621 B

After

Width:  |  Height:  |  Size: 2.7 KiB

+3
View File
@@ -21,6 +21,7 @@
"name": "ui", "name": "ui",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"7.css": "^0.21.1",
"@angular/common": "^22.0.0", "@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0", "@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0", "@angular/core": "^22.0.0",
@@ -45,6 +46,8 @@
"@parcel/watcher", "@parcel/watcher",
], ],
"packages": { "packages": {
"7.css": ["7.css@0.21.1", "", {}, "sha512-Q+/aFUpGemoR/Zltj4a7yvHonyxfb+9cgiwBL+j/Lm+H28EOoLzWoJki7MdGPV+3mLCvKSCVqr3M2x2uNYyeWw=="],
"@algolia/abtesting": ["@algolia/abtesting@1.18.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-8siuLG+FIns1AjZ/g2SDVwHz9S+ObacDQISEJvS8XsNei1zl3FXqfqQrBpmrG7ACWCyesXHbicMJtvRbg00FEw=="], "@algolia/abtesting": ["@algolia/abtesting@1.18.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-8siuLG+FIns1AjZ/g2SDVwHz9S+ObacDQISEJvS8XsNei1zl3FXqfqQrBpmrG7ACWCyesXHbicMJtvRbg00FEw=="],
"@algolia/client-abtesting": ["@algolia/client-abtesting@5.52.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-wtwPgyPmO7b7sQPVgoK29c1VpfS08DnnJCmxX/oU1pV2DlMRJCzQcLN7JSloYpodyKHwM8+9wOzlAM0co3TDmA=="], "@algolia/client-abtesting": ["@algolia/client-abtesting@5.52.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-wtwPgyPmO7b7sQPVgoK29c1VpfS08DnnJCmxX/oU1pV2DlMRJCzQcLN7JSloYpodyKHwM8+9wOzlAM0co3TDmA=="],
+324 -24
View File
@@ -10,7 +10,8 @@ Dense reference for development and debugging. Companion files:
its native launcher. Owns every privileged capability: filesystem, SSH, its native launcher. Owns every privileged capability: filesystem, SSH,
window management, native menus/dialogs/tray, the connection/settings stores. 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`. Requests are typed by the `AppRPC` schema in `src/shared/rpc.ts`.
@@ -42,14 +43,18 @@ no-op functions, reassigned in `src/bun/index.ts` after everything exists:
## 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`), `__APP_SKIN`
`ui/src/main.ts` picks the root component from `APP_MODE`; all three roots use (`modern|win7`), `__APP_CTX` before any page script. `ui/src/main.ts` selects on
selector `app-root` (index.html hosts only that tag). `openSecondaryWindow` is BOTH axes: the skin picks a component tree, `APP_MODE` picks a root within it
the ONLY place that branches platform for secondary chrome: macOS (see "UI layout" below). All roots use selector `app-root` (index.html hosts
`hiddenInset` + `trafficLightOffset {x:12,y:13}` (the UI renders an only that tag). Both globals are preload-baked because both are needed before
`h-10 pl-24` drag-region header via `IS_MAC_CHROME`); other platforms keep the first paint — settings arrive over an async RPC, far too late to choose a tree
native titlebar and a compact `h-9` non-drag header. without flashing the wrong UI. `chromeOptions(skin)` is the ONLY place the
native frame is decided: win7 is frameless + transparent everywhere, modern
keeps the native titlebar (macOS `hiddenInset` + `trafficLightOffset
{x:12,y:13}`, where the shadlike tree renders an `h-10 pl-24` drag-region header
via `IS_MAC_CHROME`).
`WindowRegistry<Rec>` (same file): keyed window map with dedupe/activate, `WindowRegistry<Rec>` (same file): keyed window map with dedupe/activate,
cascade placement off an anchor frame, focus tracking. electrobun's emitter cascade placement off an anchor frame, focus tracking. electrobun's emitter
@@ -57,7 +62,7 @@ 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/*/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).
@@ -114,6 +119,12 @@ would float over editor windows).
callback. mtime seconds→ms; mode masked `& 0o7777`. callback. mtime seconds→ms; mode masked `& 0o7777`.
- `fs/registry.ts` — sessionId → provider map; `"local"` is built-in; - `fs/registry.ts` — sessionId → provider map; `"local"` is built-in;
`add()` mints `sftp-<n>`; `drop()` (session died) vs `remove()` (clean). `add()` mints `sftp-<n>`; `drop()` (session died) vs `remove()` (clean).
- `fs/watcher.ts` — `LocalDirWatcher`: `fs.watch` handles keyed by dir,
debounced ~150 ms (watch fires bursts). Drives local auto-refresh: the main
webview sends `setWatchedDirs` (the active tab's local panel cwds); a change
fires `onChange` → `rpc.send.pathRefresh({sessionId:"local", dir})`.
`onChange` is late-bound in index.ts (the `events` pattern). SFTP has no
watch, so remote panels are polled UI-side instead (see panels.service).
- `transfers/engine.ts` — FIFO job queue. `Semaphore` with live resize - `transfers/engine.ts` — FIFO job queue. `Semaphore` with live resize
(`wake()` reserves slots synchronously — an async waiter increment was a (`wake()` reserves slots synchronously — an async waiter increment was a
race). Per-job worker pool (`MAX_WORKERS_PER_JOB=8`) draws from the shared race). Per-job worker pool (`MAX_WORKERS_PER_JOB=8`) draws from the shared
@@ -142,13 +153,17 @@ 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). ⌘W is intercepted by the menu, so bun
routes `close-tab` to the focused editor window first. routes `close-tab` to the focused editor window first. Labels go through
backend `i18n.t` and the menu is rebuilt on a locale change too.
- `i18n.ts` — backend translator (native menus + thrown error messages, which
no Angular service can reach). `setLocale(setting)` caches the resolved
locale (from `settings.locale`, `system` via `Intl…resolvedOptions().locale`);
`t(key, params)` shares the same `src/shared/i18n` dictionaries as the UI.
Wired in index.ts on boot and on `settingsChanged` (relocalizes + rebuilds
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).
@@ -156,7 +171,41 @@ 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: one core, two skin trees
```
ui/src/app/
core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts,
and all 12 *.service.ts — everything skin-agnostic
skins/
shadlike/ the modern (shadcn-style) tree + shadlike.css
7like/ the Windows 7 tree + 7like.css + fonts/ + title-bar/
```
Each skin owns a **complete** component tree, including its own three window
roots, and exports a barrel (`SHAD_ROOTS` / `SEVEN_ROOTS`) with the same
`{ main, manager, editor }` shape. `main.ts` therefore knows nothing about
either tree's internals: `APP_SKIN` picks the barrel, `APP_MODE` picks the root.
Both trees are imported statically into one bundle — the inactive skin costs
bundle size but no runtime work, which keeps bootstrap synchronous.
Duplicate selectors across trees (`app-file-panel` exists in both) are safe:
standalone components resolve through their importer's `imports:` array, never
a global registry, and the two trees are never mounted together.
Only `7like` has a `title-bar/`. The modern skin defers to the native frame,
which is exactly what `IS_MAC_CHROME` accounts for.
**Why this split was cheap**: the service layer was already skin-agnostic —
`git diff main...feat/7css-skin` touched none of it. Components reach it through
the **`@core/*`** tsconfig alias (alongside `@shared/*` → `src/shared`), which
is deliberate: skin 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**: shared behaviour → `core/`; markup and appearance for
one look → that skin's folder; tokens both skins consume → `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
@@ -172,7 +221,15 @@ would float over editor windows).
auto-load `localDir` into an untouched local panel). `connectToken` auto-load `localDir` into an untouched local panel). `connectToken`
supersedes stale connects. `Workspace` = tab = left/right pair + activeSide. supersedes stale connects. `Workspace` = tab = left/right pair + activeSide.
Remote goes RIGHT on connect/open-stack (commander convention); per-panel Remote goes RIGHT on connect/open-stack (commander convention); per-panel
dropdown connects target their own panel. dropdown connects target their own panel. **Auto-refresh** (always on):
`refresh({silent})` re-lists without the loading/busy flags and touches no
DOM (scroll/cursor/selection/filter preserved), snapshotting sessionId+cwd
to discard results if the user navigated mid-flight. Only the active tab is
live: its local panels are fs.watched (backend `setWatchedDirs` effect +
`pathRefresh` push → silent refresh), its remote panels polled every 3s
(`REMOTE_POLL_MS`); switching tabs silently refreshes the newly-active pair.
A future on/off setting is stubbed in commented breadcrumbs (types.ts,
settings.service, tab-bar).
- `transfers.service.ts` — jobMap from `transferUpdate` callbacks; EMA speed - `transfers.service.ts` — jobMap from `transferUpdate` callbacks; EMA speed
(`SPEED_SMOOTHING=0.7`); `copyToOtherPanel` guards same-fs+same-dir before (`SPEED_SMOOTHING=0.7`); `copyToOtherPanel` guards same-fs+same-dir before
enqueue and runs the collision flow (same-path auto-skip, type-mismatch enqueue and runs the collision flow (same-path auto-skip, type-mismatch
@@ -188,25 +245,268 @@ would float over editor windows).
(whose constructor spawns a workspace — fatal in secondary windows). (whose constructor spawns a workspace — fatal in secondary windows).
- `connections.service.ts` — store mirror + ssh-config hosts; reloads on the - `connections.service.ts` — store mirror + ssh-config hosts; reloads on the
`connectionsChanged` push. `connectionsChanged` push.
- `i18n.service.ts` — runtime translator. `locale` signal + `t('ns.key', params)`
(dotted lookup, `{param}` interpolation, `{one,other}` plural maps via
`Intl.PluralRules`); missing keys fall back to English then the raw key.
**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
tracking as `iconClass`) — no pipe. Also exposes locale-aware `date()`/`size()`
/`speed()` so the file list reformats on switch. `SettingsService.applyLocale`
resolves the `locale` setting (`system` → `navigator.language`) into it, on
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` + `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 — Win7 greys out, and a menu whose items move between right-clicks is
worse than one with a dead row. 7.css hangs that state on
`li[role=menuitem][aria-disabled]` (opacity + `pointer-events: none`), which
the keyboard path never consults — so `pick()` and the arrow-key walker check
the flag themselves, in both skins.
- `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, never accent-tinted) and the Kind column.
- `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
`state` (`idle→available→downloading→ready`|`error`), `version`, `progress`.
Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on
launch+6h, `onStatusChange` progress); `ready` is derived when the
`downloadUpdate` request resolves ok (not a pushed phase). `download()` /
`apply()` (relaunch) / `check()` (menu). Backend wiring lives in
`src/bun/index.ts`; feed is `release.baseUrl` (`https://miniscp.com/dl`, the
`/dl` host in `site/`). See build-and-release.md "Auto-updater".
## Theming ## Theming
`ui/src/styles.css`: shadcn-style OKLCH tokens; dark values are duplicated in `ui/src/styles.css`: shadcn-style OKLCH tokens; dark values are duplicated in
`:root[data-theme='dark']` AND a `prefers-color-scheme` media block (system `:root[data-theme='dark']` AND a `prefers-color-scheme` media block (system
mode) — every dark token change goes in BOTH. Accent tones: one mode) — every dark token change goes in BOTH. Light is the zinc family; the
dark neutrals are a **warm gray family (hue 107)** anchored on `#1a1a19`
(`--background`) — retoning one dark neutral means retoning them all, or warm
and cool surfaces clash. Surface elevation ramp, both themes: `--chrome`
(frame strips) sits *below* `--background` (content panes) < `--card`
(popovers/dialogs) < `--accent` (hover). Dark ramp `#151514` → `#1a1a19` →
`#20201f` → `#2e2e2d`. The "darker" tone is true-black AMOLED: `--chrome` AND
`--background` are both `#000` (the frame/content step collapses on purpose),
elevation survives only via `--card`/`--accent`, and `--border`/`--input` go to
16%/18% white because those lines then carry the whole layout.
`bg-chrome` is on the tab bar, status bar, transfer bar, the editor/manager
header+footer strips and the manager's connection sidebar; panel-internal
toolbars deliberately stay on `--background`. Accent tones: one
`--tone-h/--tone-c` line per tone + three formula blocks derive `--primary`, `--tone-h/--tone-c` line per tone + three formula blocks derive `--primary`,
`--ring`, `--cursor-tint`; graphite = attribute removed = pristine zinc. `--ring`, `--cursor-tint`; graphite = attribute removed = pristine zinc.
`@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-cursor-tint`). Selected rows are `bg-primary/15`
(follow the accent); the cursor row uses `--cursor-tint` so graphite stays (follow the accent); the cursor row uses `--cursor-tint` so graphite stays
pixel-identical to the old neutral hover. Text-size levels set `--row-h`, pixel-identical to the old neutral hover. **Interface scale** (`settings.uiScale`,
`--list-font` etc. per `data-text` attribute, consumed via `h-(--row-h)` / 0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets
`text-(length:--list-font)` arbitrary utilities. `html { font-size: 16px * scale }`, and since EVERY size in the app is rem
(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes, `--w7-fs`
and the 7.css bridge in 7like.css), both skins scale whole, chrome included.
Driven by the tab-bar stepper, Ctrl/Cmd +/−/0, and preload-baked for a
flash-free first paint. It replaced the 3-tier `data-text` axis; the store
migrates old `textSize` values. TS that measures rem-sized CSS converts through
`@core/metrics` `remPx()` — never an assumed 16.
### Skins (`settings.skin`: `modern | win7`)
A fifth theming axis, and the only one that is a **class** (`.win7` on
`<html>`) rather than a `data-*` attribute — `7.css/dist/7.scoped.css` is
precompiled against a literal `.win7`, so the name is not ours to pick.
Default `modern` = class absent, matching the delete-when-default rule.
`buildPreload` is the **only** writer of that class, ever — there is
deliberately no `SettingsService.applySkin`.
**Unlike the other four axes, this one is launch-time only.** It selects a
whole component tree (see "UI layout") as well as CSS, and neither half can
change on a live window: the tree is bootstrapped once from the preload-baked
`__APP_SKIN`, and `titleBarStyle`/`transparent` are `BrowserWindow` constructor
options with no setters. Clicking a skin therefore only persists it; the
options row shows the saved value as pending (`skinPending()` compares
`settings.skin()` against `APP_SKIN`) and the running UI is untouched until the
next app start.
The bun side pins this: `initSessionSkin` freezes the skin at startup, before
any window exists, and both `buildPreload` and `chromeOptions` read that pinned
value rather than the live settings snapshot. Otherwise an editor or manager
opened after a pending change would boot the *new* tree and frame while the
main window still ran the old one.
**Stylesheet split.** `ui/src/styles.css` holds only what BOTH skins consume —
the `@layer` statement, the Tailwind import, OKLCH tokens, `@theme inline`,
panel-chrome tokens, animations, the accent axis, and the `html, body` base. Each
skin's own rules live in its folder (`skins/7like/7like.css`,
`skins/shadlike/shadlike.css`) and are `@import`ed from there. `shadlike.css` is
near-empty by nature: that skin IS the shared token system plus inline Tailwind.
Two constraints: the `@layer` statement must stay the FIRST statement in
`styles.css` (it is one of only two rule types allowed above `@import`), and
`@font-face` `url()`s resolve relative to the file that declares them — which is
why 7like's Noto Sans lives at `skins/7like/fonts/`, inside the skin.
**Cascade** (`ui/src/styles.css` first lines + `skins/7like/7like.css`): the
explicit `@layer theme, base, components, utilities, skin, skinfix;` statement
precedes every import. 7.css is imported with `layer(skin)`, i.e. **above
`utilities`**,
so a bare `button` rule in 7.css outranks `rounded-md` on that same button —
that inversion is what reskins ~1900 inline utility classes with no template
edits. Consequences: (1) any utility later added to a `button`/`input`/
`select`/`textarea`/`table` silently loses under the skin; (2) neutralizers
that want the Tailwind value back must sit **inside `@layer skin`**, not
`skinfix` — `revert-layer` rolls back one layer, so from `skin` it lands on
`utilities`, but from `skinfix` it lands back on 7.css and does nothing.
`skinfix` is for rules that state an explicit value (`.w7-btn`).
**Token bridge** (`skins/7like/7like.css`): `:root.win7` re-points the existing
semantic tokens at Win7 values, so utilities Tailwind still controls come out
right too. It adds NO new tokens on purpose — a new colour would need another
`@theme inline` line. The Win7 surface ramp **inverts** the modern one:
`--background` (content panes) is Explorer white and `--chrome` is the darker
`#f0f0f0` window surface. `--radius: 5px` so `rounded-md`
(`calc(--radius - 2px)`, 74 uses) lands exactly on 7.css's 3px `--w7-el-bdr`.
`--primary`/`--ring` are deliberately left alone so the accent axis stays live;
`:root.win7[data-accent]` feeds it to `--w7-w-bg`, which is authentic (Win7
shipped "Window Color"). Dark Win7 is hand-authored (Microsoft never shipped
one) as ~20 `--w7-*` overrides, duplicated forced + system like every other
dark block. `--w7-font` is restated as a rem `font` shorthand so the interface
scale reaches it and 7.css's `pt` shorthand stops resetting weight/line-height;
the rest of 7.css's px metrics are re-expressed in rem by the scale bridge in
7like.css's `skinfix`.
**Markup hooks**: `role="progressbar"` (+aria) on the transfer bars,
`has-scrollbar` on scroll containers (7.css only styles `::-webkit-scrollbar`
under that class), `w7-toolbar` on every command row — the three window header
rows plus each file panel's session strip and path bar (their buttons go flat
with a hover glow — Explorer's command bar was flat, unlike dialog push
buttons; it only targets `button`, so the path `<input>` keeps its 7.css
textbox treatment), `w7-flat`
as the per-button escape hatch for the same treatment outside a toolbar (the
status-bar transfers summary is a text toggle, not a push button), `w7-btn`
on the 10 dialog action buttons. The flat treatment is split across two
layers on purpose: `background: revert-layer` must sit in `skin` to reach
`utilities`, while `border-color`/`box-shadow` sit in `skinfix` because 7.css
restates `border-color` at (0,3,1) and no sane `.w7-flat` selector outranks
that.
**Tabs**: the workspace strip is 7.css's real tab component —
`menu[role=tablist]` + one `button[role=tab][aria-selected]` per workspace, so
the framework draws the joined inactive tabs and the raised active one. The
selector is a DESCENDANT (`menu[role=tablist] button`), so the `+` button, the
update pill and the right-hand tools deliberately sit outside the `<menu>`.
Tabs are exempted from the `w7-toolbar` flattening via `:not([role='tab'])` —
they want the full Aero slab their neighbours are stripped of. There is no
per-tab ✕ (Win7 tab controls had none, and a nested button is invalid HTML):
closing is `Ctrl/Cmd+W` or middle-click. `role="tabpanel"` on the panel area
was considered and rejected — its `padding: 14px`, `margin-bottom: 9px` and
border fight the split-pane layout for a join that cannot read anyway with the
panel toolbar row in between.
**Frame transparency**: the rounded frame only reads as rounded because
nothing paints an opaque full-bleed ground behind it. That is not free —
`html, body` paint `var(--background)` **unlayered**, which beats every
`@layer`, so `.win7` resets them to transparent in an equally unlayered rule
next to the `font-family` one. A maximized window drops the radius (7.css
declares it three times: `.window`, `::before`, `::after` — all three must be
zeroed); the frame reads the state off its own child with
`:has(> .title-bar.is-maximized)`, which `title-bar.ts` publishes from its
existing `maximized` signal rather than subscribing to `windowState` twice.
**Selection band**: the file list's selected row is a FLAT Win7-Explorer blue
(`--w7-sel-bg`, `#cbe8f6`) with **no border** — not 7.css's `--w7-el-grad-h`,
which is its Aero element-hover ramp and stays reserved for buttons and tabs
that still want the gloss, and not its `#aaddfa` row outline, which stacked a
second edge inside the fill and, with the cursor row's dotted rect, drew three
concentric outlines on one row. Every row still reserves `border: 1px solid
transparent` for geometry; the fill paints under it (`background-clip`
defaults to `border-box`). The unfocused pane gets `--w7-sel-bg-idle`,
Explorer's grey inactive selection — the band never disappears with focus,
only weakens (the commander convention above). Both are dark-overridden in
BOTH dark blocks. The context menu's item highlight uses the same
`--w7-sel-bg`, so selection reads identically wherever it appears.
**Context menu**: pure 7.css via its real menu contract —
`ul[role=menu] > li[role=menuitem] > button` for actions,
`input[type=checkbox] + label` for the checkable column toggles, dividers as
`.has-divider` on the PRECEDING item (an `::after` bevel, not a node). The
contract is exact: an earlier iteration rendered `div > button`, matched
nothing, and wrongly concluded "7.css has no menu component" — it has a full
one (`dist/gui/menu.css`), including the gutter stripe, disabled state and
submenu arrows. Keyboard moves REAL focus between items (7.css styles
`:focus-visible` like `:hover`); Enter/Space activate buttons natively. Only
menu.css's hardcoded light literals are restated for dark, in `skinfix` — plus
three overrides keyed on the app's own `w7-ctx` marker: `position: fixed`
(7.css's `position: relative` on `ul[role=menu]` had been silently winning),
the flat `--w7-sel-bg` highlight in place of its gradient wash, and
`content: none` on the item buttons' pseudo-elements, which kills both the
1s Aero afterglow and the infinite `pulse-anim` on a focused item (see
gotchas.md). `scripts/menu-probe.ts` regression-tests placement AND highlight
flatness, each with a control that proves the check can fail.
**Font**: Segoe UI is absent off Windows and not redistributable. Noto Sans —
the substitute 7.css itself names — is vendored (OFL-1.1, from
`@fontsource/noto-sans`) into `ui/src/fonts/`; under `src/` and not `public/`
so esbuild emits it hashed into `media/` and rewrites the `url()`.
**Window chrome**: `chromeOptions(skin)` in `src/bun/windows.ts` is the single
place either skin's frame is decided, spread by both the main window and
`openSecondaryWindow`. `win7` uses `titleBarStyle: "hidden"` on **every**
platform — that clears `Titled` from the Cocoa style mask, which is also the
only way to remove the macOS traffic lights. **Linux honours it too**: the GTK
wrapper calls `gtk_window_set_decorated(false)` and logs "GTK: Created window
without decorations (custom titlebar)" (verified on GNOME/Wayland), so the
frameless Aero chrome is not a mac/win-only feature. The webview then draws the whole
frame via `ui/src/app/skins/7like/title-bar/title-bar.ts` (7.css's `.title-bar` >
`.title-bar-text` + `.title-bar-controls`, buttons keyed by `aria-label`),
mounted by all three of that skin's window roots. Because `titleBarStyle` is
fixed at construction, **a skin change only reaches the frame on the next app
start** — and nothing about the skin flips live, deliberately. `IS_WIN7_CHROME` in
`platform.ts` reads the preload-baked `__APP_SKIN` (settings resolve too late
for layout) and forces `IS_MAC_CHROME` false, since there are no traffic
lights to reserve `pl-24` for.
**Window resize (STOPGAP)**: undecorating the window also removes the WM's
resize borders, and electrobun 1.18.1 has no native resize gesture — so
`skins/7like/window-resize/` synthesizes one: a bottom-right grip drags the
frame through `windowGetFrame`/`windowSetFrame` → `BrowserWindow.setFrame`.
Mounted by all three win7 roots, hidden while maximized by a CSS `:has()` rule
(no second `windowState` subscription), and the grip carries
`electrobun-webkit-app-region-no-drag` or the drag region would move the window
instead. **One corner, not eight handles**: the handles are invisible and paint
above the frame, so a north strip or top corner overlays the caption buttons and
steals their `:hover`, which reads as the Aero glow flickering on those buttons.
The bottom-right corner is the only one that overlaps nothing (it sits in the
6px gutter `.window-body`'s margin leaves). The frame math is a dependency-free module so
`scripts/resize-math-probe.ts` can assert all eight edges without a window.
**This is meant to be deleted**: the component header carries the removal
checklist and `scripts/check-electrobun-resize.ts` (wired into `postinstall`)
prints it when upstream ships the primitive. See platform-notes.md.
**Window controls**: new `windowMinimize` / `windowToggleMaximize` /
`windowClose` / `windowIsMaximized` requests, built by
`windowControlHandlers(getWindow, onClose?)` in `rpc-handlers.ts` and served by
all three window kinds (each passing a late-bound getter for its own window —
the window takes the rpc in its constructor, same reason `events` is
late-bound). `onClose` is overridden per window kind so the X reuses the
existing close path: editors run their dirty prompt, the manager runs
`managerClose`, main falls through to the cancellable `before-quit` guard.
electrobun emits **no maximize event**, so `trackMaximizeState` derives it from
`resize` + the synchronous `isMaximized()` and pushes `windowState` — which
also covers Aero Snap and Win+Up. Double-click-to-maximize is likewise absent
from electrobun's drag regions and is implemented in the title-bar component.
+63 -3
View File
@@ -25,7 +25,8 @@ semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel`
## Release builds ## Release builds
`bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64]` (default: host). `bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64|linux-x64]`
(default: host).
Steps: gen-version → gen-icons → Angular build → `electrobun build Steps: gen-version → gen-icons → Angular build → `electrobun build
--env=stable` → copy/rename artifacts into --env=stable` → copy/rename artifacts into
`release/<target>/miniSCP-Build<n>-<target>.*`. `release/<target>/miniSCP-Build<n>-<target>.*`.
@@ -37,7 +38,8 @@ Facts the script encodes:
- **No OS cross-compile.** The CLI builds for its host (its `targets` config - **No OS cross-compile.** The CLI builds for its host (its `targets` config
field is unread in 1.18.1); packaging needs host tools (codesign/hdiutil on field is unread in 1.18.1); packaging needs host tools (codesign/hdiutil on
macOS, rcedit/PowerShell on Windows). Hence one script run per artifact, on macOS, rcedit/PowerShell on Windows). Hence one script run per artifact, on
the matching machine; `win-x64` on a non-Windows host errors out. the matching machine; `win-x64` on a non-Windows host and `linux-x64` on a
non-Linux host both error out.
- **mac-x64 from the arm64 Mac (Rosetta)**: the script downloads electrobun's - **mac-x64 from the arm64 Mac (Rosetta)**: the script downloads electrobun's
x64 CLI binary once (`electrobun-cli-darwin-x64.tar.gz` from the GitHub x64 CLI binary once (`electrobun-cli-darwin-x64.tar.gz` from the GitHub
release matching the installed version, cached at release matching the installed version, cached at
@@ -56,7 +58,16 @@ Facts the script encodes:
(self-extracting installer → `%LOCALAPPDATA%`) plus the script's own (self-extracting installer → `%LOCALAPPDATA%`) plus the script's own
**portable zip** made from the assembled app folder under **portable zip** made from the assembled app folder under
`build/stable-win-x64/` (layout to confirm on the first real Windows build — `build/stable-win-x64/` (layout to confirm on the first real Windows build —
the step warns instead of failing). the step warns instead of failing); Linux → only
`stable-linux-x64-miniSCP.tar.zst` + `update.json`, i.e. the updater feed and
nothing installable, so the script's **`.tar.gz` of the assembled app folder**
under `build/stable-linux-x64/` IS the release artifact (same best-effort
warn). No AppImage/.deb/.desktop — unpack and run the `miniSCP.sh` wrapper the
script writes beside `bin/launcher` (it applies the NVIDIA/DMABUF workaround,
which the app cannot set for itself — see gotchas.md).
- **Linux builds need the AppIndicator dev libraries present to RUN** (not to
build) — see platform-notes.md; a packaged tarball 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.
@@ -67,6 +78,55 @@ runner matrix, each running the same release script; inert until act_runners
with those labels are registered. Needs `fetch-depth: 0` (the build number is with those labels are registered. Needs `fetch-depth: 0` (the build number is
the commit count). Runner prereqs are commented in the workflow file. the commit count). Runner prereqs are commented in the workflow file.
## Download host (`site/` + Caddy)
`site/` is a self-contained landing page **and** download host, served by Caddy on an
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
stable filenames** (`/dl/stable-macos-arm64-miniSCP.dmg`, …) so the page never changes per
release. 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
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
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`.
- **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-*`
into `/dl/`; installers overwrite in place, `.patch` files accumulate (never delete them, or
older installs lose their incremental-update trail). **Tradeoff:** only the latest full
installer is downloadable; old full versions are overwritten.
- `site/deploy.sh` — run per build machine after `scripts/release.ts`; copies `artifacts/stable-*`
and renames the build-numbered win portable zip to the stable `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
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.
With `generatePatch` (default true), release builds now need **build-time network to
`miniscp.com/dl`** so electrobun can fetch the currently-published version to diff a `.patch`
(the first build after enabling it has nothing to diff and just ships the full artifacts).
## Auto-updater (app side)
Wired on top of the download host above. See the "Auto-updater" section in
[architecture.md](architecture.md) for the runtime flow.
- **Backend** ([src/bun/index.ts](../src/bun/index.ts)): `Updater.checkForUpdate()` runs ~5s after launch and
every 6h; on `updateAvailable` it pushes `rpc.send.updateStatus({phase:"available",…})`.
`Updater.onStatusChange` streams `download-progress` (0–100) → `updateStatus{downloading}`.
`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` /
`applyUpdate` (handlers in [rpc-handlers.ts](../src/bun/rpc-handlers.ts) call the electrobun `Updater`);
push message `updateStatus`.
- **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/7like/tab-bar/tab-bar.ts))
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
`downloadUpdate` response). App-menu "Check for Updates…" ([menu.ts](../src/bun/menu.ts)) routes a
`check-for-updates` menu action into `UpdaterService.check()`.
- **Unsigned is fine:** `applyUpdate()` strips the macOS quarantine xattr before relaunch, so the
swapped bundle isn't Gatekeeper-blocked.
## Icons ## Icons
`scripts/gen-icons.ts` (resvg, dev-time): renders `assets/logo.svg` into the `scripts/gen-icons.ts` (resvg, dev-time): renders `assets/logo.svg` into the
+324 -1
View File
@@ -28,6 +28,174 @@ touching the adjacent code.
- 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`.
- **The win7 skin layer outranks Tailwind utilities.** 7.css is imported
`layer(skin)`, ordered above `utilities`, so any utility you add to a
`button`/`input`/`select`/`textarea`/`table` is silently dead under that
skin. If it matters, neutralize in the `@layer skin` block in
`app/skins/7like/7like.css` — and note `revert-layer` only reaches
`utilities` from **inside `skin`**; used from `skinfix` it falls back onto
7.css and is a no-op. This bit once already.
- `@import … layer(name)` works through `@tailwindcss/postcss` (it wraps the
imported AST), but the **deep path is mandatory**: 7.css ships no `exports`
map and Tailwind's resolver reads only `style`/`mainFields`, so a bare
`@import "7.css"` resolves to nothing. Use `7.css/dist/7.scoped.css`.
- 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.
## 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.
- **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
three px reservoirs were: the panel-chrome tokens (`--row-h` etc.), 93
`text-[11px]` arbitraries, and 7.css itself — 370 px values, zero rem, with
`--w7-font` reaching only 15 rules' typography. 7like.css's `skinfix` carries
a scale bridge re-expressing 7.css's vars and hardcoded box metrics in rem.
1px hairlines and focus rects stay px deliberately (crisp at any scale; the
`tr` border exists to stop selection shifting rows).
- **Restating a 7.css rule in `skinfix` also outranks 7.css's VARIANTS of that
rule — by layer, regardless of specificity.** The title-bar-controls
restatement silently shrank the close button from 48px to 29px (7.css widens
it via `button[aria-label=Close]`), and the tablist padding flattened the
active tab's 4px raise. Caught only by the harness pixel diff. When bridging
a rule, grep 7.scoped.css for every variant that overrides the same
properties and restate those too.
- **px constants in TS that mirror rem-sized CSS are latent scale bugs.** The
shadlike 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.
## Hover states and the frame edge
- **A child's box-shadow is NOT clipped by its parent's border or radius.** The
close button's hover glow (7.css: `0 0 7px 3px #e68e75` on a `::before`)
painted across the window's top border and onto the desktop beyond it — the
caption buttons sit flush against the top edge (`border-top: 0`), and the
frame's rounded transparent corners clip nothing. Fixed with
`clip-path: inset(0 0 -1rem 0)` on `.w7-frame > .title-bar`: glow stops at
the frame like the real DWM, bottom bleed (authentic) stays open. Note
clip-path also clips hit-testing — safe here because the drag region is the
title-bar's own box, entirely inside the clip.
- **Hover states CAN be tested headlessly**: `scripts/hover-probe.ts` copies
the built stylesheet with `:hover` rewritten to `.simhover`, renders the
frame markup with the state forced, and pixel-scans artifact regions,
exiting non-zero on hover-introduced red outside the frame. Firefox
`--screenshot` has no pointer and `import -window` captures the real
pointer's state, so the class rewrite is the only programmatic hover on this
machine. Re-run it after touching title-bar or hover styling.
## The two skin trees (`ui/src/app/skins/`)
- **`.win7` is not a name we may change.** `7.scoped.css` is precompiled with
every selector prefixed by that literal class, so the skin id in
`AppSettings` stays `"win7"` even though its folder is `7like/`. The folder
names are cosmetic; the class and the persisted setting value are not.
- **Reach `core/` through the `@core/*` alias, never a relative path.** Skin
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.
- **`@font-face` `url()` resolves relative to the file that declares it.** When
the win7 rules moved three directories deeper, `./fonts/...` would silently
have 404'd — build still green, app just renders a fallback font. The fonts
therefore live inside the skin that uses them.
- **The `@layer` statement must stay the first statement in `ui/src/styles.css`.**
It is one of only two rule types allowed above `@import`, and it is what sorts
`skin` above `utilities`. Every `@import` (Tailwind and both skin sheets) must
also precede all other rules.
- **Duplicate selectors across skins are safe.** `app-file-panel` exists in both
trees; standalone components resolve through their importer's `imports:`
array, never a global registry, and the trees are never mounted together.
- **The build DOES type-check components nothing imports.** ngtsc compiles the
whole program, so a skin that is currently unreachable still fails
`bun run build:ui` on a type or template error. Verified deliberately by
injecting an error into an unreferenced file — worth knowing before assuming
a green build proves nothing about the inactive skin.
## The win7 skin (7.css)
- **Unlayered declarations beat every `@layer`, regardless of specificity.**
`html, body { background: … }` in styles.css is unlayered, so no `skinfix`
rule can override it — the win7 transparent-corner reset has to be unlayered
too. The frame's rounded corners only read as rounded because nothing paints
an opaque full-bleed ground behind them; anything that does fills the corners
and the window becomes a rectangle with a curve drawn on it.
- **7.css restates `border-color` at (0,3,1)** via
`.win7 button:not(:disabled):not(:hover)` and its `:hover` twin. A two-class
selector like `.win7 .w7-flat` (0,2,0) loses to it, so paint suppression for
flat buttons lives in `skinfix` where layer order settles it. The
`.w7-toolbar` arm only ever won by tying on specificity and landing later in
the same layer — an accident `w7-flat` did not share, which is why it
rendered a bordered slab while its neighbours went flat.
- **`overflow-x: auto` also clips vertically.** CSS refuses to pair `visible`
with a non-visible axis, so the computed `overflow-y` becomes `auto` too.
7.css raises the active tab with `margin-top: -2px`; inside the horizontally
scrolling tab strip that put its top border outside the scroll box and
deleted it silently. The strip reserves `padding-top: 2px` to compensate —
mirroring the `padding-left: 3px` 7.css already reserves for the same tab's
`-3px` left margin.
- **7.css hardcodes light-mode literals outside its own var system** —
`color: #222` on tablist buttons, black/white bevels in the ListView and
menu. Each one has to be routed through a `--w7-*` token declared in
`:root.win7` and overridden in **both** dark blocks (the `[data-theme]`
attribute one *and* the `prefers-color-scheme` one; system dark sets no
attribute).
- **The skin layer can also override `position`, and that breaks LAYOUT, not
just paint.** `ul[role=menu]` carries `position: relative` in 7.css (for the
gutter stripe's `::before` and submenu anchoring), which outranked Tailwind's
`.fixed` on the app's own context menu. `[style.left/top.px]` then became
*flow* offsets from the menu's static position — the last child of the
`h-screen` frame — so every right-click menu rendered below the viewport,
unreachable behind `overflow: hidden`. The backdrop is a plain `<div>`, which
7.css does not match, so it stayed genuinely fixed and ate the next click:
the symptom was "right-click does nothing, then the app ignores me once".
Fixed with a `position: fixed` restatement in `skinfix`, scoped to a `w7-ctx`
marker class so 7.css's own menus keep theirs. Regressed in `0ae2a62`, when
the menu root became a real `ul[role=menu]` — i.e. adopting the framework's
markup is what exposed it. **A resting pixel diff cannot catch this class of
bug**: nothing about the idle window changes. `scripts/menu-probe.ts` renders
the menu at declared coordinates and scans whether it landed there, with a
marker-less control menu as its own discriminator.
- **7.css's button effects reach anything that IS a button — including menu
items — and one of them fades out over a FULL SECOND.** `button::before` is
an Aero glow overlay with `opacity: 0`, raised to 1 on hover, and
`button:not(:hover):before` carries `transition: opacity 1s linear`.
menu.css suppresses it with `content: none` on the **`:hover` arm only**, so
while an item is hovered nothing paints, but the moment the pointer leaves,
the pseudo returns with opacity animating 1 → 0: a blue ghost trailing the
cursor down the menu. Killing `content` in *every* state is the fix —
suppressing the transition alone leaves a glow that simply snaps. Two more
from the same family: `button:focus` runs `pulse-anim` *infinitely*
(`1s ease infinite alternate`), which matters because the context menu moves
REAL focus between items for keyboard nav, and the hover paint is 7.css's
translucent gradient wash — the same one the ListView selection dropped.
All three are overridden together, scoped to `w7-ctx`.
- **Its selectors are exact, and a near-miss looks exactly like "7.css doesn't
support this".** `menu[role=tablist] button` is a DESCENDANT selector, so
every button inside the strip becomes a tab — the `+` and the right-hand
tools must live outside it. `.window.glass > .title-bar` is direct-child, so
an inner wrapper breaks the glass coupling. A wrong guess here already
produced one hand-written lookalike that was deleted.
## Electrobun ## Electrobun
@@ -48,7 +216,109 @@ 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
`dist/api/bun/proc/linux.md` says so; 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/*/context-menu.ts`) on
every platform; don't reintroduce the native one for "nativeness" on mac/win
without keeping a Linux path.
## 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/bun` —
does nothing. It must be in the environment before the app process starts.
- Hence every launch path injects it: `scripts/linux-gpu.ts` (`launchEnv()`)
for `bun run dev` / `bun run start`, and the `miniSCP.sh` wrapper that
`release.ts` writes beside `bin/launcher` in the tarball. **A new launch
path must call `launchEnv()` or it will render black on NVIDIA.**
- 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.
- **A frameless window cannot be resized, and `preventDefault` will not save
you from the drag region.** Undecorating the GTK window (what
`titleBarStyle: "hidden"` does) takes the WM's resize borders with it, and
electrobun exposes no resize gesture — hence the synthesized grip in
`ui/src/app/skins/7like/window-resize/` (a STOPGAP; that file carries the
removal checklist, and `scripts/check-electrobun-resize.ts` fires from
`postinstall` when upstream makes it deletable).
**An invisible resize handle over a control steals that control's hover.** A
full eight-handle set shipped briefly and was reverted: the north strip ran
the whole width across the caption buttons and the north-east square sat on
the close button's corner, so crossing one dropped the button's `:hover`
mid-glow and looked like a rendering artifact in 7.css — nothing in the CSS
was wrong. Only the bottom-right corner overlaps nothing (it lands in the 6px
gutter `.window-body { margin: 0 6px 6px }` leaves). Any new handle must be
checked against what sits under it; south/east/west are innocent, north and
both top corners are not.
Three more traps live in that code, all verified against
`dist/api/bun/preload/dragRegions.ts`:
- It starts a window **move** from a `mousedown` listener on `document`, in
the **bubble** phase. It never reads `defaultPrevented`, so
`event.preventDefault()` does **nothing** to stop it.
- A `pointerdown` handler does not stop it either: the compatibility
`mousedown` still fires afterwards, and stopping propagation on a *pointer*
event has no effect on a *mouse* event. This is the trap that looks fixed
in every test until the window jumps.
- `isAppRegionDrag` checks `.electrobun-webkit-app-region-no-drag` **first**
and short-circuits, so that class — on the handle itself — is the only
reliable suppressor. Without it, every resize drag also drags the window.
Also: deltas must come from `screenX/screenY`. North/west resizes move the
window's origin, which moves the webview's client origin under a stationary
pointer, so `clientX` feeds that movement back into the next delta and the
window runs away. And the minimum-size clamp must re-anchor the opposite
edge — clamping the width alone lets `x` keep tracking the pointer, walking
the window off-screen while it looks frozen (`scripts/resize-math-probe.ts`
asserts exactly that, and was verified to catch it).
- **Prefer the headless probes over launching the app.** `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. `scripts/shot.ts` (+ `--dark`), `hover-probe.ts` and
`menu-probe.ts` all render through headless Firefox and open nothing. Launch
the real app only for what Gecko genuinely cannot answer — WebKitGTK
behaviour, the native frame, transparency, live RPC — ask first, and batch
every live check into a single launch.
- **X11 capture cannot answer "is this pixel transparent?"** for the frameless
window. `import -window` grabs the window's own pixels with no compositing,
so a transparent corner and an opaque black one both come out black;
`png32:` just fills alpha with 1 and proves nothing. XWayland's root window
is not the composited desktop, so `import -window root` fails too. Frame
transparency and the maximized corners need a human looking at the screen —
say so rather than reporting a screenshot as confirmation.
- 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).
- **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
@@ -56,6 +326,33 @@ touching the adjacent code.
attributes before first paint (`buildPreload`), (3) `ui/src/index.html` attributes before first paint (`buildPreload`), (3) `ui/src/index.html`
carries an inline `<style>` with the html background per theme — its oklch carries an inline `<style>` with the html background per theme — its oklch
values MUST stay in sync with the `--background` tokens in styles.css. values MUST stay in sync with the `--background` tokens in styles.css.
The win7 skin adds a **fourth value to keep in sync**: `buildPreload` also
bakes the `.win7` class, and index.html carries a matching `html.win7` arm
for all four theme states. Miss it and every win7 window flashes white.
- **The skin is a LAUNCH-TIME choice. Never apply one to a running window.**
This shipped broken once: `SettingsService.applySkin` toggled the `.win7`
class the moment the setting changed, which was harmless when there was one
component tree and became a broken window as soon as there were two — 7.css
painted onto shadlike markup that has no `.window.glass`, no `.title-bar`, no
`menu[role=tablist]` and no `<table>` ListView. Each skin's CSS is written
against its OWN tree's markup; the class must only ever be written by
`buildPreload`, for the tree that window actually booted.
- **A reload does not rescue it.** The preload is a persistent
`WebKitUserScript` injected at DOCUMENT_START, so its string is frozen at
webview construction — `webview.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` are construction-time only** — no setters
exist, so a live window can never 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.
- Don't "fix" this by recreating the main window either: `exitOnLastWindowClosed`
is true, the main `rpc` is captured by six push closures, the tray captures
`win`, listeners cannot be removed (no `off()`) — and it would drop every
session and tab.
- Bun pins it: `initSessionSkin` freezes the skin before any window exists,
and `buildPreload`/`chromeOptions` read that, never the live settings. Skip
the pin and a window opened after a pending change boots the other skin.
- 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
@@ -86,6 +383,32 @@ touching the adjacent code.
- `panel.touched` gates localDir auto-navigation; `connectToken` guards - `panel.touched` gates localDir auto-navigation; `connectToken` guards
against a slow connect resolving into a panel the user already reused. against a slow connect resolving into a panel the user already reused.
## i18n
- **Translations live in `src/shared/i18n/*.ts` (TS, not JSON)** — deliberate:
it sidesteps `resolveJsonModule` differences between the Angular esbuild and
Bun builds and gives full typing. `en.ts` is the source of truth; other
locales are partial and fall back to English then the raw key.
- **`i18n.t()` is reactive because it reads the `locale` signal** — that's what
makes live language switching work with no pipe. If you cache a translated
string in a plain field it WON'T update on switch; call `t()` in the template
(or in a signal-reading getter) instead. Same for `i18n.date()/size()`.
- Dictionary values are TS string literals: **French/other apostrophes break
single-quoted entries** (`'…c'est…'`). Use double quotes for any value
containing an apostrophe. Plurals are `{ one, other }` maps (not ICU strings);
zh/ja supply only `other`.
- **Native menus can't use the Angular service** — `menu.ts` goes through backend
`i18n.ts`, and the app menu must be rebuilt when the locale changes (wired in
index.ts `settingsChanged`), or it keeps the old language. The context menu no
longer has this problem: it is Angular now and reads `I18nService` directly.
- `eu`/`zh`/`ja` are AI drafts pending native review (see the i18n README).
- **Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched
result.** A normal `refresh()` is masked by `busy` (input blocked), but the
silent variant (poll/watch) runs while the user can navigate — without the
guard, a poll that started in dir A resolves and stamps A's listing over the
dir B the user just opened. It also never calls `scrollCursorIntoView`
(scroll must stay put) and returns early if `busy()` or already refreshing.
## Build / tooling ## Build / tooling
- Angular CLI rejects Bun's Node-compat version — `scripts/node-version-shim.ts` - Angular CLI rejects Bun's Node-compat version — `scripts/node-version-shim.ts`
+144 -12
View File
@@ -1,31 +1,162 @@
# 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 is the primary target; Windows 11+ x64 and Linux x64 are **beta** targets
deferred; Linux untargeted — electrobun lacks menus/context menus there). (win-arm64 and linux-arm64 deferred). Every platform seam carries a
Every platform seam carries a `PORTABILITY:` comment at the branch site. `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 (modern skin) | `src/bun/windows.ts`, `index.ts` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
| Window chrome (win7 skin) | `src/bun/windows.ts chromeOptions` | `hidden` (clears Titled → no traffic lights) | frameless | `gtk_window_set_decorated(false)` |
| Window **resize** | `ui/.../7like/window-resize/` | native frame resizes; win7 frameless uses the synthesized handles | as mac | as mac |
| 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) — About lives in the options popover | 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/*/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`).
### Frameless windows lose the WM's resize borders (win7 skin, every platform)
`titleBarStyle: "hidden"` is what lets 7.css draw the Aero caption — on Linux the
GTK wrapper honours it with `gtk_window_set_decorated(false)`. Undecorating also
removes the window manager's resize borders and corner grips, and electrobun
1.18.1 offers **no** native resize gesture to put back: the shipped
`libNativeWrapper` exports `startWindowMove`/`stopWindowMove` with no resize
counterpart, does not link `gtk_window_begin_resize_drag`, and `BrowserWindow`
has neither `setResizable` nor min-size constructor options. The window is still
*programmatically* resizable (`setFrame`/`getFrame` work, and
`gtk_window_set_resizable(false)` is never called), so the app **synthesizes the
input affordance**: a bottom-right grip in the webview drags the native frame
over RPC (`ui/src/app/skins/7like/window-resize/`). Only that one corner — an
invisible handle anywhere else overlays a control and steals its hover, which is
what a full edge set did to the caption buttons.
That is a **stopgap** — each drag tick is an RPC hop plus an FFI call, where a
real WM grab is compositor-side. `scripts/check-electrobun-resize.ts` watches the
installed electrobun for the missing primitives and prints the removal checklist
when they appear; it runs from `postinstall`, so an electrobun bump surfaces it
without anyone remembering to look. The modern skin keeps the native frame and
needs none of this.
## Memory footprint (expected, not a bug)
Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB
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).
Not reducible to tens of MB without dropping the web UI.
- macOS splits across several "miniSCP" processes: the **Bun main** (~120 MB:
backend + the bundled electrobun runtime + live SSH sessions — the largest and
least reducible piece), the **`views://app`** WKWebView content process (~60 MB,
the Angular UI), and the standard WebKit **Networking** / **Graphics and
Media** helpers. All are one app; WebKit just names them per-role.
- Windows is higher because **WebView2 is Chromium** (multi-process: browser +
GPU + renderer + utility + crashpad), vs macOS's lighter WKWebKit.
- Measure against a **packaged** build — `bun run dev` inflates the WebContent
heap (ng-serve, unminified, sourcemaps, live-reload) and is not representative.
- Verified non-issues: transfers stream (no whole-file buffering), icons
tree-shaken to ~33 glyphs, zoneless Angular, 364 KB prod bundle.
- Only real growth levers if this ever matters: the file list is **not
virtualized** (a directory with tens of thousands of entries retains a DOM row
each), and each open tab keeps both panels' full `entries` arrays; terminal
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.
**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 `dist/api/bun/proc/linux.md`; 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. Every launch path sets
`WEBKIT_DISABLE_DMABUF_RENDERER=1` when `/proc/driver/nvidia` exists —
`scripts/linux-gpu.ts` for dev, the `miniSCP.sh` wrapper in the tarball for
releases. 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, unpackaged**: the release artifact is a portable tarball — no
AppImage, `.deb`, `.desktop` entry or icon installation. Unpack and run
`miniSCP.sh` (which execs `bin/launcher`).
## 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.tar.gz`; unpack it elsewhere and
run `miniSCP.sh`.
## 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
@@ -60,8 +191,9 @@ Code paths written blind (no Windows box was available); verify in order:
5. External editor: default app opens via `cmd /c start`; the Choose… picker 5. External editor: default app opens via `cmd /c start`; the Choose… picker
starts in Program Files and accepts an .exe; remote temp-edit → save → starts in Program Files and accepts an .exe; remote temp-edit → save →
auto-upload works (directory watcher on NTFS). auto-upload works (directory watcher on NTFS).
6. Context menus: Show in Explorer, Move to Recycle Bin (item restorable from 6. Context menus: Send to Other Panel is the first row item; Show in Explorer,
the bin), Copy Path; Permissions ABSENT on local rows, present on remote. Move to Recycle Bin (item restorable from the bin), Copy Path; Permissions
ABSENT on local rows, present on remote.
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.
+8
View File
@@ -7,6 +7,14 @@ export default {
identifier: BRANDING.identifier, identifier: BRANDING.identifier,
version: BRANDING.version, version: BRANDING.version,
}, },
// Auto-updater feed. The flat artifacts land under /dl on the host (see site/);
// miniscp.com is fronted by an external proxy for TLS. baseUrl is baked into
// version.json at build time, so only builds made after this takes effect check
// for updates. generatePatch (default true) needs build-time network to this URL
// to diff against the currently-published version.
release: {
baseUrl: "https://miniscp.com/dl",
},
build: { build: {
bun: { bun: {
entrypoint: "src/bun/index.ts", entrypoint: "src/bun/index.ts",
+2 -2
View File
@@ -11,8 +11,8 @@
"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 run build:ui && bunx electrobun build", "build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build",
"start": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun dev", "start": "bun scripts/gen-version.ts && bun run build:ui && bun scripts/start.ts",
"postinstall": "bun scripts/gen-version.ts", "postinstall": "bun scripts/gen-version.ts && bun scripts/check-electrobun-resize.ts",
"release": "bun scripts/release.ts" "release": "bun scripts/release.ts"
}, },
"dependencies": { "dependencies": {
+140
View File
@@ -0,0 +1,140 @@
// Watches for electrobun growing a real window-resize gesture, so the
// synthesized one can be deleted the moment it becomes dead weight.
//
// bun scripts/check-electrobun-resize.ts (also runs from `postinstall`)
//
// WHY THIS EXISTS
//
// The win7 skin is frameless, which on GTK means undecorated, which means the
// window manager supplies no resize borders. electrobun 1.18.1 exposes no
// native resize gesture at all, so the app synthesizes one: handles in the
// webview drag the frame via RPC. That is a genuine stopgap — jerkier than a
// compositor-side grab and always will be — and the thing most likely to
// happen to a stopgap is that it outlives its reason and nobody notices.
//
// So the trigger is automated rather than a comment somebody has to re-read: an
// electrobun bump runs `postinstall`, which runs this, which prints the removal
// checklist the moment upstream ships the primitive we are waiting for.
//
// It NEVER fails the install (always exit 0) and is silent when there is
// nothing to say — a check that cries wolf gets muted, and a muted check is
// worse than none.
import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
/** The version the synthesized resize was written against and verified on. If
* the installed version differs, this script says so once — that is the cue to
* re-check upstream's changelog and then bump this constant. */
const VALIDATED_VERSION = "1.18.1";
/**
* Symbols that would mean "upstream can resize now". Verified ABSENT in 1.18.1:
* the shipped library exports `startWindowMove`/`stopWindowMove` with no resize
* counterpart, and imports `gtk_window_begin_move_drag` but not the resize
* twin. Any of these appearing is the signal.
*
* Searched as raw ASCII in the binary rather than via `nm`/`strings`: only the
* host platform's library is installed (`dist-linux-x64/…so`, `.dylib`, `.dll`
* elsewhere), and `nm` does not exist on Windows. Symbol names live in the
* binary as plain text either way.
*/
const NATIVE_NEEDLES = [
"gtk_window_begin_resize_drag", // the GTK primitive, if they ever link it
"gtk_window_set_resizable",
"startWindowResize", // the obvious sibling of startWindowMove
"setWindowResizable",
"beginWindowResize",
];
/** Source-level signals in the shipped TS API. */
const SOURCE_NEEDLES: { file: string; needle: string; means: string }[] = [
{
file: "dist/api/bun/core/BrowserWindow.ts",
needle: "setResizable",
means: "BrowserWindow gained a resizability setter",
},
{
file: "dist/api/bun/core/BrowserWindow.ts",
needle: "minWidth",
means: "the window constructor gained min-size options (our JS clamp may be redundant)",
},
{
file: "dist/api/bun/preload/dragRegions.ts",
needle: "resize",
means: "the drag-region preload now knows about resizing",
},
];
const CHECKLIST = `
┌────────────────────────────────────────────────────────────────────────┐
│ electrobun may now support window resizing natively. │
│ The synthesized resize is a STOPGAP and should be deleted. │
│ Full checklist: │
│ ui/src/app/skins/7like/window-resize/window-resize.ts │
└────────────────────────────────────────────────────────────────────────┘`;
function main(): void {
const pkgDir = path.join(import.meta.dir, "..", "node_modules", "electrobun");
if (!existsSync(pkgDir)) return; // not installed yet (fresh clone mid-install)
const hits: string[] = [];
// --- the shipped native library, whichever platform's is installed ---
for (const entry of readdirSync(pkgDir)) {
if (!entry.startsWith("dist-")) continue;
const dir = path.join(pkgDir, entry);
for (const file of readdirSync(dir)) {
if (!/^libNativeWrapper.*\.(so|dylib|dll)$/.test(file)) continue;
// latin1: we are searching for ASCII symbol names inside a binary, and
// utf-8 decoding would mangle bytes >0x7f (and can drop invalid
// sequences), which could hide a needle sitting next to one.
const text = readFileSync(path.join(dir, file), "latin1");
for (const needle of NATIVE_NEEDLES) {
if (text.includes(needle)) hits.push(`${entry}/${file} contains ${needle}`);
}
}
}
// --- the shipped TypeScript API ---
for (const { file, needle, means } of SOURCE_NEEDLES) {
const full = path.join(pkgDir, file);
if (!existsSync(full)) continue;
if (readFileSync(full, "utf8").includes(needle)) hits.push(`${file} mentions ${needle} — ${means}`);
}
const version = installedVersion(pkgDir);
if (hits.length) {
console.log(CHECKLIST);
console.log(` electrobun ${version ?? "?"} — what changed:`);
for (const hit of hits) console.log(` · ${hit}`);
console.log("");
return;
}
// No resize support. Stay quiet unless the version moved out from under the
// hack, in which case one line is the reminder to look.
if (version && version !== VALIDATED_VERSION) {
console.log(
`[electrobun] ${VALIDATED_VERSION} → ${version}: no window-resize API found, so the ` +
`synthesized resize stays. Re-check, then bump VALIDATED_VERSION in ` +
`scripts/check-electrobun-resize.ts to silence this.`,
);
}
}
function installedVersion(pkgDir: string): string | null {
try {
return JSON.parse(readFileSync(path.join(pkgDir, "package.json"), "utf8")).version ?? null;
} catch {
return null;
}
}
// A broken check must never break an install: swallow everything, exit 0.
try {
main();
} catch (error) {
// Deliberately quiet — this is advisory. Surface it only when run by hand.
if (process.env["DEBUG"]) console.error("[electrobun] resize check failed:", error);
}
+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() {
+171
View File
@@ -0,0 +1,171 @@
// Programmatic HOVER test for the win7 title-bar close button.
//
// bun scripts/hover-probe.ts
//
// Why this exists: hovering the X paints 7.css's red glow ::before
// (box-shadow: 0 0 7px 3px #e68e75), and a stray red mark was reported on the
// window border's far edge. Hover cannot be captured by any tool on this
// machine — Firefox --screenshot has no pointer, `import -window` grabs
// whatever the real pointer is (not) doing — so this script makes hover a
// CLASS: it copies the built stylesheet with every `:hover` rewritten to
// `.simhover`, renders the app's exact frame markup with the X force-hovered,
// and then PIXEL-SCANS the regions where the artifact could live, printing a
// verdict table. Re-run after any change to the title-bar/hover styling; the
// artifact regions must stay at zero.
//
// Inherits shot.ts's constraints: dedicated Firefox profile (a running Firefox
// holds the default profile's lock), and the page must be written INTO the
// dist folder or strict_origin_policy silently unstyles it. Gecko vs WebKitGTK
// is fine here — box-shadow/border-radius compositing is engine-agnostic.
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import path from "node:path";
const ROOT = path.join(import.meta.dir, "..");
const DIST = path.join(ROOT, "ui", "dist", "ui", "browser");
const WORK = path.join(ROOT, ".shot");
function fail(message: string): never {
console.error(`[hover-probe] ERROR: ${message}`);
process.exit(1);
}
// ---- stylesheet with :hover rewritten to .simhover ----
if (!existsSync(DIST)) fail(`no build output at ${DIST} — run: bun run build:ui`);
const styles = readdirSync(DIST).find((f) => f.startsWith("styles-") && f.endsWith(".css"));
if (!styles) fail(`no styles-*.css in ${DIST} — run: bun run build:ui`);
const css = await Bun.file(path.join(DIST, styles)).text();
// Global on purpose: EVERY hover rule becomes assertable markup. `:hover` has
// no partial-token hazard (no `:hoverish` exists in CSS).
await Bun.write(path.join(DIST, "_probe.css"), css.replaceAll(":hover", ".simhover"));
// ---- the probe page: the app's frame markup, X force-hovered ----
// Mirrors ui/src/app/skins/7like/app.html + title-bar.ts EXACTLY where it
// matters: .window.glass.w7-frame.active with .title-bar as a DIRECT child
// (7.css couples the glass with `.window.glass > .title-bar`), controls keyed
// by aria-label. Fixed-size windows instead of h-screen so two fit one shot.
const win = (id: string, hover: boolean) => `
<div id="${id}" class="window glass w7-frame active" style="width: 420px; height: 120px; position: relative;">
<div class="title-bar electrobun-webkit-app-region-drag">
<div class="title-bar-text">miniSCP</div>
<div class="title-bar-controls">
<button aria-label="Minimize"></button>
<button aria-label="Maximize"></button>
<button aria-label="Close"${hover ? ' class="simhover"' : ""}></button>
</div>
</div>
<div class="window-body" style="height: 60px"></div>
</div>`;
const page = `<!doctype html>
<html class="win7" lang="en">
<head><meta charset="utf-8"><link rel="stylesheet" href="_probe.css">
<style>
/* checkerboard ground so semi-transparent escapes are visible over ANY tone */
body { margin: 0; padding: 40px; background: #808080; }
.slot { margin-bottom: 40px; }
</style></head>
<body>
<div class="slot">${win("control", false)}</div>
<div class="slot">${win("hovered", true)}</div>
</body></html>`;
const pagePath = path.join(DIST, "_hover-probe.html");
await Bun.write(pagePath, page);
// ---- shoot ----
mkdirSync(WORK, { recursive: true });
const profile = path.join(WORK, "profile");
mkdirSync(profile, { recursive: true });
rmSync(path.join(profile, "parent.lock"), { force: true });
const out = path.join(WORK, "hover-probe.png");
const proc = Bun.spawnSync(
["firefox", "--headless", "--profile", profile, "--screenshot", out, "--window-size", "560,420", `file://${pagePath}`],
{ cwd: ROOT, stdout: "pipe", stderr: "pipe" },
);
if (!existsSync(out)) {
console.error(proc.stderr.toString().trim());
fail("firefox produced no image");
}
// ---- pixel scan ----
// Page geometry (CSS px = image px; no DPR scaling in headless Firefox):
// body padding 40, window at x:40..460 (width 420), first window y:40..160,
// second y:200..320. The window's own 1px border is AT x=40 and x=459.
const WIN_X = 40;
const WIN_W = 420;
const CONTROL_Y = 40;
const HOVER_Y = 200;
const WIN_H = 120;
interface Region {
name: string;
x: number;
y: number;
w: number;
h: number;
artifact: boolean; // true = red here is a DEFECT; false = red here is the intended glow
}
// The close button: last control, min-width 48px (3rem), controls end 6px
// (--w7-w-space padding) from the window's right edge — so the button spans
// roughly x = right-6-48 .. right-6. The glow reaches up to blur+spread = 10px
// beyond any edge of it.
const regions = (top: number): Region[] => [
// the window's right border column (2px wide: border + inset ring), full title-bar height
{ name: "right border (A)", x: WIN_X + WIN_W - 2, y: top + 2, w: 2, h: 30, artifact: true },
// OUTSIDE the window entirely, right of the border, title-bar height
{ name: "outside right (B)", x: WIN_X + WIN_W, y: top, w: 12, h: 34, artifact: true },
// OUTSIDE above the top edge / rounded corner
{ name: "outside top (B)", x: WIN_X + WIN_W - 80, y: top - 12, w: 92, h: 12, artifact: true },
// the gutter between the button and the border — glow HERE is the intended look
{ name: "gutter (glow ok)", x: WIN_X + WIN_W - 7, y: top + 2, w: 5, h: 30, artifact: false },
];
async function redCount(img: string, r: Region): Promise<number> {
// txt: gives one "x,y: (r,g,b[,a])" line per pixel
const p = Bun.spawnSync(
["magick", img, "-crop", `${r.w}x${r.h}+${r.x}+${r.y}`, "+repage", "txt:"],
{ stdout: "pipe", stderr: "pipe" },
);
let count = 0;
for (const line of p.stdout.toString().split("\n").slice(1)) {
const m = /\((\d+),(\d+),(\d+)/.exec(line);
if (!m) continue;
const [red, green, blue] = [Number(m[1]), Number(m[2]), Number(m[3])];
// "red" = the red channel dominating both others decisively. The Aero
// chrome is blue/white, so any genuine red here is the glow.
if (red > 120 && red > green + 30 && red > blue + 30) count++;
}
return count;
}
console.log("[hover-probe] region scan (red-dominant pixels):\n");
console.log(" region control hovered verdict");
let defects = 0;
for (const [i, region] of regions(CONTROL_Y).entries()) {
const hoverRegion = regions(HOVER_Y)[i];
const inControl = await redCount(out, region);
const inHover = await redCount(out, hoverRegion);
const introduced = inHover - inControl;
let verdict: string;
if (!region.artifact) verdict = introduced > 0 ? "glow present (intended)" : "no glow";
else if (introduced > 0) {
verdict = "DEFECT: red introduced by hover";
defects++;
} else verdict = "clean";
console.log(
` ${region.name.padEnd(20)}${String(inControl).padStart(7)}${String(inHover).padStart(9)} ${verdict}`,
);
}
console.log(`\n[hover-probe] image: ${out}`);
if (defects > 0) {
console.log(`[hover-probe] ${defects} artifact region(s) show hover-introduced red`);
process.exit(2);
}
console.log("[hover-probe] clean");
+33
View File
@@ -0,0 +1,33 @@
// 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/. Every launch path has to
// inject it: the dev scripts here, and the wrapper script that release.ts ships
// beside the packaged launcher.
//
// 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,
};
}
+225
View File
@@ -0,0 +1,225 @@
// Programmatic POSITIONING + HIGHLIGHT test for the win7 context menu.
//
// bun scripts/menu-probe.ts
//
// Why this exists: 7.css declares `position: relative` on ul[role=menu] (it
// needs it for the gutter stripe and submenus), and the `skin` layer outranks
// `utilities` — so Tailwind's `.fixed` on the app's own menu lost, its
// [style.left/top.px] became FLOW offsets, and every right-click menu rendered
// below the viewport of the h-screen frame. Nothing about the idle window
// changes when that breaks, so the screenshot harness (scripts/shot.ts) cannot
// see it, and hover-probe.ts tests a different axis. This script renders the
// menu markup at declared coordinates and pixel-scans whether it actually
// landed there.
//
// It carries its own control: a second menu WITHOUT the `w7-ctx` marker class,
// i.e. the bug verbatim. That one must stay invisible — if it ever shows up,
// 7.css stopped forcing `relative` and this probe's premise is stale.
//
// It also checks the item HIGHLIGHT, using hover-probe.ts's trick (`:hover`
// rewritten to a `.simhover` class, since headless Firefox has no pointer):
// 7.css paints hovered items with a translucent gradient wash, and this app
// wants one flat colour. A gradient is detectable as vertical colour spread
// down a text-free strip of the item, so "is it flat?" is a measurement, not
// an opinion. What canNOT be measured here is the 1s afterglow fade — a
// temporal effect — but its cause is structural (`button::before` still has
// `content`), and this probe would show the glow's colour if the pseudo ever
// came back.
//
// Inherits shot.ts's constraints: dedicated Firefox profile (a running Firefox
// holds the default profile's lock), and the page must be written INTO the
// dist folder or strict_origin_policy silently unstyles it.
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import path from "node:path";
const ROOT = path.join(import.meta.dir, "..");
const DIST = path.join(ROOT, "ui", "dist", "ui", "browser");
const WORK = path.join(ROOT, ".shot");
function fail(message: string): never {
console.error(`[menu-probe] ERROR: ${message}`);
process.exit(1);
}
if (!existsSync(DIST)) fail(`no build output at ${DIST} — run: bun run build:ui`);
const styles = readdirSync(DIST).find((f) => f.startsWith("styles-") && f.endsWith(".css"));
if (!styles) fail(`no styles-*.css in ${DIST} — run: bun run build:ui`);
// Hover as a CLASS: same rewrite hover-probe.ts uses, for the same reason —
// `--screenshot` has no pointer. `:hover` has no partial-token hazard.
const css = await Bun.file(path.join(DIST, styles)).text();
const PROBE_CSS = "_menu-probe.css";
await Bun.write(path.join(DIST, PROBE_CSS), css.replaceAll(":hover", ".simhover"));
// ---- the probe page ----
// Geometry (CSS px = image px; headless Firefox applies no DPR scaling).
const MENU_X = 200;
const MENU_Y = 100;
const CONTROL_X = 560;
const MENU_W = 200;
// Mirrors ui/src/app/skins/7like/context-menu.ts EXACTLY where it matters:
// ul[role=menu] > li[role=menuitem] > button, the `w7-ctx fixed` class pair,
// and left/top as inline pixel styles. `w7-ctx` is what the skinfix rule in
// 7like.css keys on; the control deliberately omits it.
const menu = (x: number, marker: boolean) => `
<ul role="menu" tabindex="-1"
class="${marker ? "w7-ctx " : ""}fixed z-50 outline-none"
style="left: ${x}px; top: ${MENU_Y}px; width: ${MENU_W}px">
<li role="menuitem"><button type="button"${marker ? ' class="simhover"' : ""}>Send to Other Panel</button></li>
<li role="menuitem" class="has-divider"><button type="button">Rename</button></li>
<li role="menuitem"><button type="button">Copy Path</button></li>
<li role="menuitem" aria-disabled="true"><button type="button">Delete</button></li>
</ul>`;
// The frame reproduces the app's: .window.w7-frame is a 100vh flex column and
// <app-context-menu> is one of its LAST children, so a menu that falls back to
// static flow lands past the bottom edge — which is the whole bug.
// The body gets an explicit white background (inline, so no layer can touch
// it) purely so "is anything drawn here?" is an unambiguous question.
const page = `<!doctype html>
<html class="win7" lang="en">
<head><meta charset="utf-8"><link rel="stylesheet" href="${PROBE_CSS}">
</head>
<body>
<div class="window glass w7-frame active" style="display: flex; flex-direction: column; height: 100vh">
<div class="title-bar">
<div class="title-bar-text">miniSCP</div>
<div class="title-bar-controls"><button aria-label="Close"></button></div>
</div>
<div class="window-body" style="flex: 1; min-height: 0; background: #fff"></div>
${menu(MENU_X, true)}
${menu(CONTROL_X, false)}
</div>
</body></html>`;
const pagePath = path.join(DIST, "_menu-probe.html");
await Bun.write(pagePath, page);
// ---- shoot ----
mkdirSync(WORK, { recursive: true });
const profile = path.join(WORK, "profile");
mkdirSync(profile, { recursive: true });
rmSync(path.join(profile, "parent.lock"), { force: true });
const out = path.join(WORK, "menu-probe.png");
const proc = Bun.spawnSync(
["firefox", "--headless", "--profile", profile, "--screenshot", out, "--window-size", "800,400", `file://${pagePath}`],
{ cwd: ROOT, stdout: "pipe", stderr: "pipe" },
);
if (!existsSync(out)) {
console.error(proc.stderr.toString().trim());
fail("firefox produced no image");
}
// ---- pixel scan ----
/**
* Count pixels that are visibly NOT the white window body: the menu's border
* (#0006 over white ≈ #999), its shadow and its black item text all qualify.
* A menu that rendered where it was told fills this with hundreds of them; an
* empty white region scores 0.
*/
async function inkCount(img: string, x: number, y: number, w: number, h: number): Promise<number> {
const p = Bun.spawnSync(["magick", img, "-crop", `${w}x${h}+${x}+${y}`, "+repage", "txt:"], {
stdout: "pipe",
stderr: "pipe",
});
let count = 0;
for (const line of p.stdout.toString().split("\n").slice(1)) {
const m = /\((\d+),(\d+),(\d+)/.exec(line);
if (!m) continue;
const [r, g, b] = [Number(m[1]), Number(m[2]), Number(m[3])];
if (Math.max(r, g, b) < 200) count++;
}
return count;
}
/**
* Colour spread down a text-free strip of the first (force-hovered) item: the
* ul's own 2px padding + the item's 1px border put the item's background at
* x >= menu+3, and its 32px left padding keeps label text out to menu+32. So a
* column at menu+5..menu+26 is pure fill — except for the gutter stripe, which
* 7.css puts at left: 28px. A flat colour reads 0 spread; a gradient does not.
*/
async function fillSpread(
img: string,
x: number,
y: number,
w: number,
h: number,
): Promise<{ spread: number; mean: [number, number, number] }> {
const p = Bun.spawnSync(["magick", img, "-crop", `${w}x${h}+${x}+${y}`, "+repage", "txt:"], {
stdout: "pipe",
stderr: "pipe",
});
const lo: number[] = [255, 255, 255];
const hi: number[] = [0, 0, 0];
const sum = [0, 0, 0];
let n = 0;
for (const line of p.stdout.toString().split("\n").slice(1)) {
const m = /\((\d+),(\d+),(\d+)/.exec(line);
if (!m) continue;
for (let c = 0; c < 3; c++) {
const v = Number(m[c + 1]);
lo[c] = Math.min(lo[c], v);
hi[c] = Math.max(hi[c], v);
sum[c] += v;
}
n++;
}
if (!n) return { spread: -1, mean: [0, 0, 0] };
return {
spread: Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]),
mean: [Math.round(sum[0] / n), Math.round(sum[1] / n), Math.round(sum[2] / n)],
};
}
// Inset by 2px so the scan cannot straddle the region's own edge.
const BOX = { w: MENU_W - 4, h: 60 };
const placed = await inkCount(out, MENU_X + 2, MENU_Y + 2, BOX.w, BOX.h);
const control = await inkCount(out, CONTROL_X + 2, MENU_Y + 2, BOX.w, BOX.h);
console.log("[menu-probe] ink at the declared coordinates:\n");
console.log(" menu px verdict");
console.log(
` w7-ctx (position: fixed)${String(placed).padStart(6)} ${placed > 0 ? "placed" : "MISSING: menu did not render where it was told"}`,
);
console.log(
` control (no marker) ${String(control).padStart(6)} ${control > 0 ? "NOTE: 7.css no longer forces relative — premise stale" : "off-viewport, as expected"}`,
);
// The force-hovered first item: fill strip, and the same strip on the item
// below it as a not-hovered reference (which must stay the menu surface).
const FILL = { x: MENU_X + 5, w: 21, h: 14 };
const hovered = await fillSpread(out, FILL.x, MENU_Y + 7, FILL.w, FILL.h);
const resting = await fillSpread(out, FILL.x, MENU_Y + 34, FILL.w, FILL.h);
const FLAT_MAX = 6; // antialiasing tolerance; 7.css's wash spreads far wider
console.log("\n[menu-probe] item highlight (force-hovered vs resting):\n");
console.log(" item mean rgb spread verdict");
console.log(
` hovered (.simhover) ${hovered.mean.join(",").padEnd(15)} ${String(hovered.spread).padStart(6)} ${
hovered.spread <= FLAT_MAX ? "flat" : "GRADIENT: highlight is not a flat colour"
}`,
);
console.log(` resting ${resting.mean.join(",").padEnd(15)} ${String(resting.spread).padStart(6)} reference`);
console.log(`\n[menu-probe] image: ${out}`);
let failed = false;
if (placed === 0) {
console.log("[menu-probe] the context menu is positioned in FLOW again — see 7like.css .w7-ctx");
failed = true;
}
if (hovered.spread > FLAT_MAX) {
console.log("[menu-probe] the item highlight regained a gradient — see the menu block in 7like.css");
failed = true;
}
if (hovered.mean.join(",") === resting.mean.join(",")) {
console.log("[menu-probe] hovered and resting items are identical — the highlight is not painting at all");
failed = true;
}
if (failed) process.exit(2);
console.log("[menu-probe] clean");
+47 -11
View File
@@ -1,21 +1,22 @@
// 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|mac-x64|win-x64|linux-x64]
// Default target = the host. electrobun cannot cross-compile OSes (its // Default target = the host. electrobun cannot cross-compile OSes (its
// packaging needs host tools: codesign/hdiutil on mac, rcedit/PowerShell on // packaging needs host tools: codesign/hdiutil on mac, rcedit/PowerShell on
// Windows), so win-x64 must run on a Windows machine. mac-x64 IS buildable on // Windows), so win-x64 must run on a Windows machine. mac-x64 IS buildable on
// an arm64 Mac: we fetch electrobun's x64 CLI binary and run it under // an arm64 Mac: we fetch electrobun's x64 CLI binary and run it under
// Rosetta 2 — it sees an x64 host, pulls the x64 core, and packages mac-x64. // 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 } from "node:fs"; import { cpSync, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
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", "mac-x64", "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";
if (process.platform === "linux") return "linux-x64";
return process.arch === "arm64" ? "mac-arm64" : "mac-x64"; return process.arch === "arm64" ? "mac-arm64" : "mac-x64";
} }
@@ -43,6 +44,9 @@ if (!TARGETS.includes(requested)) fail(`unknown target "${requested}" (${TARGETS
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 === "linux-x64" && process.platform !== "linux") {
fail("linux-x64 must be built on a Linux machine/runner — electrobun cannot cross-compile.");
}
if (requested.startsWith("mac") && process.platform !== "darwin") { if (requested.startsWith("mac") && process.platform !== "darwin") {
fail(`${requested} must be built on macOS.`); fail(`${requested} must be built on macOS.`);
} }
@@ -89,7 +93,12 @@ if (viaRosetta) {
// ---- collect artifacts ---- // ---- collect artifacts ----
const [osName, archName] = requested === "win-x64" ? ["win", "x64"] : ["macos", requested.slice(4)]; const [osName, archName] =
requested === "win-x64"
? ["win", "x64"]
: requested === "linux-x64"
? ["linux", "x64"]
: ["macos", requested.slice(4)];
const prefix = `stable-${osName}-${archName}-`; const prefix = `stable-${osName}-${archName}-`;
const artifactsDir = path.join(ROOT, "artifacts"); const artifactsDir = path.join(ROOT, "artifacts");
const outDir = path.join(ROOT, "release", requested); const outDir = path.join(ROOT, "release", requested);
@@ -109,15 +118,42 @@ for (const file of produced) {
out.push(name); out.push(name);
} }
// Windows: also produce a portable zip from the assembled app folder (the // Windows / Linux: package the assembled app folder directly. On Windows that
// Setup.exe is an installer; portable = unzip anywhere and run). Best-effort: // complements the Setup.exe installer (portable = unzip anywhere and run); on
// the folder layout is confirmed by the first real Windows build. // Linux it is the ONLY artifact, since electrobun emits just the .tar.zst
if (requested === "win-x64") { // updater blob there — no installer, AppImage or .deb. Best-effort: the folder
// layout is confirmed by the first real build on each OS.
if (requested === "win-x64" || requested === "linux-x64") {
const appDir = path.join(ROOT, "build", `stable-${osName}-${archName}`, BRANDING.appName); const appDir = path.join(ROOT, "build", `stable-${osName}-${archName}`, BRANDING.appName);
if (existsSync(appDir)) { if (existsSync(appDir)) {
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`); if (requested === "win-x64") {
run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]); const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
out.push(path.basename(portable)); run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]);
out.push(path.basename(portable));
} else {
// Ship a wrapper beside the launcher: the packaged app never goes
// through scripts/start.ts, and the NVIDIA/DMABUF workaround has to be
// in the environment BEFORE the process starts (see scripts/linux-gpu.ts).
const wrapper = path.join(appDir, `${BRANDING.appName}.sh`);
writeFileSync(
wrapper,
[
"#!/bin/sh",
"# WebKitGTK's DMABUF renderer cannot allocate GBM buffers on the NVIDIA",
"# proprietary driver — the window renders black. Disable it there only.",
'if [ -z "$WEBKIT_DISABLE_DMABUF_RENDERER" ] && [ -e /proc/driver/nvidia ]; then',
" export WEBKIT_DISABLE_DMABUF_RENDERER=1",
"fi",
'exec "$(dirname "$(readlink -f "$0")")/bin/launcher" "$@"',
"",
].join("\n"),
{ mode: 0o755 },
);
const tarball = path.join(outDir, `${BRANDING.appName}-${buildName}-linux-x64.tar.gz`);
// -C the parent so the archive unpacks into one named folder
run(["tar", "-czf", tarball, "-C", path.dirname(appDir), path.basename(appDir)]);
out.push(path.basename(tarball));
}
} else { } else {
console.warn(`[release] WARN: portable skipped — app folder not found at ${appDir}`); console.warn(`[release] WARN: portable skipped — app folder not found at ${appDir}`);
} }
+126
View File
@@ -0,0 +1,126 @@
// Programmatic test for the synthesized window resize's frame arithmetic.
//
// bun scripts/resize-math-probe.ts
//
// Why this exists: the win7 skin resizes itself by dragging handles and moving
// the native frame (see ui/src/app/skins/7like/window-resize/window-resize.ts —
// a STOPGAP with a removal checklist). Pointer drags cannot be synthesized
// headlessly on this machine, so the interaction itself needs a human; but the
// part most likely to be subtly wrong is pure arithmetic — eight edges, four of
// which move the window's ORIGIN as well as its size, plus a minimum-size clamp
// that must not let the anchored edge drift. That is all in `resize-math.ts`,
// which is deliberately dependency-free so this script can drive it directly.
//
// The failure this guards against is not "the window does not resize" (obvious
// on sight) but "dragging the west edge inward past the minimum walks the whole
// window across the screen" — correct-looking until you hit the limit.
//
// This covers MORE than the UI currently mounts, deliberately: only the
// bottom-right grip is wired up (an invisible handle over a caption button
// steals its hover — see the component), but the math stays general so that
// restoring a side later is one line against already-proven arithmetic.
import {
MIN_HEIGHT,
MIN_WIDTH,
RESIZE_EDGES,
type Frame,
type ResizeEdge,
resizeFrame,
} from "../ui/src/app/skins/7like/window-resize/resize-math";
const START: Frame = { x: 100, y: 200, width: 1000, height: 800 };
interface Case {
name: string;
edge: ResizeEdge;
dx: number;
dy: number;
want: Frame;
}
// Every expectation is written out in full rather than computed, so a bug in
// resizeFrame cannot be mirrored into the expectation by sharing its algebra.
const CASES: Case[] = [
// --- edges: each moves only what it owns ---
{ name: "east grows width only", edge: "e", dx: 50, dy: 999, want: { x: 100, y: 200, width: 1050, height: 800 } },
{ name: "south grows height only", edge: "s", dx: 999, dy: 40, want: { x: 100, y: 200, width: 1000, height: 840 } },
{ name: "west moves x AND width", edge: "w", dx: -30, dy: 0, want: { x: 70, y: 200, width: 1030, height: 800 } },
{ name: "north moves y AND height", edge: "n", dx: 0, dy: -25, want: { x: 100, y: 175, width: 1000, height: 825 } },
// --- corners: two axes at once ---
{ name: "south-east", edge: "se", dx: 10, dy: 20, want: { x: 100, y: 200, width: 1010, height: 820 } },
{ name: "north-west", edge: "nw", dx: -10, dy: -20, want: { x: 90, y: 180, width: 1010, height: 820 } },
{ name: "north-east", edge: "ne", dx: 10, dy: -20, want: { x: 100, y: 180, width: 1010, height: 820 } },
{ name: "south-west", edge: "sw", dx: -10, dy: 20, want: { x: 90, y: 200, width: 1010, height: 820 } },
// --- the anchored edge must not drift once clamped ---
// west dragged far east: width parks at MIN, and x parks where the EAST edge
// (100 + 1000 = 1100) minus MIN puts it. If x kept tracking dx, the window
// would slide away while appearing frozen.
{
name: "west clamps, east edge holds",
edge: "w",
dx: 5000,
dy: 0,
want: { x: 1100 - MIN_WIDTH, y: 200, width: MIN_WIDTH, height: 800 },
},
{
name: "north clamps, south edge holds",
edge: "n",
dx: 0,
dy: 5000,
want: { x: 100, y: 1000 - MIN_HEIGHT, width: 1000, height: MIN_HEIGHT },
},
// east/south clamp the size but never touch the origin
{ name: "east clamps, origin holds", edge: "e", dx: -5000, dy: 0, want: { x: 100, y: 200, width: MIN_WIDTH, height: 800 } },
{ name: "south clamps, origin holds", edge: "s", dx: 0, dy: -5000, want: { x: 100, y: 200, width: 1000, height: MIN_HEIGHT } },
// a corner clamping on BOTH axes at once
{
name: "north-west clamps both",
edge: "nw",
dx: 5000,
dy: 5000,
want: { x: 1100 - MIN_WIDTH, y: 1000 - MIN_HEIGHT, width: MIN_WIDTH, height: MIN_HEIGHT },
},
// a zero drag must be a no-op, or a click on a handle would nudge the window
{ name: "zero drag is identity", edge: "se", dx: 0, dy: 0, want: START },
];
const fmt = (f: Frame) => `${f.x},${f.y} ${f.width}x${f.height}`;
console.log("[resize-math] frame arithmetic:\n");
console.log(" case edge got verdict");
let failed = 0;
for (const c of CASES) {
const got = resizeFrame(c.edge, START, c.dx, c.dy);
const ok = fmt(got) === fmt(c.want);
if (!ok) failed++;
console.log(
` ${c.name.padEnd(32)} ${c.edge.padEnd(5)} ${fmt(got).padEnd(22)} ${ok ? "ok" : `FAIL: want ${fmt(c.want)}`}`,
);
}
// Structural check: every edge must be exercised above, so adding a ninth
// handle without a case cannot slip through.
const covered = new Set(CASES.map((c) => c.edge));
const missing = RESIZE_EDGES.filter((e) => !covered.has(e));
if (missing.length) {
console.log(`\n[resize-math] no case covers: ${missing.join(", ")}`);
failed++;
}
// The clamp must never produce a frame smaller than the minimum, whatever the
// drag — a cheap sweep over every edge and a range of deltas.
for (const edge of RESIZE_EDGES) {
for (const d of [-9999, -1000, -1, 0, 1, 1000, 9999]) {
const got = resizeFrame(edge, START, d, d);
if (got.width < MIN_WIDTH || got.height < MIN_HEIGHT) {
console.log(`\n[resize-math] ${edge} at delta ${d} produced ${fmt(got)} — under the minimum`);
failed++;
}
}
}
if (failed) {
console.log(`\n[resize-math] ${failed} failure(s)`);
process.exit(2);
}
console.log("\n[resize-math] clean");
+106
View File
@@ -0,0 +1,106 @@
// Headless screenshot of the skin harness, so the Win7 look can be reviewed
// visually during development rather than inferred from a clean build log.
//
// bun scripts/shot.ts [--dark] [--width 1200] [--height 1400] [--out path.png]
// bun scripts/shot.ts --url http://localhost:4200 (any page, not just the harness)
//
// Firefox is used because it ships on this machine and has a built-in
// --screenshot mode; no Chromium/puppeteer download is needed. Two constraints
// it imposes:
// - a DEDICATED PROFILE is mandatory. A running Firefox holds a lock on the
// default profile and the headless run aborts with "Firefox is already
// running, but is not responding".
// - it renders with Gecko, NOT the WebKitGTK engine the app actually uses.
// Good enough for skin fidelity (layout, colour, gradients, borders);
// useless for engine-specific behaviour or native window transparency.
// Real confirmation is still `bun run start`.
//
// The harness links the REAL hashed stylesheet from ui/dist (produced by the
// normal `bun run build:ui`), so there is no second build pipeline to keep in
// sync.
//
// The composed page is written INTO that same dist folder, and this is not
// incidental: Firefox's security.fileuri.strict_origin_policy stops a file://
// document from loading a stylesheet that lives in a different directory. Host
// the page elsewhere and it renders completely unstyled — which looks exactly
// like "the skin is broken" rather than "the CSS never loaded". Keeping them
// co-located also lets media/ fonts resolve.
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import path from "node:path";
const ROOT = path.join(import.meta.dir, "..");
const DIST = path.join(ROOT, "ui", "dist", "ui", "browser");
const HARNESS = path.join(ROOT, "ui", "dev", "harness.html");
const WORK = path.join(ROOT, ".shot");
function arg(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : undefined;
}
const flag = (name: string) => process.argv.includes(`--${name}`);
function fail(message: string): never {
console.error(`[shot] ERROR: ${message}`);
process.exit(1);
}
// ---- resolve the built stylesheet ----
if (!existsSync(DIST)) fail(`no build output at ${DIST} — run: bun run build:ui`);
const styles = readdirSync(DIST).find((f) => f.startsWith("styles-") && f.endsWith(".css"));
if (!styles) fail(`no styles-*.css in ${DIST} — run: bun run build:ui`);
// ---- compose the page ----
mkdirSync(WORK, { recursive: true });
const profile = path.join(WORK, "profile");
mkdirSync(profile, { recursive: true });
const url = arg("url");
let target: string;
if (url) {
target = url;
} else {
const dark = flag("dark");
let html = await Bun.file(HARNESS).text();
// replaceAll, not replace: the harness documents its own {{STYLES}} token in
// a header comment that precedes the <link>, so a first-match replace
// substitutes the comment and silently leaves the stylesheet unlinked.
html = html.replaceAll("{{STYLES}}", styles); // relative: page sits next to it
// The preload bakes these attributes in the real app; do it by hand here.
if (dark) html = html.replace('<html class="win7"', '<html class="win7" data-theme="dark"');
const page = path.join(DIST, dark ? "_harness.dark.html" : "_harness.light.html");
await Bun.write(page, html);
target = `file://${page}`;
}
// ---- shoot ----
const out = path.resolve(arg("out") ?? path.join(WORK, flag("dark") ? "dark.png" : "light.png"));
const width = arg("width") ?? "1200";
const height = arg("height") ?? "1500";
// A stale lock from a killed run makes every later shot fail confusingly.
rmSync(path.join(profile, "parent.lock"), { force: true });
const proc = Bun.spawnSync(
[
"firefox",
"--headless",
"--profile",
profile,
"--screenshot",
out,
"--window-size",
`${width},${height}`,
target,
],
{ cwd: ROOT, stdout: "pipe", stderr: "pipe" },
);
if (!existsSync(out)) {
console.error(proc.stderr.toString().trim());
fail("firefox produced no image (see stderr above)");
}
console.log(`[shot] ${out}`);
+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);
+34
View File
@@ -0,0 +1,34 @@
# miniSCP landing page + artifact host.
# Local / internal, plain HTTP on :80 (no domain, no TLS).
# Deploy target is /srv/miniscp — see site/README.md.
:80 {
root * /srv/miniscp
encode zstd gzip
# Flat artifact feed: these files are BOTH the human downloads AND (later) the
# electrobun updater baseUrl. Publishing a build = drop its artifacts/ files here;
# stable-named installers overwrite in place, hashed .patch files accumulate.
handle /dl/* {
# Incremental update patches have unique hashed names → cache forever.
@patch path *.patch
header @patch Cache-Control "public, max-age=31536000, immutable"
# The updater feed must always reflect the newest published version.
@feed path *update.json
header @feed Cache-Control "no-cache"
# Installers/payloads keep stable names but get overwritten each release.
@pkg path *.dmg *.zip *.tar.zst
header @pkg Cache-Control "public, max-age=300"
# browse => directory listing; file_server serves range requests
# (resumable large .dmg downloads) by default.
file_server browse
}
# Static landing page + assets. No SPA index fallback: unknown paths 404.
handle {
file_server
}
}
+69
View File
@@ -0,0 +1,69 @@
# miniSCP site
The all-in-one landing page **and** download host for miniSCP. One static page, one flat
download folder, served by [Caddy](https://caddyserver.com). No JS, no build step.
## Layout
```
site/
index.html # the landing page (pure static HTML/CSS)
Caddyfile # local :80 server config
deploy.sh # copy a build's artifacts into the download folder
assets/ # images (logo, screenshots) — dropped in as they're ready
```
Deploys to `/srv/miniscp` on the server:
```
/srv/miniscp/
index.html
assets/
dl/ # flat folder of all artifacts (the downloads + future updater feed)
```
## Run it
```bash
# from the repo root, on the server
sudo mkdir -p /srv/miniscp/dl
sudo cp site/index.html /srv/miniscp/
sudo cp -r site/assets /srv/miniscp/ # once assets exist
caddy run --config site/Caddyfile --adapter caddyfile
# or install as a service: sudo caddy start --config site/Caddyfile
```
Then open `http://<host>/`.
## Publish a build
electrobun can't cross-compile, so each OS/arch is built on its own machine. On each build
machine, after producing artifacts, drop them into the shared `dl/` folder:
```bash
bun scripts/release.ts # or --target mac-x64 / win-x64
site/deploy.sh # -> /srv/miniscp/dl
```
Serving on a different box than you build on? Stage locally, then sync:
```bash
MINISCP_DL_DIR=./_dl site/deploy.sh
rsync -av ./_dl/ user@host:/srv/miniscp/dl/
```
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.
> **Don't delete old `*.patch` files.** They're the incremental-update trail for the (upcoming)
> built-in updater — removing them forces older installs to re-download the full app.
## Notes
- 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
static and always points at the latest build.
- Builds are currently unsigned — the FAQ on the page covers the macOS `xattr` step and the
Windows SmartScreen prompt.
- `/dl/` is also the intended `release.baseUrl` for the future electrobun auto-updater.
+11
View File
@@ -0,0 +1,11 @@
# Image assets
Drop images here as they're ready; the page references them by relative path.
Planned slots (commented into `../index.html`):
- `logo.svg` — hero logo above the wordmark (`.logo`, 128×128). Source lives at repo
`assets/logo.svg`.
- `screenshot.png` — product shot under the intro paragraphs (`.shot`).
To wire one up, uncomment the matching `<img>` in `index.html`.
Executable
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
# 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.:
# bun scripts/release.ts # or --target mac-x64 / win-x64
# site/deploy.sh
#
# electrobun cannot cross-compile, so each OS/arch is built (and deployed) on its
# own machine; every run just adds/overwrites that target's files in one flat dir.
# Installers keep stable names (overwrite in place); .patch files accumulate — never
# delete old ones, or users on older versions lose their incremental update trail.
#
# Serving locally? Leave MINISCP_DL_DIR unset (defaults to /srv/miniscp/dl).
# Serving on a remote box? Point MINISCP_DL_DIR at a local staging dir, then rsync:
# MINISCP_DL_DIR=./_dl site/deploy.sh
# rsync -av ./_dl/ user@host:/srv/miniscp/dl/
dl="${MINISCP_DL_DIR:-/srv/miniscp/dl}"
mkdir -p "$dl"
# electrobun's flat artifacts (stable-<os>-<arch>-*): installers, update payloads,
# update.json, and (once release.baseUrl is set) .patch files.
if compgen -G 'artifacts/stable-*' >/dev/null; then
cp -f artifacts/stable-* "$dl"/
else
echo "deploy: WARN — no artifacts/stable-* found (did scripts/release.ts run?)" >&2
fi
# Windows portable zip is a release.ts extra (build-numbered) → give it a stable name.
port=$(ls -1 release/win-x64/*-win-x64-portable.zip 2>/dev/null | head -n1 || true)
if [ -n "$port" ]; then
cp -f "$port" "$dl/stable-win-x64-miniSCP-portable.zip"
fi
echo "Published to $dl:"
ls -1 "$dl"
+253
View File
@@ -0,0 +1,253 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>miniSCP: a dual-pane SFTP file manager</title>
<style>
body {
font-family: 'Lucida Console', Menlo, Consolas, monospace;
font-size: 18px;
line-height: 1.5;
color: #111;
background-color: white;
max-width: 1000px;
margin: 0 auto;
padding: 0 50px;
box-sizing: border-box;
}
a {
color: inherit;
}
.landing {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
min-height: 100dvh;
text-align: center;
}
.wordmark {
font-size: 64px;
font-weight: bold;
letter-spacing: -2px;
margin: 0;
}
.wordmark .lo {
color: #999;
}
.tagline {
color: #555;
margin: 10px 0 0;
}
/* Image slot for the logo — drop assets/logo.svg in later.
<img class="logo" src="assets/logo.svg" alt="miniSCP"> */
.logo {
width: 128px;
height: 128px;
display: block;
margin: 0 auto 10px;
}
.links-container {
font-size: 22px;
margin: 24px;
}
.links-container a {
text-decoration: none;
margin: 0 6px;
}
.separator {
letter-spacing: -9px;
color: #ccc;
}
hr {
border: none;
height: 2px;
background-color: lightgrey;
width: 100%;
margin: 40px auto;
}
h2 {
font-weight: bold;
}
.feat {
margin: 0 0 14px;
}
.feat b {
display: inline;
}
/* Screenshot slot — drop assets/screenshot.png in later.
<img class="shot" src="assets/screenshot.png" alt="miniSCP screenshot"> */
.shot {
display: block;
max-width: 100%;
margin: 20px auto;
border: 1px solid #ddd;
}
.downloads {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.dl {
flex: 1 1 200px;
text-decoration: none;
border: 2px solid #111;
padding: 14px 18px;
text-align: center;
}
.dl:hover {
background: #111;
color: #fff;
}
.dl small {
display: block;
color: #888;
font-size: 13px;
margin-top: 4px;
}
.dl:hover small {
color: #bbb;
}
.browse {
color: #888;
font-size: 14px;
margin-top: 14px;
}
.faqtable dt {
margin-bottom: 0.4em;
font-style: italic;
}
.faqtable dd {
margin-left: 0;
margin-bottom: 1.6em;
}
code {
background: #f2f2f2;
padding: 1px 5px;
}
</style>
</head>
<body>
<div class="landing">
<!-- logo image slot goes here later -->
<h1 class="wordmark">mini<span class="lo">SCP</span></h1>
<p class="tagline">A dual-pane SFTP file manager.<br>Same tool you know, only a little different.</p>
<div class="links-container">
<a href="#different">what's different</a><span class="separator">|</span>
<a href="#download">download</a><span class="separator">|</span>
<a href="#faq">FAQ</a>
</div>
</div>
<hr>
<h2 id="different">Same tool you know, only a little different</h2>
<p>If you've ever used a two-pane file manager to move files over SFTP, you already know how to
use miniSCP. Two panels, a connection on each side, send between them. Nothing to relearn.</p>
<p>The difference is in the corners. Both panels are fully symmetric &mdash; either side can be
your local disk or any saved server &mdash; so sending isn't only up or down. You can move files
<b>straight from one server to another</b>, remote to remote, without staging them on your
machine first.
</p>
<p>It's the same familiar tool, sanded down where the old ones snag.</p>
<!-- screenshot slot goes here later -->
<p><b>What's different:</b></p>
<p class="feat"><b>Remote &rarr; remote.</b> Send straight between two servers &mdash; nothing
staged on your machine.</p>
<p class="feat"><b>Symmetric panels.</b> Either side is your local disk or any saved server. Every
panel is equal.</p>
<p class="feat"><b>Knows your servers already.</b> Reads the hosts from your
<code>~/.ssh/config</code>, and imports the saved sessions from your previous client.
</p>
<p class="feat"><b>Themes.</b> Light, dark, or follow the system &mdash; with a set of accent
tones.</p>
<p class="feat"><b>macOS and Windows.</b> macOS is the primary target; Windows 11+ (x64) is in
beta.</p>
<p><b>What's the same:</b></p>
<p class="feat"><b>Tabs that keep working.</b> Open several sessions at once; transfers keep
running in background tabs.</p>
<p class="feat"><b>Edit in place.</b> Open a remote file in your editor &mdash; every save uploads
itself, automatically, until you disconnect.</p>
<p class="feat"><b>Won't eat your files.</b> A multi-layer guard catches when the source and
destination are secretly the same file, even behind NAT or split-DNS, before it can truncate
anything.</p>
<p class="feat"><b>Honest overwrites.</b> Compares size and date, tells you which copy is newer,
and lets you replace-all or skip-all in one go.</p>
<p class="feat"><b>A queue with an ETA.</b> Per-file speed and estimate, cancel anytime.</p>
<p class="feat"><b>Permissions.</b> Rename, delete, create folders and files, and change
permissions (chmod) on remote files.</p>
<hr>
<h2 id="download">Download</h2>
<p>Always the latest stable build.</p>
<div class="downloads">
<a class="dl" href="/dl/stable-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/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>
</div>
<p class="browse">Looking for everything? <a href="/dl/">browse all files</a>.</p>
<hr>
<h2 id="faq">FAQ</h2>
<dl class="faqtable">
<dt>Which platforms are supported?</dt>
<dd>macOS (Apple Silicon and Intel) and Windows 11+ (x64). macOS is the primary target;
Windows is currently in beta.</dd>
<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
quarantine flag once and it opens normally:
<br><code>xattr -cr /Applications/miniSCP.app</code>
</dd>
<dt>Windows warns me with a blue SmartScreen box.</dt>
<dd>The Windows builds aren't signed yet either, so SmartScreen flags them on first run. Choose
"More info" &rarr; "Run anyway". The installer unpacks to your local app data; the portable
zip runs from anywhere.</dd>
<dt>Can I bring my existing servers over?</dt>
<dd>Yes. miniSCP reads the hosts from your <code>~/.ssh/config</code> automatically, and it can
import the saved sessions exported from your previous file-transfer client.</dd>
<dt>Installer or portable on Windows?</dt>
<dd>The installer sets miniSCP up in your user profile with a desktop shortcut. The portable zip
is just the app &mdash; unzip it anywhere and run it, no install.</dd>
</dl>
</body>
</html>
-134
View File
@@ -1,134 +0,0 @@
import { ContextMenu, Utils, type ApplicationMenuItemConfig } from "electrobun/bun";
import type { ColumnKey } from "../shared/types";
/** Optional list columns, in display order (Name is fixed). */
const COLUMNS: { key: ColumnKey; label: string }[] = [
{ key: "size", label: "Size" },
{ key: "mtime", label: "Modified" },
{ key: "kind", label: "Kind" },
{ key: "rights", label: "Rights" },
{ key: "owner", label: "Owner" },
{ key: "group", label: "Group" },
];
// PORTABILITY: shell-integration wording per OS
const IS_WIN = process.platform === "win32";
const REVEAL_LABEL =
process.platform === "darwin"
? "Reveal in Finder"
: IS_WIN
? "Show in Explorer"
: "Show in File Manager";
const TRASH_LABEL = IS_WIN ? "Move to Recycle Bin" : "Move to Trash";
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(
COLUMNS.map(({ key, label }) => ({
label,
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: "New Folder", action: "ctx-new-folder" });
items.push({ label: "New File", action: "ctx-new-file" });
items.push({ type: "separator" });
items.push({ label: "Refresh", action: "ctx-refresh" });
if (ctx.isLocal) {
items.push({ type: "separator" });
items.push({ label: REVEAL_LABEL, action: "ctx-reveal" });
}
return items;
}
if (ctx.type === "dir") {
items.push({ label: "Open", action: "ctx-open" });
} else {
if (ctx.isLocal) {
items.push({ label: "Open", action: "ctx-open-default", tooltip: "Open with the default application" });
}
items.push({ label: "Edit", action: "ctx-edit", accelerator: "e" });
}
items.push({ label: "Copy to Other Panel", action: "ctx-copy" });
items.push({ label: "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: "Permissions", action: "ctx-chmod" });
}
items.push({ type: "separator" });
items.push({ label: "Copy Path", action: "ctx-copy-path" });
if (ctx.isLocal) {
items.push({ label: REVEAL_LABEL, action: "ctx-reveal" });
}
items.push({ type: "separator" });
if (ctx.isLocal) {
items.push({
label: TRASH_LABEL,
action: "ctx-trash",
...(process.platform === "darwin"
? { tooltip: "Finder's Restore is not available for items trashed from here" }
: {}),
});
}
items.push({ label: "Delete", action: "ctx-delete" });
return items;
}
}
Binary file not shown.
+3 -2
View File
@@ -1,3 +1,4 @@
import { t } from "../i18n";
import { LocalProvider } from "./local"; import { LocalProvider } from "./local";
import type { FileSystemProvider } from "./provider"; import type { FileSystemProvider } from "./provider";
@@ -15,7 +16,7 @@ export class SessionRegistry {
get(sessionId: string): FileSystemProvider { get(sessionId: string): FileSystemProvider {
const provider = this.sessions.get(sessionId); const provider = this.sessions.get(sessionId);
if (!provider) throw new Error(`Session "${sessionId}" is not connected`); if (!provider) throw new Error(t("errors.sessionNotConnected", { id: sessionId }));
return provider; return provider;
} }
@@ -27,7 +28,7 @@ export class SessionRegistry {
async remove(sessionId: string): Promise<void> { async remove(sessionId: string): Promise<void> {
if (sessionId === LOCAL_SESSION_ID) if (sessionId === LOCAL_SESSION_ID)
throw new Error("The local session cannot be disconnected"); throw new Error(t("errors.localCannotDisconnect"));
const provider = this.sessions.get(sessionId); const provider = this.sessions.get(sessionId);
if (!provider) return; if (!provider) return;
this.sessions.delete(sessionId); this.sessions.delete(sessionId);
+3 -2
View File
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { Client, type ConnectConfig, type SFTPWrapper } from "ssh2"; import { Client, type ConnectConfig, type SFTPWrapper } from "ssh2";
import type { ConnectionConfig, EntryType, FileEntry } from "../../shared/types"; import type { ConnectionConfig, EntryType, FileEntry } from "../../shared/types";
import { t } from "../i18n";
import { expandTilde } from "../store/ssh-config"; import { expandTilde } from "../store/ssh-config";
import type { FileSystemProvider } from "./provider"; import type { FileSystemProvider } from "./provider";
@@ -63,7 +64,7 @@ export class SftpProvider implements FileSystemProvider {
connectConfig.password = config.auth.password; connectConfig.password = config.auth.password;
break; break;
case "agent": case "agent":
if (!agentSock) throw new Error("ssh-agent not available (SSH_AUTH_SOCK is not set)"); if (!agentSock) throw new Error(t("errors.sshAgentUnavailable"));
connectConfig.agent = agentSock; connectConfig.agent = agentSock;
break; break;
case "key": { case "key": {
@@ -73,7 +74,7 @@ export class SftpProvider implements FileSystemProvider {
connectConfig.privateKey = readFileSync(expandTilde(config.auth.privateKeyPath)); connectConfig.privateKey = readFileSync(expandTilde(config.auth.privateKeyPath));
} catch { } catch {
if (!agentSock) if (!agentSock)
throw new Error(`identity file not found: ${config.auth.privateKeyPath}`); throw new Error(t("errors.identityFileNotFound", { path: config.auth.privateKeyPath }));
} }
if (config.auth.passphrase) connectConfig.passphrase = config.auth.passphrase; if (config.auth.passphrase) connectConfig.passphrase = config.auth.passphrase;
else if (agentSock) connectConfig.agent = agentSock; else if (agentSock) connectConfig.agent = agentSock;
+64
View File
@@ -0,0 +1,64 @@
import { type FSWatcher, watch } from "node:fs";
const DEBOUNCE_MS = 150;
/**
* Watches a set of local directories with node's fs.watch and fires onChange
* (debounced per dir — fs.watch emits bursts) when their contents change. Used
* to auto-refresh the active tab's local panels instantly; SFTP has no
* equivalent, so remote panels are polled UI-side instead.
*
* onChange is late-bound (assigned after the RPC exists, like AppDeps.events)
* so it can push pathRefresh to the main webview.
*/
export class LocalDirWatcher {
onChange: (dir: string) => void = () => {};
private readonly handles = new Map<string, FSWatcher>();
private readonly debounces = new Map<string, ReturnType<typeof setTimeout>>();
/** Replace the watched set: drop handles no longer wanted, open new ones. */
setWatched(dirs: string[]): void {
const wanted = new Set(dirs);
for (const [dir, handle] of this.handles) {
if (!wanted.has(dir)) {
handle.close();
this.handles.delete(dir);
}
}
for (const dir of wanted) {
if (this.handles.has(dir)) continue;
try {
const handle = watch(dir, () => this.fire(dir));
// a watched dir that gets deleted/unmounted errors — just drop it
handle.on("error", () => this.drop(dir));
this.handles.set(dir, handle);
} catch {
// dir vanished between listing and watching — ignore
}
}
}
private fire(dir: string): void {
const pending = this.debounces.get(dir);
if (pending) clearTimeout(pending);
this.debounces.set(
dir,
setTimeout(() => {
this.debounces.delete(dir);
this.onChange(dir);
}, DEBOUNCE_MS),
);
}
private drop(dir: string): void {
this.handles.get(dir)?.close();
this.handles.delete(dir);
}
dispose(): void {
for (const handle of this.handles.values()) handle.close();
this.handles.clear();
for (const pending of this.debounces.values()) clearTimeout(pending);
this.debounces.clear();
}
}
+38
View File
@@ -0,0 +1,38 @@
import { DICTS, type Messages, resolveLocale } from "../shared/i18n";
import type { AppSettings, SupportedLocale } from "../shared/types";
// Backend-side translator for the native menus and thrown error messages
// (the Angular I18nService can't reach OS chrome). The locale is cached and
// refreshed from settings (see index.ts wiring); no signals here.
let locale: SupportedLocale = "en";
/** OS locale for the "system" setting, e.g. "es-ES" -> resolves to "es". */
function osLocale(): string {
try {
return new Intl.DateTimeFormat().resolvedOptions().locale;
} catch {
return "en";
}
}
export function setLocale(setting: AppSettings["locale"]): void {
locale = resolveLocale(setting, osLocale());
}
function lookup(dict: Messages, key: string): string | undefined {
let node: string | Messages | undefined = dict;
for (const part of key.split(".")) {
if (typeof node !== "object") return undefined;
node = node[part];
}
return typeof node === "string" ? node : undefined;
}
/** Translate a key with {name} interpolation; falls back to English then the key. */
export function t(key: string, params?: Record<string, string | number>): string {
const value = lookup(DICTS[locale], key) ?? lookup(DICTS.en, key) ?? key;
if (!params) return value;
return value.replace(/\{(\w+)\}/g, (_, name: string) =>
name in params ? String(params[name]) : `{${name}}`,
);
}
+69 -13
View File
@@ -2,11 +2,12 @@ import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "elec
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";
import { LocalDirWatcher } from "./fs/watcher";
import { ManagerWindow } from "./manager-window"; import { ManagerWindow } from "./manager-window";
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 { ConnectionStore } from "./store/connections"; import { ConnectionStore } from "./store/connections";
@@ -14,7 +15,13 @@ 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,
initSessionSkin,
showWhenReady,
trackMaximizeState,
} from "./windows";
const PROD_URL = "views://app/index.html"; const PROD_URL = "views://app/index.html";
@@ -44,7 +51,7 @@ 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 events = { const events = {
sessionClosed: (_info: { sessionId: string; reason: string }) => {}, sessionClosed: (_info: { sessionId: string; reason: string }) => {},
connectionsChanged: () => {}, connectionsChanged: () => {},
@@ -53,15 +60,33 @@ const events = {
openConnectionManager: () => {}, openConnectionManager: () => {},
showAbout: () => {}, showAbout: () => {},
}; };
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events }); // Late-bound for the same reason events is: `win` below takes this rpc in its
// constructor, so it cannot exist yet. Only the win7 title bar reads it.
let mainWin: BrowserWindow | undefined;
const rpc = createAppRPC({
registry,
engine,
store,
settings,
edits,
watcher,
mainWindow: () => mainWin,
events,
});
// late-bound like events: a watched local dir changed -> tell the main webview
watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir });
const url = await getMainViewUrl(); const url = await getMainViewUrl();
// Loaded before any window exists: the persisted theme is baked into every // Loaded before any window exists: the persisted theme is baked into every
// window's preload so the first paint is already themed (no flash). Kept // window's preload so the first paint is already themed (no flash). Kept
// fresh via the saveSettings flow below. // fresh via the saveSettings flow below.
let currentSettings = await settings.load(); let currentSettings = await settings.load();
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
const isMac = process.platform === "darwin"; // Freeze the skin for this process BEFORE any window is constructed. It picks
// a component tree and a native frame, neither of which can change on a live
// window, so the persisted value may only take effect at the next start — see
// initSessionSkin.
initSessionSkin(currentSettings.skin);
const win = new BrowserWindow({ const win = new BrowserWindow({
title: BRANDING.appName, title: BRANDING.appName,
@@ -72,12 +97,9 @@ 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 — see windows.ts. Constructor-only,
// UI's h-10 tab row (offset centers them vertically). Other platforms keep // and read from the pinned session skin, not from currentSettings.
// the native titlebar and the tab bar stays a separate row. ...chromeOptions(),
...(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,
@@ -85,6 +107,8 @@ const win = new BrowserWindow({
hidden: true, hidden: true,
}); });
showWhenReady(win); showWhenReady(win);
mainWin = win;
trackMaximizeState(win, (maximized) => rpc.send.windowState({ maximized }));
const editors = new EditorWindows({ const editors = new EditorWindows({
registry, registry,
@@ -111,7 +135,12 @@ const manager = new ManagerWindow({
events.openEditor = (ctx) => editors.open(ctx); events.openEditor = (ctx) => editors.open(ctx);
events.settingsChanged = (s) => { events.settingsChanged = (s) => {
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) {
setBackendLocale(s.locale);
void applyApplicationMenu(store); // relabel the native menu in the new language
}
editors.broadcastSettings(s); editors.broadcastSettings(s);
manager.broadcastSettings(s); manager.broadcastSettings(s);
}; };
@@ -138,7 +167,6 @@ win.on("close", () => {
}); });
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;
@@ -234,9 +262,37 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
showAbout(); showAbout();
return; return;
} }
if (action === "quit") {
// Utils.quit() emits before-quit -> the guard runs (transfers/unsaved).
// Handled before the manager check so ⌘Q quits even with it open.
Utils.quit();
return;
}
// the manager is modal to the main window — don't drive main behind it // the manager is modal to the main window — don't drive main behind it
if (manager.isOpen()) return; if (manager.isOpen()) return;
rpc.send.menuAction({ action }); rpc.send.menuAction({ action });
}); });
// ---- auto-updater: check on launch + every 6h, stream status to the tab bar ----
// Updater.checkForUpdate() self-guards the dev channel (returns updateAvailable:
// false), so the pill never appears during `bun run dev`.
Updater.onStatusChange((entry) => {
if (entry.status === "download-progress") {
rpc.send.updateStatus({ phase: "downloading", progress: entry.details?.progress ?? 0 });
} else if (entry.status === "error") {
rpc.send.updateStatus({ phase: "error", error: entry.message });
}
});
async function runUpdateCheck(): Promise<void> {
try {
const r = await Updater.checkForUpdate();
if (r.updateAvailable) rpc.send.updateStatus({ phase: "available", version: r.version });
} catch {
// offline / feed unreachable — stay quiet and retry on the next tick
}
}
setTimeout(() => void runUpdateCheck(), 5_000); // let the window settle first
setInterval(() => void runUpdateCheck(), 6 * 60 * 60 * 1000);
console.log(`${BRANDING.appName} started (${url})`); console.log(`${BRANDING.appName} started (${url})`);
+2
View File
@@ -70,6 +70,8 @@ export class ManagerWindow {
this.close(); this.close();
}, },
onClose: () => this.close(), onClose: () => this.close(),
// late-bound: the window takes this rpc in its constructor
window: () => this.win ?? undefined,
}, },
); );
+20 -14
View File
@@ -1,6 +1,7 @@
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/bun"; import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/bun";
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 type { ConnectionStore } from "./store/connections"; import type { ConnectionStore } from "./store/connections";
/** /**
@@ -27,37 +28,42 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
...looseItems, ...looseItems,
...stackMenus, ...stackMenus,
...(looseItems.length || stackMenus.length ? [{ type: "separator" } as const] : []), ...(looseItems.length || stackMenus.length ? [{ type: "separator" } as const] : []),
{ label: "Connection Manager…", action: "manage-connections" }, { label: t("menu.connectionManager"), action: "manage-connections" },
]; ];
ApplicationMenu.setApplicationMenu([ ApplicationMenu.setApplicationMenu([
{ {
submenu: [ submenu: [
{ label: `About ${BRANDING.appName}`, action: "about" }, { label: t("menu.about", { app: BRANDING.appName }), action: "about" },
{ type: "separator" },
{ label: t("menu.checkForUpdates"), action: "check-for-updates" },
{ type: "separator" }, { type: "separator" },
{ role: "hide" }, { role: "hide" },
{ role: "hideOthers" }, { role: "hideOthers" },
{ role: "showAll" }, { role: "showAll" },
{ type: "separator" }, { type: "separator" },
{ label: "Quit", role: "quit" }, // Custom action (not role:"quit") + explicit ⌘Q: routes through
// Utils.quit() so the before-quit guard (active transfers / unsaved
// editors) always runs instead of the OS terminating directly.
{ label: t("menu.quit"), action: "quit", accelerator: "q" },
], ],
}, },
{ {
label: "File", label: t("menu.file"),
submenu: [ submenu: [
{ label: "New Tab", action: "new-tab", accelerator: "t" }, { label: t("menu.newTab"), action: "new-tab", accelerator: "t" },
{ label: "Close Tab", action: "close-tab", accelerator: "w" }, { label: t("menu.closeTab"), action: "close-tab", accelerator: "w" },
{ type: "separator" }, { type: "separator" },
{ label: "New Folder", action: "new-folder" }, { label: t("menu.newFolder"), action: "new-folder" },
{ label: "New File", action: "new-file" }, { label: t("menu.newFile"), action: "new-file" },
{ type: "separator" }, { type: "separator" },
{ label: "Edit File", action: "edit-file", accelerator: "e" }, { label: t("menu.editFile"), action: "edit-file", accelerator: "e" },
{ type: "separator" }, { type: "separator" },
{ label: "Connect", submenu: connectSubmenu }, { label: t("menu.connect"), submenu: connectSubmenu },
], ],
}, },
{ {
label: "Edit", label: t("menu.edit"),
submenu: [ submenu: [
{ role: "undo" }, { role: "undo" },
{ role: "redo" }, { role: "redo" },
@@ -71,15 +77,15 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
], ],
}, },
{ {
label: "View", label: t("menu.view"),
submenu: [ submenu: [
{ label: "Refresh", action: "refresh-active", accelerator: "r" }, { label: t("menu.refresh"), action: "refresh-active", accelerator: "r" },
{ type: "separator" }, { type: "separator" },
{ role: "toggleFullScreen" }, { role: "toggleFullScreen" },
], ],
}, },
{ {
label: "Window", label: t("menu.window"),
submenu: [ submenu: [
{ role: "minimize" }, { role: "minimize" },
{ role: "zoom" }, { role: "zoom" },
+130 -13
View File
@@ -1,10 +1,10 @@
import { BrowserView, Utils } from "electrobun/bun"; import { BrowserView, type BrowserWindow, Updater, Utils } from "electrobun/bun";
import type { AppRPC, EditorOpenContext } from "../shared/rpc"; import type { AppRPC, EditorOpenContext } from "../shared/rpc";
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types"; import { effectiveConcurrency, type AppSettings, type 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";
import type { LocalDirWatcher } from "./fs/watcher";
import { SftpProvider } from "./fs/sftp"; import { SftpProvider } from "./fs/sftp";
import type { ConnectionStore } from "./store/connections"; import type { ConnectionStore } from "./store/connections";
import type { SettingsStore } from "./store/settings"; import type { SettingsStore } from "./store/settings";
@@ -23,7 +23,13 @@ export interface AppDeps {
store: ConnectionStore; store: ConnectionStore;
settings: SettingsStore; settings: SettingsStore;
edits: EditManager; edits: EditManager;
contextMenu: PanelContextMenu; watcher: LocalDirWatcher;
/**
* The main BrowserWindow, for the win7 skin's custom title-bar controls.
* Lazy for the same reason events is late-bound: the window takes this rpc
* instance in its constructor, so it does not exist yet at createAppRPC time.
*/
mainWindow: () => BrowserWindow | undefined;
/** Assigned after the rpc object exists (it needs rpc.send). */ /** Assigned after the rpc object exists (it needs rpc.send). */
events: { events: {
sessionClosed: (info: { sessionId: string; reason: string }) => void; sessionClosed: (info: { sessionId: string; reason: string }) => void;
@@ -43,6 +49,78 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
} }
} }
/**
* Window-control requests for the win7 skin's custom title bar. Shared by all
* three window kinds; each passes a getter for ITS OWN window.
*
* The getter is lazy because the main window's rpc instance is constructed
* before the BrowserWindow exists (the window takes the rpc in its
* constructor) — same late-binding reason as AppDeps.events. Under the modern
* skin nothing calls these; they are harmless no-ops if the window is gone.
*/
export function windowControlHandlers(
getWindow: () => BrowserWindow | undefined,
/**
* What the title bar's X does. Defaults to a raw `win.close()`. Editor
* windows MUST pass their confirmed-close hook instead — the native close
* is not cancellable, so a raw close there would skip the dirty prompt
* (see gotchas.md "Window `close` is not cancellable").
*/
onClose: (win: BrowserWindow | undefined) => void = (win) => win?.close(),
) {
return {
windowMinimize: () =>
wrap(() => {
getWindow()?.minimize();
return {};
}),
windowToggleMaximize: () =>
wrap(() => {
const win = getWindow();
if (!win) return { maximized: false };
// isMaximized is a synchronous FFI read, so this stays one round trip
const next = !win.isMaximized();
if (next) win.maximize();
else win.unmaximize();
return { maximized: next };
}),
windowClose: () =>
wrap(() => {
onClose(getWindow());
return {};
}),
windowIsMaximized: () => wrap(() => ({ maximized: getWindow()?.isMaximized() ?? false })),
// ---- STOPGAP: synthesized resize ----
// A frameless GTK window has no resize borders, and electrobun exposes no
// native resize gesture (it has startWindowMove and nothing else — the
// shipped libNativeWrapper does not even link gtk_window_begin_resize_drag).
// So the webview drags its own handles and moves the frame itself. Both are
// synchronous FFI reads/writes, like isMaximized above, so each drag tick
// stays one round trip. DELETE WITH THE REST OF THE HACK — the checklist is
// in ui/src/app/skins/7like/window-resize/window-resize.ts.
windowGetFrame: () =>
wrap(() => getWindow()?.getFrame() ?? { x: 0, y: 0, width: 0, height: 0 }),
// Params are annotated from the schema rather than inferred: this factory
// returns a bare object literal, so it gets none of the contextual typing
// the inline handler sets inside defineRPC<AppRPC> enjoy.
windowSetFrame: ({
x,
y,
width,
height,
}: AppRPC["bun"]["requests"]["windowSetFrame"]["params"]) =>
wrap(() => {
getWindow()?.setFrame(x, y, width, height);
return {};
}),
};
}
/** Connection-store requests, shared by the main and manager window rpcs. */ /** Connection-store requests, shared by the main and manager window rpcs. */
function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) { function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) {
return { return {
@@ -120,7 +198,8 @@ export function createAppRPC({
store, store,
settings, settings,
edits, edits,
contextMenu, watcher,
mainWindow,
events, events,
}: AppDeps) { }: AppDeps) {
return BrowserView.defineRPC<AppRPC>({ return BrowserView.defineRPC<AppRPC>({
@@ -160,6 +239,12 @@ export function createAppRPC({
return { path: dir, entries: await fs.list(dir) }; return { path: dir, entries: await fs.list(dir) };
}), }),
setWatchedDirs: ({ dirs }) =>
wrap(async () => {
watcher.setWatched(dirs);
return {};
}),
mkdir: ({ sessionId, path }) => mkdir: ({ sessionId, path }) =>
wrap(async () => { wrap(async () => {
await registry.get(sessionId).mkdir(path); await registry.get(sessionId).mkdir(path);
@@ -212,6 +297,9 @@ export function createAppRPC({
return {}; return {};
}), }),
// ---- custom window chrome (win7 skin) ----
...windowControlHandlers(mainWindow),
// ---- connection store ---- // ---- connection store ----
...connectionStoreHandlers(store, () => events.connectionsChanged()), ...connectionStoreHandlers(store, () => events.connectionsChanged()),
@@ -273,10 +361,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 {};
}), }),
@@ -286,12 +376,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 () => {
@@ -313,6 +397,25 @@ export function createAppRPC({
}), }),
listDrives: () => wrap(async () => ({ drives: listLocalDrives() })), listDrives: () => wrap(async () => ({ drives: listLocalDrives() })),
// ---- auto-updater ----
checkForUpdate: () =>
wrap(async () => {
const r = await Updater.checkForUpdate();
return { updateAvailable: r.updateAvailable, version: r.version };
}),
downloadUpdate: () =>
wrap(async () => {
await Updater.downloadUpdate();
return {};
}),
applyUpdate: () =>
wrap(async () => {
await Updater.applyUpdate(); // relaunches + quits; may never resolve
return {};
}),
}, },
}, },
}); });
@@ -326,6 +429,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, for the win7 title-bar controls (late-bound like the rest). */
window: () => BrowserWindow | undefined;
} }
/** /**
@@ -361,6 +466,12 @@ export function createEditorRPC(
hooks.onCloseConfirmed(); hooks.onCloseConfirmed();
return {}; return {};
}), }),
// win7 skin: this window is frameless, so the webview draws the
// chrome. The X routes through onCloseConfirmed (suppressPrompt +
// close), exactly like editorClose — the editor UI runs its dirty
// prompt BEFORE calling this, same as the ⌘W path.
...windowControlHandlers(hooks.window, () => hooks.onCloseConfirmed()),
}, },
messages: { messages: {
editorState: (state) => hooks.onDraft(state), editorState: (state) => hooks.onDraft(state),
@@ -376,6 +487,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, for the win7 title-bar controls (late-bound like the rest). */
window: () => BrowserWindow | undefined;
} }
/** RPC for the single connection-manager window (per-window instance). */ /** RPC for the single connection-manager window (per-window instance). */
@@ -408,6 +521,10 @@ export function createManagerRPC(
hooks.onClose(); hooks.onClose();
return {}; return {};
}), }),
// win7 skin: frameless window, webview-drawn chrome. The X reuses
// the managerClose path so the modal bookkeeping still runs.
...windowControlHandlers(hooks.window, () => hooks.onClose()),
}, },
}, },
}); });
+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 };
} }
+5 -4
View File
@@ -4,6 +4,7 @@ import { BRANDING } from "../../shared/branding";
import type { EntryType, TransferSnapshot, TransferState } from "../../shared/types"; import type { EntryType, TransferSnapshot, TransferState } from "../../shared/types";
import type { FileSystemProvider } from "../fs/provider"; import type { FileSystemProvider } from "../fs/provider";
import type { SessionRegistry } from "../fs/registry"; import type { SessionRegistry } from "../fs/registry";
import { t } from "../i18n";
import { walkTree } from "./walk"; import { walkTree } from "./walk";
const PROGRESS_INTERVAL_MS = 100; const PROGRESS_INTERVAL_MS = 100;
@@ -59,7 +60,7 @@ async function ensureDir(fs: FileSystemProvider, path: string): Promise<void> {
try { try {
const st = await fs.stat(path); const st = await fs.stat(path);
if (st.type === "dir") return; if (st.type === "dir") return;
throw new Error(`"${path}" exists and is not a directory`); throw new Error(t("errors.notADirectory", { path }));
} catch { } catch {
await fs.mkdir(path); await fs.mkdir(path);
} }
@@ -235,7 +236,7 @@ export class TransferEngine {
if (sameFs) { if (sameFs) {
for (const file of files) { for (const file of files) {
if (file.srcPath === file.dstPath) { if (file.srcPath === file.dstPath) {
throw new Error(`"${file.name}": source and destination are the same file`); throw new Error(t("errors.sameFileTransfer", { name: file.name }));
} }
} }
} }
@@ -343,10 +344,10 @@ export class TransferEngine {
if (samePath) { if (samePath) {
throw new Error( throw new Error(
isDir ? "Cannot copy a folder onto itself" : "Source and destination are the same file", isDir ? t("errors.folderOntoItself") : t("errors.sameFileNoName"),
); );
} }
throw new Error("Cannot copy a folder into itself"); throw new Error(t("errors.folderIntoItself"));
} }
/** /**
+100 -13
View File
@@ -1,7 +1,27 @@
import { BrowserWindow } from "electrobun/bun"; import { BrowserWindow } from "electrobun/bun";
import type { AppSettings } from "../shared/types"; import { DEFAULT_SETTINGS, type AppSettings } from "../shared/types";
const isMac = process.platform === "darwin"; /**
* The skin THIS PROCESS booted with, frozen at startup by `initSessionSkin`.
*
* Every window must be built from this, never from the live settings snapshot.
* The skin picks a whole component tree (bootstrapped from the preload-baked
* __APP_SKIN) and the native frame (constructor-only), so it cannot change
* without a restart — but the persisted setting CAN change mid-session. Reading
* the live value would boot a newly opened editor or manager into the pending
* skin while the main window still runs the old one: two windows, two trees,
* two frames.
*
* Pinning it in one place makes that impossible rather than making each call
* site remember. Everything else in the preload keeps using the live snapshot,
* because theme/tone/accent/text-size genuinely do apply live.
*/
let sessionSkin: AppSettings["skin"] = DEFAULT_SETTINGS.skin;
/** Call once, at startup, before any window is constructed. */
export function initSessionSkin(skin: AppSettings["skin"]): void {
sessionSkin = skin;
}
/** /**
* The preload globals every window gets. Single composition point: the UI * The preload globals every window gets. Single composition point: the UI
@@ -19,22 +39,70 @@ export function buildPreload(
settings: AppSettings, settings: AppSettings,
ctx?: unknown, ctx?: unknown,
): string { ): string {
// sessionSkin, NOT settings.skin — a pending skin change must not leak into
// a window opened before the restart. See initSessionSkin.
let script = let script =
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` + `window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
`window.__APP_MODE=${JSON.stringify(mode)};`; `window.__APP_MODE=${JSON.stringify(mode)};` +
`window.__APP_SKIN=${JSON.stringify(sessionSkin)};`;
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`; if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
// A class, not a data-* attribute: 7.scoped.css is precompiled to require a
// literal `.win7` ancestor, so the name is not ours to choose. This is the
// ONLY place it is ever written — the UI must never toggle it live, because
// each skin's CSS is written against its own tree's markup.
if (sessionSkin === "win7") {
script += `document.documentElement.classList.add("win7");`;
}
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; if (settings.accent !== "graphite") dataset["accent"] = settings.accent;
if (settings.textSize !== "default") dataset["text"] = settings.textSize; // interface scale: mirrors SettingsService.applyUiScale so the FIRST paint
// 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 options for a window. The ONE place the frame decision is made —
* both the main window (src/bun/index.ts) and openSecondaryWindow spread this.
*
* Both values are BrowserWindow CONSTRUCTOR options — there is no API to
* re-decorate a live window — which is half of why the skin is a launch-time
* choice. Takes no argument on purpose: it reads the pinned sessionSkin, so no
* call site can accidentally frame a window from a pending setting.
*/
export function chromeOptions() {
const skin = sessionSkin;
// win7 draws its own Aero title bar (drag region + our minimize/maximize/
// close), so the window is frameless on EVERY platform. `titleBarStyle:
// "hidden"` clears `Titled` from the Cocoa style mask, which is also what
// removes the macOS traffic lights — there is no separate
// hide-traffic-lights API. Linux honours it too: the GTK wrapper calls
// gtk_window_set_decorated(false).
//
// `transparent` is what lets the Aero frame be a real frame: .window's 6px
// radius is drawn by the webview OUTSIDE the window's own rectangle, so on
// an opaque surface the corners are simply clipped square.
// PORTABILITY: on Linux this needs a compositor granting an RGBA visual.
if (skin === "win7") return { titleBarStyle: "hidden" as const, transparent: true };
// modern: the native frame stays. On macOS it fuses with the app's own
// header row (hiddenInset), and the traffic lights overlay it — which is
// what IS_MAC_CHROME reserves pl-24 for in the shadlike tree.
return process.platform === "darwin"
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
: {};
}
/** /**
* 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
@@ -55,6 +123,31 @@ export function showWhenReady(win: BrowserWindow): void {
}); });
} }
/**
* Push maximize-state changes to a window's webview so the win7 title bar can
* swap its maximize/restore glyph.
*
* electrobun emits NO maximize/unmaximize event (only close/resize/move/focus/
* blur/keyDown/keyUp), so the state is derived from `resize` — which is also
* what makes OS-initiated changes work: double-click zoom, Win+Up, Aero Snap
* all resize the window. `isMaximized()` is a synchronous FFI read, so polling
* it per resize is cheap; the last-value guard keeps the wire quiet during a
* drag-resize. The emitter has no off(), so this closure must not capture
* anything that needs to be garbage collected (see WindowRegistry.track).
*/
export function trackMaximizeState(
win: BrowserWindow,
push: (maximized: boolean) => void,
): void {
let last = win.isMaximized();
win.on("resize", () => {
const now = win.isMaximized();
if (now === last) return;
last = now;
push(now);
});
}
export interface SecondaryWindowOptions { 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). */
@@ -70,21 +163,15 @@ export interface SecondaryWindowOptions {
} }
/** /**
* App-chromed secondary window. * App-chromed secondary window. The frame decision is delegated to
* * chromeOptions (the single frame decision).
* 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,
+35
View File
@@ -0,0 +1,35 @@
# Translations
`en.ts` is the **source of truth**. Every other locale mirrors its key shape;
any missing key falls back to English at lookup time (see `index.ts` +
`I18nService`/backend `i18n.ts`), so partial translations are safe.
## Adding / editing strings
1. Add the key to `en.ts` (nested namespace, `{param}` interpolation). For
plurals, use a `{ one, other }` map instead of a string — the runtime picks
the CLDR category via `Intl.PluralRules` (languages without plural, e.g. zh
and ja, only need `other`).
2. Reference it as `i18n.t('ns.key', { param })` in the UI (or backend `t(...)`
for native menus / thrown errors).
3. Add the translation to the other locale files as convenient — until then
English shows through.
The format is kept **Transloco-compatible** (nested namespaces, `{param}`) so
swapping in a real i18n library later is a drop-in of these dictionaries.
## Review status
| Locale | File | Status |
|---|---|---|
| English | `en.ts` | Source |
| Spanish | `es.ts` | Full draft |
| French | `fr.ts` | Full draft |
| German | `de.ts` | Full draft |
| Chinese (Simplified) | `zh.ts` | **AI draft — needs native review** |
| Japanese | `ja.ts` | **AI draft — needs native review** |
| Basque | `eu.ts` | **AI draft — needs native review (highest priority; machine/AI Basque is unreliable)** |
Native-speaker review is especially important for Basque and the CJK locales:
grammatical agreement, counters/measure words, and UI-context word choice are
easy to get subtly wrong.
+315
View File
@@ -0,0 +1,315 @@
// German translations. Missing keys fall back to English (see en.ts / index.ts).
export const de = {
common: {
cancel: "Abbrechen",
save: "Speichern",
close: "Schließen",
delete: "Löschen",
rename: "Umbenennen",
create: "Erstellen",
apply: "Anwenden",
choose: "Wählen…",
connect: "Verbinden",
local: "Lokal",
},
app: {
notConnectedTitle: "Nicht mit der App verbunden",
notConnectedBody: "Diese Seite läuft in einem normalen Browser. Starte die Desktop-App, um sie zu verwenden.",
managerOpen: "Der Verbindungsmanager ist geöffnet.",
showConnections: "Verbindungen anzeigen",
},
tabBar: {
connections: "Verbindungen",
manageConnections: "Verbindungen verwalten",
newTab: "Neuer Tab ({mod}T)",
closeTab: "Tab schließen ({mod}W)",
options: "Optionen",
update: "Update verfügbar",
updating: "Aktualisiere",
restartToUpdate: "Zum Anwenden neu starten",
updateTooltip: "Update verfügbar: miniSCP {version}",
},
options: {
title: "Optionen",
language: "Sprache",
system: "System",
skin: {
label: "Optik",
modern: "Modern",
win7: "Windows 7",
restartHint: "Wird beim nächsten Start von miniSCP übernommen",
},
theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" },
tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" },
accent: { label: "Akzent" },
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", smaller: "Kleiner", larger: "Größer", reset: "Auf 100% zurücksetzen" },
speedUnit: { label: "Geschwindigkeitseinheit" },
transfers: { label: "Übertragungen", single: "Einzeln", multi: "Mehrere" },
atOnce: "Gleichzeitig",
editor: { label: "Editor", builtin: "Integriert", custom: "Benutzerdefiniert" },
systemDefault: "Systemstandard",
resetDefault: "Auf Standard zurücksetzen",
finishedTransfers: { label: "Abgeschlossene Übertragungen", autoClear: "Auto-löschen", keep: "Behalten" },
},
kinds: {
folder: "Ordner",
symlink: "Symbolischer Link",
file: "Datei",
image: "Bild",
audio: "Audio",
video: "Video",
archive: "Archiv",
code: "Code",
document: "Dokument",
},
columns: {
name: "Name",
size: "Größe",
mtime: "Geändert",
kind: "Typ",
rights: "Rechte",
owner: "Eigentümer",
group: "Gruppe",
},
panel: {
choosePanel: "Wählen, was dieses Panel anzeigt",
copy: "Senden",
copyToRight: "An rechtes Panel senden (F5)",
copyToLeft: "An linkes Panel senden (F5)",
edit: "Bearbeiten",
editFile: "Datei bearbeiten (F4)",
rename: "Umbenennen (F2)",
newFolder: "Neuer Ordner (F7)",
newFile: "Neue Datei",
permissions: "Berechtigungen",
delete: "Löschen",
deleteHint: "Löschen (F8 / Entf)",
parentDir: "Übergeordneter Ordner (Rücktaste)",
refresh: "Aktualisieren",
clearFilter: "Filter löschen (Esc)",
sshConfig: "~/.ssh/config",
connectingTo: "Verbinde mit {name}…",
emptyDir: "Leerer Ordner",
noMatches: "Keine Treffer",
matches: { one: "{count} Treffer · Esc", other: "{count} Treffer · Esc" },
},
connMgr: {
title: "Verbindungen",
importWinscp: "Aus WinSCP importieren…",
importWinscpHint: "Sitzungen aus einem WinSCP.ini-Export importieren",
newConnection: "Verbindung",
newConnectionHint: "Neue Verbindung",
newStack: "Gruppe",
newStackHint: "Neue Gruppe",
expand: "Aufklappen",
collapse: "Zuklappen",
renameStack: "Gruppe umbenennen",
deleteStack: "Gruppe löschen",
openStack: "Gruppe in Tabs öffnen",
empty: "leer",
connectionsHeader: "Verbindungen",
noneTitle: "Noch keine gespeicherten Verbindungen.",
noneBody: "Erstelle eine oder importiere deine WinSCP-Sitzungen.",
name: "Name",
namePlaceholder: "mein Server",
host: "Host",
port: "Port",
stack: "Gruppe",
stackNone: "(keine)",
username: "Benutzername",
remotePath: "Entfernter Pfad (optional)",
remotePathPlaceholder: "Home-Verzeichnis",
auth: "Authentifizierung",
authPassword: "Passwort",
authKey: "Privater Schlüssel",
password: "Passwort",
privateKeyFile: "Datei mit privatem Schlüssel",
passphrase: "Passphrase (optional)",
localDir: "Lokales Verzeichnis (optional)",
localDirPlaceholder: "~/projekte/mein-server",
localDirHint: "Das andere Panel öffnet sich hier, wenn es auf Lokal steht und nicht navigiert wurde.",
more: "Mehr…",
less: "Weniger…",
selectPrompt: "Wähle eine Verbindung oder erstelle eine neue.",
newStackTitle: "Neue Gruppe",
newStackPrompt: "Gruppenname",
renameStackTitle: "Gruppe umbenennen",
deleteStackTitle: "Gruppe löschen",
deleteStackBody: 'Gruppe „{name}" löschen? Ihre Verbindungen bleiben, ohne Gruppe.',
deleteConnectionTitle: "Verbindung löschen",
deleteConnectionBody: '„{name}" löschen?',
},
editor: {
noFileTitle: "Keine Datei zum Bearbeiten",
noFileBody: "Dieses Fenster wurde ohne Editor-Kontext geöffnet. Öffne Dateien aus der App.",
wrap: "Umbruch",
wrapHint: "Lange Zeilen umbrechen — blendet Zeilennummern aus ({alt}Z)",
findHint: "Suchen ({mod}F)",
findPlaceholder: "In Datei suchen",
prevMatch: "Vorheriger Treffer (⇧↵)",
nextMatch: "Nächster Treffer (↵)",
closeHint: "Schließen (Esc)",
disconnected: "Verbindung zu {name} getrennt — Speichern schlägt fehl, aber dein Text bleibt in diesem Fenster erhalten.",
cantOpen: "{name} kann nicht geöffnet werden",
closeWindow: "Fenster schließen",
shortcuts: "{mod}S speichern · {mod}F suchen · {mod}W schließen",
lines: { one: "{count} Zeile", other: "{count} Zeilen" },
close: "Schließen",
save: "Speichern",
saveChangesTitle: 'Änderungen an „{name}" speichern?',
saveChangesBody: "Deine Änderungen gehen verloren, wenn du sie nicht speicherst.",
dontSave: "Nicht speichern",
countLabel: "{current} von {total}",
noMatches: "Keine Treffer",
},
transfer: {
summary: "{active} aktiv · {finished} abgeschlossen",
clearFinished: "Abgeschlossene löschen",
cancel: "Abbrechen",
dismiss: "Verwerfen",
showDetails: "Übertragungsdetails anzeigen",
copying: "Kopiere {name} · {percent}%{speed}",
queued: "{count} in Warteschlange",
failed: { one: "{count} Übertragung fehlgeschlagen", other: "{count} Übertragungen fehlgeschlagen" },
finished: "Übertragungen abgeschlossen",
ready: "Bereit",
items: "{count} Elemente",
selected: "{count} ausgewählt",
queuedState: "In Warteschlange",
scanning: "Analysiere…",
failedState: "Fehlgeschlagen",
cancelled: "Abgebrochen",
completedFiles: { one: "{count} Datei · {size}", other: "{count} Dateien · {size}" },
left: "{eta} übrig",
},
dialogs: {
chmodTitle: "Berechtigungen",
chmodItems: "{count} Elemente",
read: "Lesen",
write: "Schreiben",
execute: "Ausführen",
roleOwner: "Eigentümer",
roleGroup: "Gruppe",
roleOthers: "Andere",
octal: "Oktal",
mergeFolder: "Ordner zusammenführen",
replace: "Ersetzen",
merge: "Zusammenführen",
mergeAll: "Alle zusammenführen",
replaceAll: "Alle ersetzen",
skip: "Überspringen",
skipAll: "Alle überspringen",
ofTotal: "{index} von {total}",
overwriteDirBody: "Am Ziel existiert bereits ein Ordner mit diesem Namen. Zusammenführen kann Dateien darin ersetzen.",
overwriteFileBody: "Am Ziel existiert bereits eine Datei mit diesem Namen.",
source: "Quelle",
destination: "Ziel",
newer: "neuer",
},
toasts: {
movedToTrash: '„{name}" in den Papierkorb verschoben',
editingExternal: "Bearbeite {name} — Speichern lädt automatisch hoch",
nothingToCopy: "Nichts zu kopieren (symbolische Links werden übersprungen)",
nothingToCopyAllSkipped: "Nichts zu kopieren — alle Konflikte wurden übersprungen",
sameFolder: "Beide Panels zeigen denselben Ordner",
sameFile: '„{name}" übersprungen — es ist dieselbe Datei',
typeMismatch: '„{name}" übersprungen — ein anderer Elementtyp ist im Weg',
transferFailed: 'Übertragung „{name}" fehlgeschlagen: {error}',
unknownError: "unbekannter Fehler",
saved: "{name} gespeichert",
disconnectedReason: "{title}: getrennt ({reason})",
imported: { one: "{count} Verbindung importiert", other: "{count} Verbindungen importiert" },
importedIntoStacks: { one: " in {count} neue Gruppe", other: " in {count} neue Gruppen" },
importedSkipped: " ({skipped} übersprungen)",
leftoverTitle: "Übrig gebliebene bearbeitete Dateien",
leftoverMore: ", +{count} weitere",
leftoverBody: {
one: "Eine frühere Sitzung hinterließ {count} temporäre bearbeitete Datei ({names}). Löschen?",
other: "Eine frühere Sitzung hinterließ {count} temporäre bearbeitete Dateien ({names}). Löschen?",
},
leftoverClear: "Löschen",
},
fileActions: {
deleteTitle: "Löschen",
deleteBody: "{what} löschen? Ordner werden rekursiv gelöscht. Dies kann nicht rückgängig gemacht werden.",
deleteConfirm: "Löschen",
renameTitle: '„{name}" umbenennen',
renameConfirm: "Umbenennen",
newFolderTitle: "Neuer Ordner",
newFolderPlaceholder: "Ordnername",
newFileTitle: "Neue Datei",
newFilePlaceholder: "Dateiname",
createConfirm: "Erstellen",
itemsCount: "{count} Elemente",
},
menu: {
about: "Über {app}",
checkForUpdates: "Nach Updates suchen…",
quit: "Beenden",
file: "Datei",
newTab: "Neuer Tab",
closeTab: "Tab schließen",
newFolder: "Neuer Ordner",
newFile: "Neue Datei",
editFile: "Datei bearbeiten",
connect: "Verbinden",
connectionManager: "Verbindungsmanager…",
edit: "Bearbeiten",
view: "Ansicht",
refresh: "Aktualisieren",
window: "Fenster",
},
ctx: {
size: "Größe",
modified: "Geändert",
kind: "Typ",
rights: "Rechte",
owner: "Eigentümer",
group: "Gruppe",
revealFinder: "Im Finder anzeigen",
revealExplorer: "Im Explorer anzeigen",
revealFileManager: "Im Dateimanager anzeigen",
moveToTrash: "In den Papierkorb",
moveToRecycleBin: "In den Papierkorb",
newFolder: "Neuer Ordner",
newFile: "Neue Datei",
refresh: "Aktualisieren",
open: "Öffnen",
openDefault: "Mit Standard-App öffnen",
edit: "Bearbeiten",
copyToOther: "An anderes Panel senden",
rename: "Umbenennen",
permissions: "Berechtigungen",
copyPath: "Pfad kopieren",
delete: "Löschen",
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",
},
errors: {
sshAgentUnavailable: "ssh-agent nicht verfügbar (SSH_AUTH_SOCK ist nicht gesetzt)",
identityFileNotFound: "Identitätsdatei nicht gefunden: {path}",
sessionNotConnected: 'Sitzung „{id}" ist nicht verbunden',
localCannotDisconnect: "Die lokale Sitzung kann nicht getrennt werden",
notADirectory: '„{path}" existiert und ist kein Ordner',
sameFileTransfer: '„{name}": Quelle und Ziel sind dieselbe Datei',
sameFileNoName: "Quelle und Ziel sind dieselbe Datei",
folderOntoItself: "Ein Ordner kann nicht auf sich selbst kopiert werden",
folderIntoItself: "Ein Ordner kann nicht in sich selbst kopiert werden",
},
} as const;
+319
View File
@@ -0,0 +1,319 @@
// English is the source of truth. Other locales mirror this shape; missing keys
// fall back to English at lookup time. Placeholders use {name} interpolation.
// Keep Transloco-compatible (nested namespaces, {param}) so a later migration
// to a real i18n library is a drop-in of these dictionaries.
export const en = {
common: {
cancel: "Cancel",
save: "Save",
close: "Close",
delete: "Delete",
rename: "Rename",
create: "Create",
apply: "Apply",
choose: "Choose…",
connect: "Connect",
local: "Local",
},
app: {
notConnectedTitle: "Not connected to the app",
notConnectedBody: "This page is running in a plain browser. Launch the desktop app to use it.",
managerOpen: "The connection manager is open.",
showConnections: "Show Connections",
},
tabBar: {
connections: "Connections",
manageConnections: "Manage connections",
newTab: "New tab ({mod}T)",
closeTab: "Close tab ({mod}W)",
options: "Options",
update: "Update available",
updating: "Updating",
restartToUpdate: "Restart to apply",
updateTooltip: "Update available: miniSCP {version}",
},
options: {
title: "Options",
language: "Language",
system: "System",
skin: {
label: "Skin",
modern: "Modern",
win7: "Windows 7",
restartHint: "Applies next time you start miniSCP",
},
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
tone: { label: "Tone", dark: "Dark", darker: "Darker" },
accent: { label: "Accent" },
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", smaller: "Smaller", larger: "Larger", reset: "Reset to 100%" },
speedUnit: { label: "Speed unit" },
transfers: { label: "Transfers", single: "Single", multi: "Multi" },
atOnce: "At once",
editor: { label: "Editor", builtin: "Built-in", custom: "Custom" },
systemDefault: "System default",
resetDefault: "Reset to system default",
finishedTransfers: { label: "Finished transfers", autoClear: "Auto-clear", keep: "Keep" },
},
kinds: {
folder: "Folder",
symlink: "Symlink",
file: "File",
image: "Image",
audio: "Audio",
video: "Video",
archive: "Archive",
code: "Code",
document: "Document",
},
columns: {
name: "Name",
size: "Size",
mtime: "Modified",
kind: "Kind",
rights: "Rights",
owner: "Owner",
group: "Group",
},
panel: {
choosePanel: "Choose what this panel shows",
copy: "Send",
copyToRight: "Send to right panel (F5)",
copyToLeft: "Send to left panel (F5)",
edit: "Edit",
editFile: "Edit file (F4)",
rename: "Rename (F2)",
newFolder: "New folder (F7)",
newFile: "New file",
permissions: "Permissions",
delete: "Delete",
deleteHint: "Delete (F8 / Del)",
parentDir: "Parent directory (Backspace)",
refresh: "Refresh",
clearFilter: "Clear filter (Esc)",
sshConfig: "~/.ssh/config",
connectingTo: "Connecting to {name}…",
emptyDir: "Empty directory",
noMatches: "No matches",
matches: { one: "{count} match · Esc", other: "{count} matches · Esc" },
},
connMgr: {
title: "Connections",
importWinscp: "Import from WinSCP…",
importWinscpHint: "Import sessions from a WinSCP.ini export",
newConnection: "Connection",
newConnectionHint: "New connection",
newStack: "Stack",
newStackHint: "New stack",
expand: "Expand",
collapse: "Collapse",
renameStack: "Rename stack",
deleteStack: "Delete stack",
openStack: "Open stack in tabs",
empty: "empty",
connectionsHeader: "Connections",
noneTitle: "No saved connections yet.",
noneBody: "Create one, or import your WinSCP sessions.",
name: "Name",
namePlaceholder: "my server",
host: "Host",
port: "Port",
stack: "Stack",
stackNone: "(none)",
username: "Username",
remotePath: "Remote path (optional)",
remotePathPlaceholder: "home directory",
auth: "Auth",
authPassword: "Password",
authKey: "Private key",
password: "Password",
privateKeyFile: "Private key file",
passphrase: "Passphrase (optional)",
localDir: "Local directory (optional)",
localDirPlaceholder: "~/projects/my-server",
localDirHint: "The other panel opens here when it's on Local and hasn't been navigated.",
more: "More…",
less: "Less…",
selectPrompt: "Select a connection or create a new one.",
newStackTitle: "New stack",
newStackPrompt: "stack name",
renameStackTitle: "Rename stack",
deleteStackTitle: "Delete stack",
deleteStackBody: 'Delete stack "{name}"? Its connections stay, ungrouped.',
deleteConnectionTitle: "Delete connection",
deleteConnectionBody: 'Delete "{name}"?',
},
editor: {
noFileTitle: "No file to edit",
noFileBody: "This window was opened without an editor context. Launch files from the app.",
wrap: "Wrap",
wrapHint: "Wrap long lines — hides line numbers ({alt}Z)",
findHint: "Find ({mod}F)",
findPlaceholder: "Find in file",
prevMatch: "Previous match (⇧↵)",
nextMatch: "Next match (↵)",
closeHint: "Close (Esc)",
disconnected: "Disconnected from {name} — saving will fail, but your text is preserved in this window.",
cantOpen: "Can't open {name}",
closeWindow: "Close Window",
shortcuts: "{mod}S save · {mod}F find · {mod}W close",
lines: { one: "{count} line", other: "{count} lines" },
close: "Close",
save: "Save",
saveChangesTitle: 'Save changes to "{name}"?',
saveChangesBody: "Your changes will be lost if you don't save them.",
dontSave: "Don't Save",
countLabel: "{current} of {total}",
noMatches: "No matches",
},
transfer: {
summary: "{active} active · {finished} finished",
clearFinished: "Clear finished",
cancel: "Cancel",
dismiss: "Dismiss",
showDetails: "Show transfer details",
copying: "Copying {name} · {percent}%{speed}",
queued: "{count} queued",
failed: { one: "{count} transfer failed", other: "{count} transfers failed" },
finished: "Transfers finished",
ready: "Ready",
items: "{count} items",
selected: "{count} selected",
queuedState: "Queued",
scanning: "Scanning…",
failedState: "Failed",
cancelled: "Cancelled",
completedFiles: { one: "{count} file · {size}", other: "{count} files · {size}" },
left: "{eta} left",
},
dialogs: {
chmodTitle: "Permissions",
chmodItems: "{count} items",
read: "Read",
write: "Write",
execute: "Execute",
roleOwner: "Owner",
roleGroup: "Group",
roleOthers: "Others",
octal: "Octal",
mergeFolder: "Merge folder",
replace: "Replace",
merge: "Merge",
mergeAll: "Merge all",
replaceAll: "Replace all",
skip: "Skip",
skipAll: "Skip all",
ofTotal: "{index} of {total}",
overwriteDirBody: "The destination already has a folder with this name. Merging may replace files inside it.",
overwriteFileBody: "The destination already has a file with this name.",
source: "Source",
destination: "Destination",
newer: "newer",
},
toasts: {
movedToTrash: 'Moved "{name}" to Trash',
editingExternal: "Editing {name} — saves upload automatically",
nothingToCopy: "Nothing to copy (symlinks are skipped)",
nothingToCopyAllSkipped: "Nothing to copy — all conflicting items were skipped",
sameFolder: "Both panels show the same folder",
sameFile: 'Skipped "{name}" — it is the same file',
typeMismatch: 'Skipped "{name}" — a different kind of item is in the way',
transferFailed: 'Transfer "{name}" failed: {error}',
unknownError: "unknown error",
saved: "Saved {name}",
disconnectedReason: "{title}: disconnected ({reason})",
imported: { one: "Imported {count} connection", other: "Imported {count} connections" },
importedIntoStacks: { one: " into {count} new stack", other: " into {count} new stacks" },
importedSkipped: " ({skipped} skipped)",
leftoverTitle: "Leftover edited files",
leftoverBody: {
one: "A previous session left {count} temporary edited file behind ({names}). Clear them?",
other: "A previous session left {count} temporary edited files behind ({names}). Clear them?",
},
leftoverMore: ", +{count} more",
leftoverClear: "Clear",
},
fileActions: {
deleteTitle: "Delete",
deleteBody: "Delete {what}? Folders are deleted recursively. This cannot be undone.",
deleteConfirm: "Delete",
renameTitle: 'Rename "{name}"',
renameConfirm: "Rename",
newFolderTitle: "New folder",
newFolderPlaceholder: "folder name",
newFileTitle: "New file",
newFilePlaceholder: "file name",
createConfirm: "Create",
itemsCount: "{count} items",
},
menu: {
about: "About {app}",
checkForUpdates: "Check for Updates…",
quit: "Quit",
file: "File",
newTab: "New Tab",
closeTab: "Close Tab",
newFolder: "New Folder",
newFile: "New File",
editFile: "Edit File",
connect: "Connect",
connectionManager: "Connection Manager…",
edit: "Edit",
view: "View",
refresh: "Refresh",
window: "Window",
},
ctx: {
size: "Size",
modified: "Modified",
kind: "Kind",
rights: "Rights",
owner: "Owner",
group: "Group",
revealFinder: "Reveal in Finder",
revealExplorer: "Show in Explorer",
revealFileManager: "Show in File Manager",
moveToTrash: "Move to Trash",
moveToRecycleBin: "Move to Recycle Bin",
newFolder: "New Folder",
newFile: "New File",
refresh: "Refresh",
open: "Open",
openDefault: "Open with the default application",
edit: "Edit",
copyToOther: "Send to Other Panel",
rename: "Rename",
permissions: "Permissions",
copyPath: "Copy Path",
delete: "Delete",
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",
},
errors: {
sshAgentUnavailable: "ssh-agent not available (SSH_AUTH_SOCK is not set)",
identityFileNotFound: "identity file not found: {path}",
sessionNotConnected: 'Session "{id}" is not connected',
localCannotDisconnect: "The local session cannot be disconnected",
notADirectory: '"{path}" exists and is not a directory',
sameFileTransfer: '"{name}": source and destination are the same file',
sameFileNoName: "Source and destination are the same file",
folderOntoItself: "Cannot copy a folder onto itself",
folderIntoItself: "Cannot copy a folder into itself",
},
} as const;
+315
View File
@@ -0,0 +1,315 @@
// Spanish translations. Missing keys fall back to English (see en.ts / index.ts).
export const es = {
common: {
cancel: "Cancelar",
save: "Guardar",
close: "Cerrar",
delete: "Eliminar",
rename: "Renombrar",
create: "Crear",
apply: "Aplicar",
choose: "Elegir…",
connect: "Conectar",
local: "Local",
},
app: {
notConnectedTitle: "No conectado a la aplicación",
notConnectedBody: "Esta página se ejecuta en un navegador normal. Abre la aplicación de escritorio para usarla.",
managerOpen: "El gestor de conexiones está abierto.",
showConnections: "Mostrar conexiones",
},
tabBar: {
connections: "Conexiones",
manageConnections: "Gestionar conexiones",
newTab: "Nueva pestaña ({mod}T)",
closeTab: "Cerrar pestaña ({mod}W)",
options: "Opciones",
update: "Actualización disponible",
updating: "Actualizando",
restartToUpdate: "Reiniciar para aplicar",
updateTooltip: "Actualización disponible: miniSCP {version}",
},
options: {
title: "Opciones",
language: "Idioma",
system: "Sistema",
skin: {
label: "Aspecto",
modern: "Moderno",
win7: "Windows 7",
restartHint: "Se aplica la próxima vez que abras miniSCP",
},
theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" },
accent: { label: "Acento" },
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", smaller: "Más pequeña", larger: "Más grande", reset: "Restablecer al 100%" },
speedUnit: { label: "Unidad de velocidad" },
transfers: { label: "Transferencias", single: "Una", multi: "Varias" },
atOnce: "A la vez",
editor: { label: "Editor", builtin: "Integrado", custom: "Personalizado" },
systemDefault: "Predeterminado del sistema",
resetDefault: "Restablecer al predeterminado",
finishedTransfers: { label: "Transferencias completadas", autoClear: "Auto-limpiar", keep: "Mantener" },
},
kinds: {
folder: "Carpeta",
symlink: "Enlace simbólico",
file: "Archivo",
image: "Imagen",
audio: "Audio",
video: "Vídeo",
archive: "Archivo comprimido",
code: "Código",
document: "Documento",
},
columns: {
name: "Nombre",
size: "Tamaño",
mtime: "Modificado",
kind: "Tipo",
rights: "Permisos",
owner: "Propietario",
group: "Grupo",
},
panel: {
choosePanel: "Elige qué muestra este panel",
copy: "Enviar",
copyToRight: "Enviar al panel derecho (F5)",
copyToLeft: "Enviar al panel izquierdo (F5)",
edit: "Editar",
editFile: "Editar archivo (F4)",
rename: "Renombrar (F2)",
newFolder: "Nueva carpeta (F7)",
newFile: "Nuevo archivo",
permissions: "Permisos",
delete: "Eliminar",
deleteHint: "Eliminar (F8 / Supr)",
parentDir: "Carpeta superior (Retroceso)",
refresh: "Actualizar",
clearFilter: "Borrar filtro (Esc)",
sshConfig: "~/.ssh/config",
connectingTo: "Conectando con {name}…",
emptyDir: "Carpeta vacía",
noMatches: "Sin coincidencias",
matches: { one: "{count} coincidencia · Esc", other: "{count} coincidencias · Esc" },
},
connMgr: {
title: "Conexiones",
importWinscp: "Importar de WinSCP…",
importWinscpHint: "Importar sesiones de una exportación WinSCP.ini",
newConnection: "Conexión",
newConnectionHint: "Nueva conexión",
newStack: "Grupo",
newStackHint: "Nuevo grupo",
expand: "Expandir",
collapse: "Contraer",
renameStack: "Renombrar grupo",
deleteStack: "Eliminar grupo",
openStack: "Abrir grupo en pestañas",
empty: "vacío",
connectionsHeader: "Conexiones",
noneTitle: "Aún no hay conexiones guardadas.",
noneBody: "Crea una o importa tus sesiones de WinSCP.",
name: "Nombre",
namePlaceholder: "mi servidor",
host: "Servidor",
port: "Puerto",
stack: "Grupo",
stackNone: "(ninguno)",
username: "Usuario",
remotePath: "Ruta remota (opcional)",
remotePathPlaceholder: "carpeta de inicio",
auth: "Autenticación",
authPassword: "Contraseña",
authKey: "Clave privada",
password: "Contraseña",
privateKeyFile: "Archivo de clave privada",
passphrase: "Frase de contraseña (opcional)",
localDir: "Carpeta local (opcional)",
localDirPlaceholder: "~/proyectos/mi-servidor",
localDirHint: "El otro panel se abre aquí cuando está en Local y no se ha navegado.",
more: "Más…",
less: "Menos…",
selectPrompt: "Selecciona una conexión o crea una nueva.",
newStackTitle: "Nuevo grupo",
newStackPrompt: "nombre del grupo",
renameStackTitle: "Renombrar grupo",
deleteStackTitle: "Eliminar grupo",
deleteStackBody: '¿Eliminar el grupo "{name}"? Sus conexiones se mantienen, sin agrupar.',
deleteConnectionTitle: "Eliminar conexión",
deleteConnectionBody: '¿Eliminar "{name}"?',
},
editor: {
noFileTitle: "Ningún archivo que editar",
noFileBody: "Esta ventana se abrió sin contexto de editor. Abre archivos desde la aplicación.",
wrap: "Ajustar",
wrapHint: "Ajustar líneas largas — oculta los números de línea ({alt}Z)",
findHint: "Buscar ({mod}F)",
findPlaceholder: "Buscar en el archivo",
prevMatch: "Coincidencia anterior (⇧↵)",
nextMatch: "Coincidencia siguiente (↵)",
closeHint: "Cerrar (Esc)",
disconnected: "Desconectado de {name} — no se podrá guardar, pero tu texto se conserva en esta ventana.",
cantOpen: "No se puede abrir {name}",
closeWindow: "Cerrar ventana",
shortcuts: "{mod}S guardar · {mod}F buscar · {mod}W cerrar",
lines: { one: "{count} línea", other: "{count} líneas" },
close: "Cerrar",
save: "Guardar",
saveChangesTitle: '¿Guardar los cambios en "{name}"?',
saveChangesBody: "Perderás tus cambios si no los guardas.",
dontSave: "No guardar",
countLabel: "{current} de {total}",
noMatches: "Sin coincidencias",
},
transfer: {
summary: "{active} activas · {finished} completadas",
clearFinished: "Limpiar completadas",
cancel: "Cancelar",
dismiss: "Descartar",
showDetails: "Ver detalles de transferencias",
copying: "Copiando {name} · {percent}%{speed}",
queued: "{count} en cola",
failed: { one: "{count} transferencia fallida", other: "{count} transferencias fallidas" },
finished: "Transferencias completadas",
ready: "Listo",
items: "{count} elementos",
selected: "{count} seleccionados",
queuedState: "En cola",
scanning: "Analizando…",
failedState: "Fallida",
cancelled: "Cancelada",
completedFiles: { one: "{count} archivo · {size}", other: "{count} archivos · {size}" },
left: "{eta} restante",
},
dialogs: {
chmodTitle: "Permisos",
chmodItems: "{count} elementos",
read: "Lectura",
write: "Escritura",
execute: "Ejecución",
roleOwner: "Propietario",
roleGroup: "Grupo",
roleOthers: "Otros",
octal: "Octal",
mergeFolder: "Combinar carpeta",
replace: "Reemplazar",
merge: "Combinar",
mergeAll: "Combinar todo",
replaceAll: "Reemplazar todo",
skip: "Omitir",
skipAll: "Omitir todo",
ofTotal: "{index} de {total}",
overwriteDirBody: "El destino ya tiene una carpeta con este nombre. Combinar puede reemplazar archivos dentro.",
overwriteFileBody: "El destino ya tiene un archivo con este nombre.",
source: "Origen",
destination: "Destino",
newer: "más reciente",
},
toasts: {
movedToTrash: 'Se movió "{name}" a la papelera',
editingExternal: "Editando {name} — los cambios se suben automáticamente",
nothingToCopy: "Nada que copiar (los enlaces simbólicos se omiten)",
nothingToCopyAllSkipped: "Nada que copiar — se omitieron todos los elementos en conflicto",
sameFolder: "Ambos paneles muestran la misma carpeta",
sameFile: 'Se omitió "{name}" — es el mismo archivo',
typeMismatch: 'Se omitió "{name}" — hay otro tipo de elemento en el camino',
transferFailed: 'La transferencia "{name}" falló: {error}',
unknownError: "error desconocido",
saved: "Se guardó {name}",
disconnectedReason: "{title}: desconectado ({reason})",
imported: { one: "Se importó {count} conexión", other: "Se importaron {count} conexiones" },
importedIntoStacks: { one: " en {count} grupo nuevo", other: " en {count} grupos nuevos" },
importedSkipped: " ({skipped} omitidas)",
leftoverTitle: "Archivos editados sobrantes",
leftoverMore: ", +{count} más",
leftoverBody: {
one: "Una sesión anterior dejó {count} archivo editado temporal ({names}). ¿Eliminarlo?",
other: "Una sesión anterior dejó {count} archivos editados temporales ({names}). ¿Eliminarlos?",
},
leftoverClear: "Eliminar",
},
fileActions: {
deleteTitle: "Eliminar",
deleteBody: "¿Eliminar {what}? Las carpetas se eliminan de forma recursiva. Esto no se puede deshacer.",
deleteConfirm: "Eliminar",
renameTitle: 'Renombrar "{name}"',
renameConfirm: "Renombrar",
newFolderTitle: "Nueva carpeta",
newFolderPlaceholder: "nombre de la carpeta",
newFileTitle: "Nuevo archivo",
newFilePlaceholder: "nombre del archivo",
createConfirm: "Crear",
itemsCount: "{count} elementos",
},
menu: {
about: "Acerca de {app}",
checkForUpdates: "Buscar actualizaciones…",
quit: "Salir",
file: "Archivo",
newTab: "Nueva pestaña",
closeTab: "Cerrar pestaña",
newFolder: "Nueva carpeta",
newFile: "Nuevo archivo",
editFile: "Editar archivo",
connect: "Conectar",
connectionManager: "Gestor de conexiones…",
edit: "Edición",
view: "Ver",
refresh: "Actualizar",
window: "Ventana",
},
ctx: {
size: "Tamaño",
modified: "Modificado",
kind: "Tipo",
rights: "Permisos",
owner: "Propietario",
group: "Grupo",
revealFinder: "Mostrar en Finder",
revealExplorer: "Mostrar en el Explorador",
revealFileManager: "Mostrar en el gestor de archivos",
moveToTrash: "Mover a la papelera",
moveToRecycleBin: "Mover a la papelera de reciclaje",
newFolder: "Nueva carpeta",
newFile: "Nuevo archivo",
refresh: "Actualizar",
open: "Abrir",
openDefault: "Abrir con la aplicación predeterminada",
edit: "Editar",
copyToOther: "Enviar al otro panel",
rename: "Renombrar",
permissions: "Permisos",
copyPath: "Copiar ruta",
delete: "Eliminar",
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í",
},
errors: {
sshAgentUnavailable: "ssh-agent no disponible (SSH_AUTH_SOCK no está definido)",
identityFileNotFound: "archivo de identidad no encontrado: {path}",
sessionNotConnected: 'La sesión "{id}" no está conectada',
localCannotDisconnect: "La sesión local no se puede desconectar",
notADirectory: '"{path}" existe y no es una carpeta',
sameFileTransfer: '"{name}": el origen y el destino son el mismo archivo',
sameFileNoName: "El origen y el destino son el mismo archivo",
folderOntoItself: "No se puede copiar una carpeta sobre sí misma",
folderIntoItself: "No se puede copiar una carpeta dentro de sí misma",
},
} as const;
+317
View File
@@ -0,0 +1,317 @@
// Basque (Euskara) translations. Fall back to English for missing keys.
// TODO(i18n): AI draft — REQUIRES native-speaker review (Basque is error-prone
// for machine/AI translation; treat every string as provisional).
export const eu = {
common: {
cancel: "Utzi",
save: "Gorde",
close: "Itxi",
delete: "Ezabatu",
rename: "Berrizendatu",
create: "Sortu",
apply: "Aplikatu",
choose: "Aukeratu…",
connect: "Konektatu",
local: "Lokala",
},
app: {
notConnectedTitle: "Ez dago aplikaziora konektatuta",
notConnectedBody: "Orri hau nabigatzaile arrunt batean exekutatzen ari da. Ireki mahaigaineko aplikazioa erabiltzeko.",
managerOpen: "Konexio-kudeatzailea irekita dago.",
showConnections: "Erakutsi konexioak",
},
tabBar: {
connections: "Konexioak",
manageConnections: "Kudeatu konexioak",
newTab: "Fitxa berria ({mod}T)",
closeTab: "Itxi fitxa ({mod}W)",
options: "Aukerak",
update: "Eguneratzea eskuragarri",
updating: "Eguneratzen",
restartToUpdate: "Berrabiarazi aplikatzeko",
updateTooltip: "Eguneratzea eskuragarri: miniSCP {version}",
},
options: {
title: "Aukerak",
language: "Hizkuntza",
system: "Sistema",
skin: {
label: "Itxura",
modern: "Modernoa",
win7: "Windows 7",
restartHint: "Hurrengoan miniSCP abiaraztean aplikatuko da",
},
theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" },
tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" },
accent: { label: "Azentua" },
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", smaller: "Txikiagoa", larger: "Handiagoa", reset: "Berrezarri %100era" },
speedUnit: { label: "Abiadura-unitatea" },
transfers: { label: "Transferentziak", single: "Banaka", multi: "Anitz" },
atOnce: "Aldi berean",
editor: { label: "Editorea", builtin: "Barnekoa", custom: "Pertsonalizatua" },
systemDefault: "Sistemaren lehenetsia",
resetDefault: "Berrezarri lehenetsira",
finishedTransfers: { label: "Amaitutako transferentziak", autoClear: "Garbitu", keep: "Mantendu" },
},
kinds: {
folder: "Karpeta",
symlink: "Esteka sinbolikoa",
file: "Fitxategia",
image: "Irudia",
audio: "Audioa",
video: "Bideoa",
archive: "Artxiboa",
code: "Kodea",
document: "Dokumentua",
},
columns: {
name: "Izena",
size: "Tamaina",
mtime: "Aldatze-data",
kind: "Mota",
rights: "Baimenak",
owner: "Jabea",
group: "Taldea",
},
panel: {
choosePanel: "Aukeratu panel honek zer erakusten duen",
copy: "Bidali",
copyToRight: "Bidali eskuineko panelera (F5)",
copyToLeft: "Bidali ezkerreko panelera (F5)",
edit: "Editatu",
editFile: "Editatu fitxategia (F4)",
rename: "Berrizendatu (F2)",
newFolder: "Karpeta berria (F7)",
newFile: "Fitxategi berria",
permissions: "Baimenak",
delete: "Ezabatu",
deleteHint: "Ezabatu (F8 / Del)",
parentDir: "Karpeta gurasoa (Atzera-tekla)",
refresh: "Freskatu",
clearFilter: "Garbitu iragazkia (Esc)",
sshConfig: "~/.ssh/config",
connectingTo: "{name}(e)ra konektatzen…",
emptyDir: "Karpeta hutsa",
noMatches: "Bat-etortzerik ez",
matches: { one: "bat-etortze {count} · Esc", other: "{count} bat-etortze · Esc" },
},
connMgr: {
title: "Konexioak",
importWinscp: "Inportatu WinSCPtik…",
importWinscpHint: "Inportatu saioak WinSCP.ini esportazio batetik",
newConnection: "Konexioa",
newConnectionHint: "Konexio berria",
newStack: "Taldea",
newStackHint: "Talde berria",
expand: "Zabaldu",
collapse: "Tolestu",
renameStack: "Berrizendatu taldea",
deleteStack: "Ezabatu taldea",
openStack: "Ireki taldea fitxetan",
empty: "hutsik",
connectionsHeader: "Konexioak",
noneTitle: "Oraindik ez dago gordetako konexiorik.",
noneBody: "Sortu bat edo inportatu zure WinSCP saioak.",
name: "Izena",
namePlaceholder: "nire zerbitzaria",
host: "Ostalaria",
port: "Ataka",
stack: "Taldea",
stackNone: "(bat ere ez)",
username: "Erabiltzailea",
remotePath: "Urruneko bidea (aukerakoa)",
remotePathPlaceholder: "hasierako karpeta",
auth: "Autentifikazioa",
authPassword: "Pasahitza",
authKey: "Gako pribatua",
password: "Pasahitza",
privateKeyFile: "Gako pribatuaren fitxategia",
passphrase: "Pasaesaldia (aukerakoa)",
localDir: "Karpeta lokala (aukerakoa)",
localDirPlaceholder: "~/proiektuak/nire-zerbitzaria",
localDirHint: "Beste panela hemen irekiko da Lokalean dagoenean eta nabigatu ez denean.",
more: "Gehiago…",
less: "Gutxiago…",
selectPrompt: "Hautatu konexio bat edo sortu berri bat.",
newStackTitle: "Talde berria",
newStackPrompt: "taldearen izena",
renameStackTitle: "Berrizendatu taldea",
deleteStackTitle: "Ezabatu taldea",
deleteStackBody: 'Ezabatu "{name}" taldea? Bere konexioak mantendu egingo dira, taldekatu gabe.',
deleteConnectionTitle: "Ezabatu konexioa",
deleteConnectionBody: 'Ezabatu "{name}"?',
},
editor: {
noFileTitle: "Ez dago editatzeko fitxategirik",
noFileBody: "Leiho hau editore-testuingururik gabe ireki da. Ireki fitxategiak aplikaziotik.",
wrap: "Doitu",
wrapHint: "Doitu lerro luzeak — lerro-zenbakiak ezkutatzen ditu ({alt}Z)",
findHint: "Bilatu ({mod}F)",
findPlaceholder: "Bilatu fitxategian",
prevMatch: "Aurreko bat-etortzea (⇧↵)",
nextMatch: "Hurrengo bat-etortzea (↵)",
closeHint: "Itxi (Esc)",
disconnected: "{name}(e)tik deskonektatuta — gordetzeak huts egingo du, baina zure testua leiho honetan gordeta dago.",
cantOpen: "Ezin da {name} ireki",
closeWindow: "Itxi leihoa",
shortcuts: "{mod}S gorde · {mod}F bilatu · {mod}W itxi",
lines: { one: "lerro {count}", other: "{count} lerro" },
close: "Itxi",
save: "Gorde",
saveChangesTitle: 'Gorde "{name}"(r)en aldaketak?',
saveChangesBody: "Zure aldaketak galduko dira gordetzen ez badituzu.",
dontSave: "Ez gorde",
countLabel: "{total}(e)tik {current}",
noMatches: "Bat-etortzerik ez",
},
transfer: {
summary: "{active} aktibo · {finished} amaituta",
clearFinished: "Garbitu amaituak",
cancel: "Utzi",
dismiss: "Baztertu",
showDetails: "Erakutsi transferentzien xehetasunak",
copying: "{name} kopiatzen · %{percent}{speed}",
queued: "{count} ilaran",
failed: { one: "transferentzia {count}(e)k huts egin du", other: "{count} transferentziak huts egin dute" },
finished: "Transferentziak amaituta",
ready: "Prest",
items: "{count} elementu",
selected: "{count} hautatuta",
queuedState: "Ilaran",
scanning: "Aztertzen…",
failedState: "Huts egin du",
cancelled: "Bertan behera utzita",
completedFiles: { one: "fitxategi {count} · {size}", other: "{count} fitxategi · {size}" },
left: "{eta} geratzen da",
},
dialogs: {
chmodTitle: "Baimenak",
chmodItems: "{count} elementu",
read: "Irakurri",
write: "Idatzi",
execute: "Exekutatu",
roleOwner: "Jabea",
roleGroup: "Taldea",
roleOthers: "Besteak",
octal: "Zortzitarra",
mergeFolder: "Batu karpeta",
replace: "Ordeztu",
merge: "Batu",
mergeAll: "Batu guztiak",
replaceAll: "Ordeztu guztiak",
skip: "Saltatu",
skipAll: "Saltatu guztiak",
ofTotal: "{total}(e)tik {index}",
overwriteDirBody: "Helmugan izen bereko karpeta bat dago jada. Batzeak barneko fitxategiak ordeztu ditzake.",
overwriteFileBody: "Helmugan izen bereko fitxategi bat dago jada.",
source: "Iturburua",
destination: "Helmuga",
newer: "berriagoa",
},
toasts: {
movedToTrash: '"{name}" zakarrontzira eraman da',
editingExternal: "{name} editatzen — gordetzean automatikoki igotzen da",
nothingToCopy: "Ez dago kopiatzeko ezer (esteka sinbolikoak saltatu egiten dira)",
nothingToCopyAllSkipped: "Ez dago kopiatzeko ezer — gatazkan zeuden elementu guztiak saltatu dira",
sameFolder: "Bi panelek karpeta bera erakusten dute",
sameFile: '"{name}" saltatu da — fitxategi bera da',
typeMismatch: '"{name}" saltatu da — beste mota bateko elementu bat dago bidean',
transferFailed: '"{name}" transferentziak huts egin du: {error}',
unknownError: "errore ezezaguna",
saved: "{name} gorde da",
disconnectedReason: "{title}: deskonektatuta ({reason})",
imported: { one: "konexio {count} inportatu da", other: "{count} konexio inportatu dira" },
importedIntoStacks: { one: " talde berri {count}ean", other: " {count} talde berritan" },
importedSkipped: " ({skipped} saltatuta)",
leftoverTitle: "Geratutako editatutako fitxategiak",
leftoverMore: ", +{count} gehiago",
leftoverBody: {
one: "Aurreko saio batek editatutako aldi baterako fitxategi {count} utzi zuen ({names}). Garbitu?",
other: "Aurreko saio batek editatutako aldi baterako {count} fitxategi utzi zituen ({names}). Garbitu?",
},
leftoverClear: "Garbitu",
},
fileActions: {
deleteTitle: "Ezabatu",
deleteBody: "Ezabatu {what}? Karpetak errekurtsiboki ezabatzen dira. Ezin da desegin.",
deleteConfirm: "Ezabatu",
renameTitle: 'Berrizendatu "{name}"',
renameConfirm: "Berrizendatu",
newFolderTitle: "Karpeta berria",
newFolderPlaceholder: "karpetaren izena",
newFileTitle: "Fitxategi berria",
newFilePlaceholder: "fitxategiaren izena",
createConfirm: "Sortu",
itemsCount: "{count} elementu",
},
menu: {
about: "{app} (r)i buruz",
checkForUpdates: "Bilatu eguneratzeak…",
quit: "Irten",
file: "Fitxategia",
newTab: "Fitxa berria",
closeTab: "Itxi fitxa",
newFolder: "Karpeta berria",
newFile: "Fitxategi berria",
editFile: "Editatu fitxategia",
connect: "Konektatu",
connectionManager: "Konexio-kudeatzailea…",
edit: "Editatu",
view: "Ikusi",
refresh: "Freskatu",
window: "Leihoa",
},
ctx: {
size: "Tamaina",
modified: "Aldatze-data",
kind: "Mota",
rights: "Baimenak",
owner: "Jabea",
group: "Taldea",
revealFinder: "Erakutsi Finder-en",
revealExplorer: "Erakutsi Explorer-en",
revealFileManager: "Erakutsi fitxategi-kudeatzailean",
moveToTrash: "Eraman zakarrontzira",
moveToRecycleBin: "Eraman birziklatze-ontzira",
newFolder: "Karpeta berria",
newFile: "Fitxategi berria",
refresh: "Freskatu",
open: "Ireki",
openDefault: "Ireki lehenetsitako aplikazioarekin",
edit: "Editatu",
copyToOther: "Bidali beste panelera",
rename: "Berrizendatu",
permissions: "Baimenak",
copyPath: "Kopiatu bidea",
delete: "Ezabatu",
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",
},
errors: {
sshAgentUnavailable: "ssh-agent ez dago erabilgarri (SSH_AUTH_SOCK ez dago ezarrita)",
identityFileNotFound: "identitate-fitxategia ez da aurkitu: {path}",
sessionNotConnected: '"{id}" saioa ez dago konektatuta',
localCannotDisconnect: "Saio lokala ezin da deskonektatu",
notADirectory: '"{path}" badago eta ez da karpeta bat',
sameFileTransfer: '"{name}": iturburua eta helmuga fitxategi bera dira',
sameFileNoName: "Iturburua eta helmuga fitxategi bera dira",
folderOntoItself: "Ezin da karpeta bat bere gainean kopiatu",
folderIntoItself: "Ezin da karpeta bat bere barnean kopiatu",
},
} as const;
+315
View File
@@ -0,0 +1,315 @@
// French translations. Missing keys fall back to English (see en.ts / index.ts).
export const fr = {
common: {
cancel: "Annuler",
save: "Enregistrer",
close: "Fermer",
delete: "Supprimer",
rename: "Renommer",
create: "Créer",
apply: "Appliquer",
choose: "Choisir…",
connect: "Connecter",
local: "Local",
},
app: {
notConnectedTitle: "Non connecté à l'application",
notConnectedBody: "Cette page s'exécute dans un navigateur classique. Lancez l'application de bureau pour l'utiliser.",
managerOpen: "Le gestionnaire de connexions est ouvert.",
showConnections: "Afficher les connexions",
},
tabBar: {
connections: "Connexions",
manageConnections: "Gérer les connexions",
newTab: "Nouvel onglet ({mod}T)",
closeTab: "Fermer l'onglet ({mod}W)",
options: "Options",
update: "Mise à jour disponible",
updating: "Mise à jour",
restartToUpdate: "Redémarrer pour appliquer",
updateTooltip: "Mise à jour disponible : miniSCP {version}",
},
options: {
title: "Options",
language: "Langue",
system: "Système",
skin: {
label: "Apparence",
modern: "Moderne",
win7: "Windows 7",
restartHint: "Prend effet au prochain démarrage de miniSCP",
},
theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" },
accent: { label: "Accent" },
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", smaller: "Plus petite", larger: "Plus grande", reset: "Réinitialiser à 100%" },
speedUnit: { label: "Unité de vitesse" },
transfers: { label: "Transferts", single: "Un", multi: "Plusieurs" },
atOnce: "Simultanés",
editor: { label: "Éditeur", builtin: "Intégré", custom: "Personnalisé" },
systemDefault: "Valeur par défaut du système",
resetDefault: "Réinitialiser par défaut",
finishedTransfers: { label: "Transferts terminés", autoClear: "Effacer auto.", keep: "Garder" },
},
kinds: {
folder: "Dossier",
symlink: "Lien symbolique",
file: "Fichier",
image: "Image",
audio: "Audio",
video: "Vidéo",
archive: "Archive",
code: "Code",
document: "Document",
},
columns: {
name: "Nom",
size: "Taille",
mtime: "Modifié",
kind: "Type",
rights: "Droits",
owner: "Propriétaire",
group: "Groupe",
},
panel: {
choosePanel: "Choisir ce qu'affiche ce panneau",
copy: "Envoyer",
copyToRight: "Envoyer vers le panneau droit (F5)",
copyToLeft: "Envoyer vers le panneau gauche (F5)",
edit: "Éditer",
editFile: "Éditer le fichier (F4)",
rename: "Renommer (F2)",
newFolder: "Nouveau dossier (F7)",
newFile: "Nouveau fichier",
permissions: "Autorisations",
delete: "Supprimer",
deleteHint: "Supprimer (F8 / Suppr)",
parentDir: "Dossier parent (Retour arrière)",
refresh: "Actualiser",
clearFilter: "Effacer le filtre (Échap)",
sshConfig: "~/.ssh/config",
connectingTo: "Connexion à {name}…",
emptyDir: "Dossier vide",
noMatches: "Aucun résultat",
matches: { one: "{count} résultat · Échap", other: "{count} résultats · Échap" },
},
connMgr: {
title: "Connexions",
importWinscp: "Importer depuis WinSCP…",
importWinscpHint: "Importer des sessions depuis un export WinSCP.ini",
newConnection: "Connexion",
newConnectionHint: "Nouvelle connexion",
newStack: "Groupe",
newStackHint: "Nouveau groupe",
expand: "Développer",
collapse: "Réduire",
renameStack: "Renommer le groupe",
deleteStack: "Supprimer le groupe",
openStack: "Ouvrir le groupe dans des onglets",
empty: "vide",
connectionsHeader: "Connexions",
noneTitle: "Aucune connexion enregistrée.",
noneBody: "Créez-en une ou importez vos sessions WinSCP.",
name: "Nom",
namePlaceholder: "mon serveur",
host: "Hôte",
port: "Port",
stack: "Groupe",
stackNone: "(aucun)",
username: "Utilisateur",
remotePath: "Chemin distant (facultatif)",
remotePathPlaceholder: "dossier personnel",
auth: "Authentification",
authPassword: "Mot de passe",
authKey: "Clé privée",
password: "Mot de passe",
privateKeyFile: "Fichier de clé privée",
passphrase: "Phrase secrète (facultative)",
localDir: "Dossier local (facultatif)",
localDirPlaceholder: "~/projets/mon-serveur",
localDirHint: "L'autre panneau s'ouvre ici lorsqu'il est sur Local et n'a pas été parcouru.",
more: "Plus…",
less: "Moins…",
selectPrompt: "Sélectionnez une connexion ou créez-en une nouvelle.",
newStackTitle: "Nouveau groupe",
newStackPrompt: "nom du groupe",
renameStackTitle: "Renommer le groupe",
deleteStackTitle: "Supprimer le groupe",
deleteStackBody: 'Supprimer le groupe « {name} » ? Ses connexions sont conservées, sans groupe.',
deleteConnectionTitle: "Supprimer la connexion",
deleteConnectionBody: 'Supprimer « {name} » ?',
},
editor: {
noFileTitle: "Aucun fichier à éditer",
noFileBody: "Cette fenêtre a été ouverte sans contexte d'éditeur. Ouvrez des fichiers depuis l'application.",
wrap: "Retour",
wrapHint: "Retour à la ligne — masque les numéros de ligne ({alt}Z)",
findHint: "Rechercher ({mod}F)",
findPlaceholder: "Rechercher dans le fichier",
prevMatch: "Résultat précédent (⇧↵)",
nextMatch: "Résultat suivant (↵)",
closeHint: "Fermer (Échap)",
disconnected: "Déconnecté de {name} — l'enregistrement échouera, mais votre texte est conservé dans cette fenêtre.",
cantOpen: "Impossible d'ouvrir {name}",
closeWindow: "Fermer la fenêtre",
shortcuts: "{mod}S enregistrer · {mod}F rechercher · {mod}W fermer",
lines: { one: "{count} ligne", other: "{count} lignes" },
close: "Fermer",
save: "Enregistrer",
saveChangesTitle: 'Enregistrer les modifications de « {name} » ?',
saveChangesBody: "Vos modifications seront perdues si vous ne les enregistrez pas.",
dontSave: "Ne pas enregistrer",
countLabel: "{current} sur {total}",
noMatches: "Aucun résultat",
},
transfer: {
summary: "{active} en cours · {finished} terminés",
clearFinished: "Effacer les terminés",
cancel: "Annuler",
dismiss: "Ignorer",
showDetails: "Afficher les détails des transferts",
copying: "Copie de {name} · {percent}%{speed}",
queued: "{count} en file",
failed: { one: "{count} transfert échoué", other: "{count} transferts échoués" },
finished: "Transferts terminés",
ready: "Prêt",
items: "{count} éléments",
selected: "{count} sélectionnés",
queuedState: "En file",
scanning: "Analyse…",
failedState: "Échec",
cancelled: "Annulé",
completedFiles: { one: "{count} fichier · {size}", other: "{count} fichiers · {size}" },
left: "{eta} restant",
},
dialogs: {
chmodTitle: "Autorisations",
chmodItems: "{count} éléments",
read: "Lecture",
write: "Écriture",
execute: "Exécution",
roleOwner: "Propriétaire",
roleGroup: "Groupe",
roleOthers: "Autres",
octal: "Octal",
mergeFolder: "Fusionner le dossier",
replace: "Remplacer",
merge: "Fusionner",
mergeAll: "Tout fusionner",
replaceAll: "Tout remplacer",
skip: "Ignorer",
skipAll: "Tout ignorer",
ofTotal: "{index} sur {total}",
overwriteDirBody: "La destination contient déjà un dossier de ce nom. La fusion peut remplacer des fichiers.",
overwriteFileBody: "La destination contient déjà un fichier de ce nom.",
source: "Source",
destination: "Destination",
newer: "plus récent",
},
toasts: {
movedToTrash: '« {name} » mis à la corbeille',
editingExternal: "Édition de {name} — les enregistrements sont envoyés automatiquement",
nothingToCopy: "Rien à copier (les liens symboliques sont ignorés)",
nothingToCopyAllSkipped: "Rien à copier — tous les éléments en conflit ont été ignorés",
sameFolder: "Les deux panneaux affichent le même dossier",
sameFile: "« {name} » ignoré — c'est le même fichier",
typeMismatch: "« {name} » ignoré — un autre type d'élément est déjà là",
transferFailed: 'Le transfert « {name} » a échoué : {error}',
unknownError: "erreur inconnue",
saved: "{name} enregistré",
disconnectedReason: "{title} : déconnecté ({reason})",
imported: { one: "{count} connexion importée", other: "{count} connexions importées" },
importedIntoStacks: { one: " dans {count} nouveau groupe", other: " dans {count} nouveaux groupes" },
importedSkipped: " ({skipped} ignorées)",
leftoverTitle: "Fichiers édités restants",
leftoverMore: ", +{count} de plus",
leftoverBody: {
one: "Une session précédente a laissé {count} fichier édité temporaire ({names}). Les supprimer ?",
other: "Une session précédente a laissé {count} fichiers édités temporaires ({names}). Les supprimer ?",
},
leftoverClear: "Supprimer",
},
fileActions: {
deleteTitle: "Supprimer",
deleteBody: "Supprimer {what} ? Les dossiers sont supprimés récursivement. Action irréversible.",
deleteConfirm: "Supprimer",
renameTitle: 'Renommer « {name} »',
renameConfirm: "Renommer",
newFolderTitle: "Nouveau dossier",
newFolderPlaceholder: "nom du dossier",
newFileTitle: "Nouveau fichier",
newFilePlaceholder: "nom du fichier",
createConfirm: "Créer",
itemsCount: "{count} éléments",
},
menu: {
about: "À propos de {app}",
checkForUpdates: "Rechercher des mises à jour…",
quit: "Quitter",
file: "Fichier",
newTab: "Nouvel onglet",
closeTab: "Fermer l'onglet",
newFolder: "Nouveau dossier",
newFile: "Nouveau fichier",
editFile: "Éditer le fichier",
connect: "Connecter",
connectionManager: "Gestionnaire de connexions…",
edit: "Édition",
view: "Affichage",
refresh: "Actualiser",
window: "Fenêtre",
},
ctx: {
size: "Taille",
modified: "Modifié",
kind: "Type",
rights: "Droits",
owner: "Propriétaire",
group: "Groupe",
revealFinder: "Afficher dans le Finder",
revealExplorer: "Afficher dans l'Explorateur",
revealFileManager: "Afficher dans le gestionnaire de fichiers",
moveToTrash: "Mettre à la corbeille",
moveToRecycleBin: "Mettre à la corbeille",
newFolder: "Nouveau dossier",
newFile: "Nouveau fichier",
refresh: "Actualiser",
open: "Ouvrir",
openDefault: "Ouvrir avec l'application par défaut",
edit: "Éditer",
copyToOther: "Envoyer vers l'autre panneau",
rename: "Renommer",
permissions: "Autorisations",
copyPath: "Copier le chemin",
delete: "Supprimer",
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",
},
errors: {
sshAgentUnavailable: "ssh-agent indisponible (SSH_AUTH_SOCK non défini)",
identityFileNotFound: "fichier d'identité introuvable : {path}",
sessionNotConnected: "La session « {id} » n'est pas connectée",
localCannotDisconnect: "La session locale ne peut pas être déconnectée",
notADirectory: "« {path} » existe et n'est pas un dossier",
sameFileTransfer: '« {name} » : la source et la destination sont le même fichier',
sameFileNoName: "La source et la destination sont le même fichier",
folderOntoItself: "Impossible de copier un dossier sur lui-même",
folderIntoItself: "Impossible de copier un dossier dans lui-même",
},
} as const;
+43
View File
@@ -0,0 +1,43 @@
import type { SupportedLocale } from "../types";
import { de } from "./de";
import { en } from "./en";
import { es } from "./es";
import { eu } from "./eu";
import { fr } from "./fr";
import { ja } from "./ja";
import { zh } from "./zh";
/** A translation tree: leaves are strings; a leaf may be a plural map. */
export type Messages = { readonly [key: string]: string | Messages };
export const DICTS: Record<SupportedLocale, Messages> = { en, es, eu, fr, de, zh, ja };
/** Order shown in the language picker. */
export const SUPPORTED: readonly SupportedLocale[] = ["en", "es", "eu", "fr", "de", "zh", "ja"];
/** Each language named in itself (endonyms) for the picker. */
export const LOCALE_LABELS: Record<SupportedLocale, string> = {
en: "English",
es: "Español",
eu: "Euskara",
fr: "Français",
de: "Deutsch",
zh: "中文",
ja: "日本語",
};
/**
* Resolve the effective locale: an explicit choice wins; "system" maps the OS
* BCP-47 tag (e.g. "es-ES", "zh-Hans-CN") to a supported language, else English.
*/
export function resolveLocale(
setting: "system" | SupportedLocale,
osLocale: string | undefined,
): SupportedLocale {
if (setting !== "system") return setting;
const tag = (osLocale ?? "en").toLowerCase();
const primary = tag.split("-")[0];
return (SUPPORTED as readonly string[]).includes(primary)
? (primary as SupportedLocale)
: "en";
}
+315
View File
@@ -0,0 +1,315 @@
// Japanese translations. Fall back to English for missing keys.
// TODO(i18n): AI draft — needs native-speaker review.
export const ja = {
common: {
cancel: "キャンセル",
save: "保存",
close: "閉じる",
delete: "削除",
rename: "名前を変更",
create: "作成",
apply: "適用",
choose: "選択…",
connect: "接続",
local: "ローカル",
},
app: {
notConnectedTitle: "アプリに接続されていません",
notConnectedBody: "このページは通常のブラウザで動作しています。デスクトップアプリを起動して使用してください。",
managerOpen: "接続マネージャーが開いています。",
showConnections: "接続を表示",
},
tabBar: {
connections: "接続",
manageConnections: "接続を管理",
newTab: "新しいタブ ({mod}T)",
closeTab: "タブを閉じる ({mod}W)",
options: "オプション",
update: "アップデートあり",
updating: "更新中",
restartToUpdate: "再起動して適用",
updateTooltip: "アップデートがあります: miniSCP {version}",
},
options: {
title: "オプション",
language: "言語",
system: "システム",
skin: {
label: "外観",
modern: "モダン",
win7: "Windows 7",
restartHint: "次回 miniSCP を起動したときに適用されます",
},
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
tone: { label: "トーン", dark: "ダーク", darker: "より暗く" },
accent: { label: "アクセント" },
accentNames: {
graphite: "グラファイト",
blue: "ブルー",
indigo: "インディゴ",
purple: "パープル",
rose: "ローズ",
orange: "オレンジ",
green: "グリーン",
teal: "ティール",
},
fileIcons: { label: "ファイルアイコン", plain: "シンプル", multi: "カラー" },
interface: { label: "インターフェース", smaller: "縮小", larger: "拡大", reset: "100%に戻す" },
speedUnit: { label: "速度の単位" },
transfers: { label: "転送", single: "1件ずつ", multi: "複数" },
atOnce: "同時数",
editor: { label: "エディタ", builtin: "内蔵", custom: "カスタム" },
systemDefault: "システムの既定",
resetDefault: "既定に戻す",
finishedTransfers: { label: "完了した転送", autoClear: "自動クリア", keep: "保持" },
},
kinds: {
folder: "フォルダ",
symlink: "シンボリックリンク",
file: "ファイル",
image: "画像",
audio: "オーディオ",
video: "動画",
archive: "アーカイブ",
code: "コード",
document: "ドキュメント",
},
columns: {
name: "名前",
size: "サイズ",
mtime: "更新日時",
kind: "種類",
rights: "権限",
owner: "所有者",
group: "グループ",
},
panel: {
choosePanel: "このパネルに表示する内容を選択",
copy: "送信",
copyToRight: "右のパネルへ送信 (F5)",
copyToLeft: "左のパネルへ送信 (F5)",
edit: "編集",
editFile: "ファイルを編集 (F4)",
rename: "名前を変更 (F2)",
newFolder: "新規フォルダ (F7)",
newFile: "新規ファイル",
permissions: "アクセス権",
delete: "削除",
deleteHint: "削除 (F8 / Del)",
parentDir: "親フォルダ (Backspace)",
refresh: "更新",
clearFilter: "フィルターをクリア (Esc)",
sshConfig: "~/.ssh/config",
connectingTo: "{name} に接続中…",
emptyDir: "空のフォルダ",
noMatches: "一致なし",
matches: { other: "{count} 件一致 · Esc" },
},
connMgr: {
title: "接続",
importWinscp: "WinSCP からインポート…",
importWinscpHint: "WinSCP.ini エクスポートからセッションをインポート",
newConnection: "接続",
newConnectionHint: "新規接続",
newStack: "グループ",
newStackHint: "新規グループ",
expand: "展開",
collapse: "折りたたむ",
renameStack: "グループ名を変更",
deleteStack: "グループを削除",
openStack: "グループをタブで開く",
empty: "空",
connectionsHeader: "接続",
noneTitle: "保存された接続はまだありません。",
noneBody: "作成するか、WinSCP セッションをインポートしてください。",
name: "名前",
namePlaceholder: "マイサーバー",
host: "ホスト",
port: "ポート",
stack: "グループ",
stackNone: "(なし)",
username: "ユーザー名",
remotePath: "リモートパス(任意)",
remotePathPlaceholder: "ホームディレクトリ",
auth: "認証",
authPassword: "パスワード",
authKey: "秘密鍵",
password: "パスワード",
privateKeyFile: "秘密鍵ファイル",
passphrase: "パスフレーズ(任意)",
localDir: "ローカルディレクトリ(任意)",
localDirPlaceholder: "~/projects/my-server",
localDirHint: "もう一方のパネルがローカルで未操作のとき、ここが開きます。",
more: "詳細…",
less: "閉じる…",
selectPrompt: "接続を選択するか、新しく作成してください。",
newStackTitle: "新規グループ",
newStackPrompt: "グループ名",
renameStackTitle: "グループ名を変更",
deleteStackTitle: "グループを削除",
deleteStackBody: 'グループ "{name}" を削除しますか?含まれる接続は残り、グループ解除されます。',
deleteConnectionTitle: "接続を削除",
deleteConnectionBody: '"{name}" を削除しますか?',
},
editor: {
noFileTitle: "編集するファイルがありません",
noFileBody: "このウィンドウはエディタコンテキストなしで開かれました。アプリからファイルを開いてください。",
wrap: "折り返し",
wrapHint: "長い行を折り返す — 行番号を非表示 ({alt}Z)",
findHint: "検索 ({mod}F)",
findPlaceholder: "ファイル内を検索",
prevMatch: "前の一致 (⇧↵)",
nextMatch: "次の一致 (↵)",
closeHint: "閉じる (Esc)",
disconnected: "{name} から切断されました — 保存は失敗しますが、テキストはこのウィンドウに保持されます。",
cantOpen: "{name} を開けません",
closeWindow: "ウィンドウを閉じる",
shortcuts: "{mod}S 保存 · {mod}F 検索 · {mod}W 閉じる",
lines: { other: "{count} 行" },
close: "閉じる",
save: "保存",
saveChangesTitle: '"{name}" への変更を保存しますか?',
saveChangesBody: "保存しないと変更は失われます。",
dontSave: "保存しない",
countLabel: "{total} 件中 {current} 件目",
noMatches: "一致なし",
},
transfer: {
summary: "{active} 件実行中 · {finished} 件完了",
clearFinished: "完了分をクリア",
cancel: "キャンセル",
dismiss: "閉じる",
showDetails: "転送の詳細を表示",
copying: "{name} をコピー中 · {percent}%{speed}",
queued: "{count} 件待機中",
failed: { other: "{count} 件の転送が失敗" },
finished: "転送が完了しました",
ready: "準備完了",
items: "{count} 項目",
selected: "{count} 件選択中",
queuedState: "待機中",
scanning: "スキャン中…",
failedState: "失敗",
cancelled: "キャンセル済み",
completedFiles: { other: "{count} 個のファイル · {size}" },
left: "残り {eta}",
},
dialogs: {
chmodTitle: "アクセス権",
chmodItems: "{count} 項目",
read: "読み取り",
write: "書き込み",
execute: "実行",
roleOwner: "所有者",
roleGroup: "グループ",
roleOthers: "その他",
octal: "8進数",
mergeFolder: "フォルダを結合",
replace: "置き換える",
merge: "結合",
mergeAll: "すべて結合",
replaceAll: "すべて置き換える",
skip: "スキップ",
skipAll: "すべてスキップ",
ofTotal: "{total} 件中 {index} 件目",
overwriteDirBody: "コピー先に同名のフォルダが既にあります。結合すると中のファイルが置き換わる可能性があります。",
overwriteFileBody: "コピー先に同名のファイルが既にあります。",
source: "コピー元",
destination: "コピー先",
newer: "新しい",
},
toasts: {
movedToTrash: '"{name}" をゴミ箱に移動しました',
editingExternal: "{name} を編集中 — 保存すると自動でアップロードされます",
nothingToCopy: "コピーする項目がありません(シンボリックリンクはスキップされます)",
nothingToCopyAllSkipped: "コピーする項目がありません — 競合する項目はすべてスキップされました",
sameFolder: "両方のパネルが同じフォルダを表示しています",
sameFile: '"{name}" をスキップしました — 同じファイルです',
typeMismatch: '"{name}" をスキップしました — 別の種類の項目があります',
transferFailed: '転送 "{name}" が失敗しました: {error}',
unknownError: "不明なエラー",
saved: "{name} を保存しました",
disconnectedReason: "{title}: 切断されました ({reason})",
imported: { other: "{count} 件の接続をインポートしました" },
importedIntoStacks: { other: "({count} 件の新規グループに)" },
importedSkipped: "({skipped} 件スキップ)",
leftoverTitle: "残った編集済みファイル",
leftoverMore: "、他 {count} 件",
leftoverBody: {
other: "前回のセッションで {count} 件の一時編集ファイルが残っています({names})。クリアしますか?",
},
leftoverClear: "クリア",
},
fileActions: {
deleteTitle: "削除",
deleteBody: "{what} を削除しますか?フォルダは再帰的に削除されます。この操作は取り消せません。",
deleteConfirm: "削除",
renameTitle: '"{name}" の名前を変更',
renameConfirm: "名前を変更",
newFolderTitle: "新規フォルダ",
newFolderPlaceholder: "フォルダ名",
newFileTitle: "新規ファイル",
newFilePlaceholder: "ファイル名",
createConfirm: "作成",
itemsCount: "{count} 項目",
},
menu: {
about: "{app} について",
checkForUpdates: "アップデートを確認…",
quit: "終了",
file: "ファイル",
newTab: "新しいタブ",
closeTab: "タブを閉じる",
newFolder: "新規フォルダ",
newFile: "新規ファイル",
editFile: "ファイルを編集",
connect: "接続",
connectionManager: "接続マネージャー…",
edit: "編集",
view: "表示",
refresh: "更新",
window: "ウィンドウ",
},
ctx: {
size: "サイズ",
modified: "更新日時",
kind: "種類",
rights: "権限",
owner: "所有者",
group: "グループ",
revealFinder: "Finder で表示",
revealExplorer: "エクスプローラーで表示",
revealFileManager: "ファイルマネージャーで表示",
moveToTrash: "ゴミ箱に移動",
moveToRecycleBin: "ごみ箱に移動",
newFolder: "新規フォルダ",
newFile: "新規ファイル",
refresh: "更新",
open: "開く",
openDefault: "既定のアプリで開く",
edit: "編集",
copyToOther: "もう一方のパネルへ送信",
rename: "名前を変更",
permissions: "アクセス権",
copyPath: "パスをコピー",
delete: "削除",
columns: "列",
tabNew: "新しいタブ",
tabClose: "タブを閉じる",
tabCloseOthers: "他のタブを閉じる",
tabCloseRight: "右側のタブを閉じる",
tabDisconnect: "切断",
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません",
},
errors: {
sshAgentUnavailable: "ssh-agent が利用できません(SSH_AUTH_SOCK が未設定)",
identityFileNotFound: "認証鍵ファイルが見つかりません: {path}",
sessionNotConnected: 'セッション "{id}" は接続されていません',
localCannotDisconnect: "ローカルセッションは切断できません",
notADirectory: '"{path}" は存在し、フォルダではありません',
sameFileTransfer: '"{name}": コピー元とコピー先が同じファイルです',
sameFileNoName: "コピー元とコピー先が同じファイルです",
folderOntoItself: "フォルダをそれ自身の上にコピーすることはできません",
folderIntoItself: "フォルダをそれ自身の中にコピーすることはできません",
},
} as const;
+315
View File
@@ -0,0 +1,315 @@
// Simplified Chinese translations. Fall back to English for missing keys.
// TODO(i18n): AI draft — needs native-speaker review.
export const zh = {
common: {
cancel: "取消",
save: "保存",
close: "关闭",
delete: "删除",
rename: "重命名",
create: "创建",
apply: "应用",
choose: "选择…",
connect: "连接",
local: "本地",
},
app: {
notConnectedTitle: "未连接到应用",
notConnectedBody: "此页面运行在普通浏览器中。请启动桌面应用来使用。",
managerOpen: "连接管理器已打开。",
showConnections: "显示连接",
},
tabBar: {
connections: "连接",
manageConnections: "管理连接",
newTab: "新建标签页 ({mod}T)",
closeTab: "关闭标签页 ({mod}W)",
options: "选项",
update: "有可用更新",
updating: "正在更新",
restartToUpdate: "重启以应用",
updateTooltip: "有可用更新:miniSCP {version}",
},
options: {
title: "选项",
language: "语言",
system: "系统",
skin: {
label: "外观",
modern: "现代",
win7: "Windows 7",
restartHint: "下次启动 miniSCP 时生效",
},
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
tone: { label: "色调", dark: "深色", darker: "更深" },
accent: { label: "强调色" },
accentNames: {
graphite: "石墨",
blue: "蓝色",
indigo: "靛蓝",
purple: "紫色",
rose: "玫红",
orange: "橙色",
green: "绿色",
teal: "青色",
},
fileIcons: { label: "文件图标", plain: "简约", multi: "彩色" },
interface: { label: "界面", smaller: "缩小", larger: "放大", reset: "重置为100%" },
speedUnit: { label: "速度单位" },
transfers: { label: "传输", single: "单个", multi: "多个" },
atOnce: "同时",
editor: { label: "编辑器", builtin: "内置", custom: "自定义" },
systemDefault: "系统默认",
resetDefault: "恢复为系统默认",
finishedTransfers: { label: "已完成传输", autoClear: "自动清除", keep: "保留" },
},
kinds: {
folder: "文件夹",
symlink: "符号链接",
file: "文件",
image: "图像",
audio: "音频",
video: "视频",
archive: "压缩包",
code: "代码",
document: "文档",
},
columns: {
name: "名称",
size: "大小",
mtime: "修改时间",
kind: "类型",
rights: "权限",
owner: "所有者",
group: "组",
},
panel: {
choosePanel: "选择此面板显示的内容",
copy: "发送",
copyToRight: "发送到右侧面板 (F5)",
copyToLeft: "发送到左侧面板 (F5)",
edit: "编辑",
editFile: "编辑文件 (F4)",
rename: "重命名 (F2)",
newFolder: "新建文件夹 (F7)",
newFile: "新建文件",
permissions: "权限",
delete: "删除",
deleteHint: "删除 (F8 / Del)",
parentDir: "上级目录 (退格)",
refresh: "刷新",
clearFilter: "清除筛选 (Esc)",
sshConfig: "~/.ssh/config",
connectingTo: "正在连接 {name}…",
emptyDir: "空目录",
noMatches: "无匹配项",
matches: { other: "{count} 个匹配 · Esc" },
},
connMgr: {
title: "连接",
importWinscp: "从 WinSCP 导入…",
importWinscpHint: "从 WinSCP.ini 导出文件导入会话",
newConnection: "连接",
newConnectionHint: "新建连接",
newStack: "分组",
newStackHint: "新建分组",
expand: "展开",
collapse: "折叠",
renameStack: "重命名分组",
deleteStack: "删除分组",
openStack: "在标签页中打开分组",
empty: "空",
connectionsHeader: "连接",
noneTitle: "尚无已保存的连接。",
noneBody: "创建一个,或导入你的 WinSCP 会话。",
name: "名称",
namePlaceholder: "我的服务器",
host: "主机",
port: "端口",
stack: "分组",
stackNone: "(无)",
username: "用户名",
remotePath: "远程路径(可选)",
remotePathPlaceholder: "主目录",
auth: "认证",
authPassword: "密码",
authKey: "私钥",
password: "密码",
privateKeyFile: "私钥文件",
passphrase: "密钥口令(可选)",
localDir: "本地目录(可选)",
localDirPlaceholder: "~/projects/my-server",
localDirHint: "当另一个面板处于本地且未浏览过时,将在此打开。",
more: "更多…",
less: "收起…",
selectPrompt: "选择一个连接或新建一个。",
newStackTitle: "新建分组",
newStackPrompt: "分组名称",
renameStackTitle: "重命名分组",
deleteStackTitle: "删除分组",
deleteStackBody: '删除分组 "{name}"?其中的连接将保留但不再分组。',
deleteConnectionTitle: "删除连接",
deleteConnectionBody: '删除 "{name}"?',
},
editor: {
noFileTitle: "没有要编辑的文件",
noFileBody: "此窗口在没有编辑器上下文的情况下打开。请从应用中打开文件。",
wrap: "换行",
wrapHint: "长行自动换行 — 隐藏行号 ({alt}Z)",
findHint: "查找 ({mod}F)",
findPlaceholder: "在文件中查找",
prevMatch: "上一个匹配 (⇧↵)",
nextMatch: "下一个匹配 (↵)",
closeHint: "关闭 (Esc)",
disconnected: "已从 {name} 断开 — 保存将失败,但你的文本会保留在此窗口中。",
cantOpen: "无法打开 {name}",
closeWindow: "关闭窗口",
shortcuts: "{mod}S 保存 · {mod}F 查找 · {mod}W 关闭",
lines: { other: "{count} 行" },
close: "关闭",
save: "保存",
saveChangesTitle: '保存对 "{name}" 的更改?',
saveChangesBody: "如果不保存,你的更改将丢失。",
dontSave: "不保存",
countLabel: "第 {current} / {total}",
noMatches: "无匹配项",
},
transfer: {
summary: "{active} 进行中 · {finished} 已完成",
clearFinished: "清除已完成",
cancel: "取消",
dismiss: "忽略",
showDetails: "显示传输详情",
copying: "正在复制 {name} · {percent}%{speed}",
queued: "{count} 个排队中",
failed: { other: "{count} 个传输失败" },
finished: "传输已完成",
ready: "就绪",
items: "{count} 项",
selected: "已选 {count} 项",
queuedState: "排队中",
scanning: "扫描中…",
failedState: "失败",
cancelled: "已取消",
completedFiles: { other: "{count} 个文件 · {size}" },
left: "剩余 {eta}",
},
dialogs: {
chmodTitle: "权限",
chmodItems: "{count} 项",
read: "读",
write: "写",
execute: "执行",
roleOwner: "所有者",
roleGroup: "组",
roleOthers: "其他",
octal: "八进制",
mergeFolder: "合并文件夹",
replace: "替换",
merge: "合并",
mergeAll: "全部合并",
replaceAll: "全部替换",
skip: "跳过",
skipAll: "全部跳过",
ofTotal: "第 {index} / {total}",
overwriteDirBody: "目标位置已存在同名文件夹。合并可能会替换其中的文件。",
overwriteFileBody: "目标位置已存在同名文件。",
source: "源",
destination: "目标",
newer: "较新",
},
toasts: {
movedToTrash: '已将 "{name}" 移到废纸篓',
editingExternal: "正在编辑 {name} — 保存后会自动上传",
nothingToCopy: "没有可复制的内容(符号链接将被跳过)",
nothingToCopyAllSkipped: "没有可复制的内容 — 所有冲突项都被跳过",
sameFolder: "两个面板显示的是同一个文件夹",
sameFile: '已跳过 "{name}" — 它是同一个文件',
typeMismatch: '已跳过 "{name}" — 目标处是不同类型的项目',
transferFailed: '传输 "{name}" 失败:{error}',
unknownError: "未知错误",
saved: "已保存 {name}",
disconnectedReason: "{title}:已断开({reason})",
imported: { other: "已导入 {count} 个连接" },
importedIntoStacks: { other: ",归入 {count} 个新分组" },
importedSkipped: "(跳过 {skipped} 个)",
leftoverTitle: "残留的已编辑文件",
leftoverMore: ",还有 {count} 个",
leftoverBody: {
other: "上一次会话遗留了 {count} 个临时编辑文件({names})。要清除吗?",
},
leftoverClear: "清除",
},
fileActions: {
deleteTitle: "删除",
deleteBody: "删除 {what}?文件夹将被递归删除。此操作无法撤销。",
deleteConfirm: "删除",
renameTitle: '重命名 "{name}"',
renameConfirm: "重命名",
newFolderTitle: "新建文件夹",
newFolderPlaceholder: "文件夹名称",
newFileTitle: "新建文件",
newFilePlaceholder: "文件名称",
createConfirm: "创建",
itemsCount: "{count} 项",
},
menu: {
about: "关于 {app}",
checkForUpdates: "检查更新…",
quit: "退出",
file: "文件",
newTab: "新建标签页",
closeTab: "关闭标签页",
newFolder: "新建文件夹",
newFile: "新建文件",
editFile: "编辑文件",
connect: "连接",
connectionManager: "连接管理器…",
edit: "编辑",
view: "查看",
refresh: "刷新",
window: "窗口",
},
ctx: {
size: "大小",
modified: "修改时间",
kind: "类型",
rights: "权限",
owner: "所有者",
group: "组",
revealFinder: "在访达中显示",
revealExplorer: "在资源管理器中显示",
revealFileManager: "在文件管理器中显示",
moveToTrash: "移到废纸篓",
moveToRecycleBin: "移到回收站",
newFolder: "新建文件夹",
newFile: "新建文件",
refresh: "刷新",
open: "打开",
openDefault: "用默认应用打开",
edit: "编辑",
copyToOther: "发送到另一个面板",
rename: "重命名",
permissions: "权限",
copyPath: "复制路径",
delete: "删除",
columns: "列",
tabNew: "新建标签页",
tabClose: "关闭标签页",
tabCloseOthers: "关闭其他标签页",
tabCloseRight: "关闭右侧标签页",
tabDisconnect: "断开连接",
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
},
errors: {
sshAgentUnavailable: "ssh-agent 不可用(未设置 SSH_AUTH_SOCK)",
identityFileNotFound: "找不到身份文件:{path}",
sessionNotConnected: '会话 "{id}" 未连接',
localCannotDisconnect: "无法断开本地会话",
notADirectory: '"{path}" 已存在且不是文件夹',
sameFileTransfer: '"{name}":源和目标是同一个文件',
sameFileNoName: "源和目标是同一个文件",
folderOntoItself: "无法将文件夹复制到其自身之上",
folderIntoItself: "无法将文件夹复制到其自身内部",
},
} as const;
+68 -12
View File
@@ -1,6 +1,5 @@
import type { import type {
AppSettings, AppSettings,
ColumnKey,
ConnectionConfig, ConnectionConfig,
ConnectionsFile, ConnectionsFile,
EntryType, EntryType,
@@ -44,6 +43,9 @@ export type AppRPC = {
params: { sessionId: string; path: string | null }; // null -> home dir params: { sessionId: string; path: string | null }; // null -> home dir
response: Result<{ path: string; entries: FileEntry[] }>; response: Result<{ path: string; entries: FileEntry[] }>;
}; };
// Local dirs to fs.watch for auto-refresh (the active tab's local panels);
// a change pushes pathRefresh. Remote panels are polled UI-side instead.
setWatchedDirs: { params: { dirs: string[] }; response: Result };
mkdir: { params: { sessionId: string; path: string }; response: Result }; mkdir: { params: { sessionId: string; path: string }; response: Result };
createFile: { params: { sessionId: string; path: string }; response: Result }; createFile: { params: { sessionId: string; path: string }; response: Result };
rename: { rename: {
@@ -123,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/context-menu.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. */
@@ -154,6 +154,45 @@ 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 };
// custom window chrome (win7 skin — the window is frameless, so the
// webview's title bar owns the controls). Served by every window kind.
windowMinimize: { params: Record<string, never>; response: Result };
/** Maximize/restore in one call; responds with the resulting state. */
windowToggleMaximize: {
params: Record<string, never>;
response: Result<{ maximized: boolean }>;
};
windowClose: { params: Record<string, never>; response: Result };
/** First paint: the title bar needs the state before any resize fires. */
windowIsMaximized: {
params: Record<string, never>;
response: Result<{ maximized: boolean }>;
};
/**
* STOPGAP — the two halves of the synthesized resize. A frameless GTK
* window has no resize borders and electrobun exposes no native resize
* gesture, so the webview drags its own handles and moves the frame
* itself. Delete both with the rest of the hack; see the removal
* checklist in ui/src/app/skins/7like/window-resize/window-resize.ts.
*/
windowGetFrame: {
params: Record<string, never>;
response: Result<{ x: number; y: number; width: number; height: number }>;
};
windowSetFrame: {
params: { x: number; y: number; width: number; height: number };
response: Result;
};
// auto-updater (electrobun Updater)
/** Main window: re-run the update check on demand (menu item). */
checkForUpdate: {
params: Record<string, never>;
response: Result<{ updateAvailable: boolean; version: string }>;
};
/** Download the pending update (patch-chain, else full). Progress arrives via updateStatus. */
downloadUpdate: { params: Record<string, never>; response: Result };
/** Swap in the downloaded update and relaunch — quits the app, so may not resolve. */
applyUpdate: { params: Record<string, never>; response: Result };
}; };
messages: { messages: {
/** Editor → bun draft mirror; safety net for the non-cancellable native close. */ /** Editor → bun draft mirror; safety net for the non-cancellable native close. */
@@ -174,8 +213,25 @@ export type AppRPC = {
editorCloseRequest: Record<string, never>; editorCloseRequest: Record<string, never>;
/** bun → main: the modal connection manager opened/closed. */ /** bun → main: the modal connection manager opened/closed. */
managerState: { open: boolean }; managerState: { open: boolean };
/**
* bun → any window: maximize state changed, so the custom title bar can
* swap its maximize/restore glyph. electrobun has no maximize event —
* this is derived from the window's `resize` event (see windows.ts
* trackMaximizeState), which also covers OS-initiated Aero Snap.
*/
windowState: { maximized: boolean };
/** bun → main: the manager window changed the connection store. */ /** bun → main: the manager window changed the connection store. */
connectionsChanged: Record<string, never>; connectionsChanged: Record<string, never>;
/**
* bun → main: auto-updater lifecycle, drives the tab-bar update pill.
* "ready" is not a phase — the UI derives it from downloadUpdate resolving ok.
*/
updateStatus: {
phase: "available" | "downloading" | "error";
version?: string;
progress?: number;
error?: string;
};
}; };
}; };
}; };
+49 -3
View File
@@ -62,6 +62,9 @@ export interface ConnectionsFile {
connections: SavedConnection[]; connections: SavedConnection[];
} }
/** UI languages the app ships translations for. */
export type SupportedLocale = "en" | "es" | "eu" | "fr" | "de" | "zh" | "ja";
export type AccentTone = export type AccentTone =
| "graphite" | "graphite"
| "blue" | "blue"
@@ -73,6 +76,20 @@ export type AccentTone =
| "teal"; | "teal";
export interface AppSettings { export interface AppSettings {
/** UI language; "system" resolves to the OS locale (fallback English). */
locale: "system" | SupportedLocale;
/**
* Visual skin. "modern" is the shadcn-style look; "win7" swaps in 7.css
* (Windows 7 Aero) and switches the window to custom frameless chrome.
* Each skin is a whole component tree under ui/src/app/skins/ — "modern" is
* shadlike/, "win7" is 7like/ — selected at bootstrap in ui/src/main.ts.
* The value is NOT named after its folder on purpose: it is persisted in
* user settings, and "win7" is also the literal class 7.scoped.css is
* precompiled to require, so neither can be renamed freely.
* Changing this needs a window reopen to take effect on the frame itself —
* the CSS half applies live.
*/
skin: "modern" | "win7";
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";
@@ -83,8 +100,13 @@ export interface AppSettings {
/** 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 BOTH skins whole. 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". */
@@ -95,23 +117,47 @@ export interface AppSettings {
editor: "builtin" | "custom"; editor: "builtin" | "custom";
/** Full path to the external editor .app; empty = OS default application. */ /** Full path to the external editor .app; empty = OS default application. */
editorApp: string; editorApp: string;
// Auto-refresh the active tab's panels (local watched, remote polled). Always
// on for now; uncomment (+ the settings.service and tab-bar breadcrumbs, and
// gate the poll/watch in panels.service) to expose a toggle later.
// autoRefresh: boolean;
} }
export const DEFAULT_SETTINGS: AppSettings = { export const DEFAULT_SETTINGS: AppSettings = {
locale: "system",
skin: "modern",
theme: "system", theme: "system",
darkTone: "dark", darkTone: "dark",
accent: "blue", accent: "blue",
iconColors: "multi", 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,
editor: "builtin", editor: "builtin",
editorApp: "", editorApp: "",
// 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));
+5 -2
View File
@@ -2,7 +2,8 @@
"$schema": "./node_modules/@angular/cli/lib/config/schema.json", "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1, "version": 1,
"cli": { "cli": {
"packageManager": "bun" "packageManager": "bun",
"analytics": false
}, },
"newProjectRoot": "projects", "newProjectRoot": "projects",
"projects": { "projects": {
@@ -25,7 +26,9 @@
"input": "public" "input": "public"
} }
], ],
"styles": ["src/styles.css"] "styles": [
"src/styles.css"
]
}, },
"configurations": { "configurations": {
"production": { "production": {
+292
View File
@@ -0,0 +1,292 @@
<!doctype html>
<!--
Skin harness — a static gallery rendered headlessly by scripts/shot.ts so the
Win7 skin can be LOOKED AT during development instead of inferred from a
clean build log. Not shipped: it lives outside ui/src, so the Angular build
never sees it.
Each section pairs 7.css's own documented markup ("reference") with what the
app currently renders ("ours"). Where those two disagree visually, 7.css is
not reaching our component — that mismatch is the whole point of the page.
Layout here uses INLINE STYLES on purpose: Tailwind v4 only emits utilities it
found while scanning ui/src, so utility classes used only here would not exist
in the built stylesheet. {{STYLES}} is replaced by scripts/shot.ts with the
real hashed build output.
-->
<html class="win7" lang="en">
<head>
<meta charset="utf-8" />
<title>7.css harness</title>
<link rel="stylesheet" href="{{STYLES}}" />
<style>
body { margin: 0; padding: 20px; }
h2 { font-size: 13px; margin: 22px 0 6px; opacity: 0.75; }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }
.cell { min-width: 0; }
.cell > figcaption {
font-size: 11px; opacity: 0.6; margin-bottom: 5px; text-transform: uppercase;
letter-spacing: 0.04em;
}
.stage { padding: 14px; background: #6ba5d7; border-radius: 4px; }
</style>
</head>
<body>
<h2>1 — Window frame, glass, drop shadow, active colour</h2>
<div class="pair">
<figure class="cell" style="margin:0">
<figcaption>reference — .window.glass.active</figcaption>
<div class="stage">
<div class="window glass active" style="width: 320px">
<div class="title-bar">
<div class="title-bar-text">miniSCP</div>
<div class="title-bar-controls">
<button aria-label="Minimize"></button>
<button aria-label="Maximize"></button>
<button aria-label="Close"></button>
</div>
</div>
<div class="window-body"><p>Glass frame, 6px radius, drop shadow.</p></div>
</div>
</div>
</figure>
<figure class="cell" style="margin:0">
<figcaption>ours — app.html shell (active above, inactive below)</figcaption>
<div class="stage">
<div class="window glass active" style="width: 320px">
<div class="title-bar">
<div class="title-bar-text">miniSCP</div>
<div class="title-bar-controls">
<button aria-label="Minimize"></button>
<button aria-label="Maximize"></button>
<button aria-label="Close"></button>
</div>
</div>
<div class="window-body"><p style="margin:0">Focused window.</p></div>
</div>
<div class="window glass" style="width: 320px; margin-top: 12px">
<div class="title-bar">
<div class="title-bar-text">miniSCP</div>
<div class="title-bar-controls">
<button aria-label="Minimize"></button>
<button aria-label="Maximize"></button>
<button aria-label="Close"></button>
</div>
</div>
<div class="window-body"><p style="margin:0">Unfocused — chrome desaturates.</p></div>
</div>
</div>
</figure>
</div>
<h2>2 — ListView (file panel)</h2>
<div class="pair">
<figure class="cell" style="margin:0">
<figcaption>reference — &lt;table&gt;</figcaption>
<table class="has-shadow" style="width:100%">
<thead>
<tr>
<th>Name</th>
<th class="highlighted indicator">Size</th>
<th>Modified</th>
</tr>
</thead>
<tbody>
<tr><td>..</td><td style="text-align:right">—</td><td>—</td></tr>
<tr><td>Documents</td><td style="text-align:right">—</td><td>3/29/2007 12:00 AM</td></tr>
<tr class="highlighted"><td>report.pdf</td><td style="text-align:right">27 KB</td><td>3/23/2007 12:00 AM</td></tr>
<tr><td>notes.txt</td><td style="text-align:right">4 KB</td><td>3/13/2007 12:00 AM</td></tr>
</tbody>
</table>
</figure>
<figure class="cell" style="margin:0">
<figcaption>ours — focused pane (top) vs unfocused pane (bottom)</figcaption>
<!-- Focused pane: strong band on selected rows AND the cursor row;
the cursor row additionally gets the dotted focus rect. -->
<table class="is-active">
<colgroup>
<col />
<col style="width: 5.5rem" />
<col style="width: 8.5rem" />
</colgroup>
<thead>
<tr>
<th>Name</th>
<th class="highlighted indicator up" style="text-align:right">Size</th>
<th>Modified</th>
</tr>
</thead>
<tbody>
<tr><td>..</td><td style="text-align:right">—</td><td>—</td></tr>
<tr class="highlighted"><td>Documents</td><td style="text-align:right">—</td><td>3/29/2007 12:00 AM</td></tr>
<tr class="highlighted"><td>report.pdf</td><td style="text-align:right">27 KB</td><td>3/23/2007 12:00 AM</td></tr>
<tr class="highlighted is-cursor"><td>notes.txt</td><td style="text-align:right">4 KB</td><td>3/13/2007 12:00 AM</td></tr>
</tbody>
</table>
<!-- Unfocused pane: same rows, faint inactive wash, no dots. -->
<table style="margin-top:10px">
<colgroup>
<col />
<col style="width: 5.5rem" />
<col style="width: 8.5rem" />
</colgroup>
<thead>
<tr>
<th>Name</th>
<th style="text-align:right">Size</th>
<th>Modified</th>
</tr>
</thead>
<tbody>
<tr><td>..</td><td style="text-align:right">—</td><td>—</td></tr>
<tr class="highlighted"><td>Documents</td><td style="text-align:right">—</td><td>3/29/2007 12:00 AM</td></tr>
<tr><td>report.pdf</td><td style="text-align:right">27 KB</td><td>3/23/2007 12:00 AM</td></tr>
<tr class="highlighted"><td>notes.txt</td><td style="text-align:right">4 KB</td><td>3/13/2007 12:00 AM</td></tr>
</tbody>
</table>
</figure>
</div>
<h2>2b — Selection geometry (rows must NOT shift when selected)</h2>
<div class="pair">
<figure class="cell" style="margin:0">
<figcaption>no selection</figcaption>
<table class="is-active">
<colgroup><col /><col style="width:6rem" /></colgroup>
<thead><tr><th>Name</th><th>Size</th></tr></thead>
<tbody>
<tr><td>aaa.txt</td><td>1 KB</td></tr>
<tr><td>bbb.txt</td><td>2 KB</td></tr>
<tr><td>ccc.txt</td><td>3 KB</td></tr>
<tr><td>ddd.txt</td><td>4 KB</td></tr>
</tbody>
</table>
</figure>
<figure class="cell" style="margin:0">
<figcaption>middle row selected — baselines must still line up</figcaption>
<table class="is-active">
<colgroup><col /><col style="width:6rem" /></colgroup>
<thead><tr><th>Name</th><th>Size</th></tr></thead>
<tbody>
<tr><td>aaa.txt</td><td>1 KB</td></tr>
<tr class="highlighted"><td>bbb.txt</td><td>2 KB</td></tr>
<tr><td>ccc.txt</td><td>3 KB</td></tr>
<tr><td>ddd.txt</td><td>4 KB</td></tr>
</tbody>
</table>
</figure>
</div>
<h2>3 — Context menu</h2>
<div class="pair">
<figure class="cell" style="margin:0">
<figcaption>reference — ul[role=menu]</figcaption>
<ul role="menu" style="width:200px">
<li role="menuitem"><a href="#m">Send to Other Panel</a></li>
<li role="menuitem"><a href="#m">Open</a></li>
<li role="menuitem" class="has-divider"><a href="#m">Rename</a></li>
<li role="menuitem"><a href="#m">Copy Path</a></li>
<li role="menuitem" aria-disabled="true"><a href="#m">Permissions</a></li>
<li role="menuitem" class="has-divider"><a href="#m">Delete</a></li>
<li role="menuitem">
<input type="checkbox" id="h-col-size" checked />
<label for="h-col-size">Size</label>
</li>
<li role="menuitem">
<input type="checkbox" id="h-col-kind" />
<label for="h-col-kind">Kind</label>
</li>
</ul>
</figure>
<figure class="cell" style="margin:0">
<figcaption>ours — context-menu.ts output (buttons, divider, accelerator, checkables)</figcaption>
<!-- w7-ctx is the app's marker class (flat highlight, no Aero afterglow).
It also carries a position:fixed restatement, which the app wants and a
review page in normal flow does not — hence the inline static. -->
<ul role="menu" class="w7-ctx" style="width:200px;position:static">
<li role="menuitem"><button type="button">Send to Other Panel</button></li>
<li role="menuitem"><button type="button">Open</button></li>
<li role="menuitem"><button type="button">Edit <span>F4</span></button></li>
<li role="menuitem" class="has-divider"><button type="button">Rename</button></li>
<li role="menuitem"><button type="button">Copy Path</button></li>
<li role="menuitem" class="has-divider"><button type="button">Delete</button></li>
<li role="menuitem">
<input type="checkbox" id="ours-col-size" checked tabindex="-1" />
<label for="ours-col-size">Size</label>
</li>
<li role="menuitem">
<input type="checkbox" id="ours-col-kind" tabindex="-1" />
<label for="ours-col-kind">Kind</label>
</li>
</ul>
</figure>
</div>
<h2>4 — Controls 7.css already owns (sanity check)</h2>
<div class="pair">
<figure class="cell" style="margin:0">
<figcaption>buttons / textbox / progress</figcaption>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="display:flex;gap:6px"><button>OK</button><button>Cancel</button><button disabled>Disabled</button></div>
<input type="text" value="/home/supongo/code" style="width:100%" />
<div role="progressbar" style="width:100%"><div style="width:42%"></div></div>
<div role="progressbar" class="marquee" style="width:100%"></div>
</div>
</figure>
<figure class="cell" style="margin:0">
<figcaption>reference — menu[role=tablist]</figcaption>
<menu role="tablist">
<button role="tab" aria-selected="true">local</button>
<button role="tab">server-a</button>
<button role="tab">server-b</button>
</menu>
<div role="tabpanel" style="padding:10px">Tab body</div>
</figure>
</div>
<h2>5 — Workspace tab strip (ours, as tab-bar.ts renders it)</h2>
<!--
The live app only ever has ONE workspace at startup and there is no way to
click "+" from a headless capture, so multi-tab state can only be checked
here. This mirrors tab-bar.ts exactly: the tabs sit in a menu[role=tablist]
inside a .w7-toolbar row, and the "+" / right-hand tools sit OUTSIDE the
menu so 7.css's descendant selector does not style them as tabs.
-->
<div class="pair">
<figure class="cell" style="margin:0">
<figcaption>ours — 3 workspaces, 2nd active</figcaption>
<div class="w7-toolbar relative flex h-9 items-center gap-0.5 border-b border-border bg-chrome pt-1 pr-1.5 pl-1.5">
<menu role="tablist" class="flex min-w-0 items-center overflow-x-auto">
<button role="tab" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
<span class="truncate">Local</span>
</button>
<button role="tab" aria-selected="true" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
<span class="truncate">build-server</span>
</button>
<button role="tab" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
<span class="truncate">a-very-long-workspace-name-that-truncates</span>
</button>
</menu>
<button class="ml-0.5 grid size-6 place-items-center rounded-md">+</button>
<div class="flex-1"></div>
<button class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px]">Connections</button>
</div>
<div style="height:60px;background:var(--w7-tab-bg,#fff);border:1px solid #8e8f8f;border-top:0"></div>
</figure>
<figure class="cell" style="margin:0">
<figcaption>ours — single workspace (the live startup state)</figcaption>
<div class="w7-toolbar relative flex h-9 items-center gap-0.5 border-b border-border bg-chrome pt-1 pr-1.5 pl-1.5">
<menu role="tablist" class="flex min-w-0 items-center overflow-x-auto">
<button role="tab" aria-selected="true" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
<span class="truncate">Local</span>
</button>
</menu>
<button class="ml-0.5 grid size-6 place-items-center rounded-md">+</button>
<div class="flex-1"></div>
<button class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px]">Connections</button>
</div>
<div style="height:60px;background:var(--w7-tab-bg,#fff);border:1px solid #8e8f8f;border-top:0"></div>
</figure>
</div>
</body>
</html>
+1
View File
@@ -9,6 +9,7 @@
"private": true, "private": true,
"packageManager": "bun@1.3.13", "packageManager": "bun@1.3.13",
"dependencies": { "dependencies": {
"7.css": "^0.21.1",
"@angular/common": "^22.0.0", "@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0", "@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0", "@angular/core": "^22.0.0",
@@ -1,10 +1,12 @@
import { Injectable, computed, inject, signal } from '@angular/core'; import { Injectable, computed, inject, signal } from '@angular/core';
import type { SavedConnection, SshConfigHost, Stack } from '@shared/types'; import type { SavedConnection, SshConfigHost, Stack } from '@shared/types';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
import { I18nService } from './i18n.service';
import { ToastService } from './toast.service'; import { ToastService } from './toast.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ConnectionsService { export class ConnectionsService {
private readonly i18n = inject(I18nService);
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService); private readonly toast = inject(ToastService);
@@ -86,11 +88,10 @@ export class ConnectionsService {
return; return;
} }
if (res.connections || res.stacks || res.skipped) { if (res.connections || res.stacks || res.skipped) {
const skipped = res.skipped ? ` (${res.skipped} skipped)` : ''; const skipped = res.skipped ? this.i18n.t('toasts.importedSkipped', { skipped: res.skipped }) : '';
const stacks = res.stacks ? this.i18n.t('toasts.importedIntoStacks', { count: res.stacks }) : '';
this.toast.success( this.toast.success(
`Imported ${res.connections} connection${res.connections === 1 ? '' : 's'}` + this.i18n.t('toasts.imported', { count: res.connections }) + stacks + skipped,
(res.stacks ? ` into ${res.stacks} new stack${res.stacks === 1 ? '' : 's'}` : '') +
skipped,
); );
} }
await this.reload(); await this.reload();
+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). */
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. Win7 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 dist/api/bun/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 a Win7 sin, 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,11 +1,13 @@
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';
import { IS_WINDOWS } from './platform'; import { IS_WINDOWS } from './platform';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
import { SettingsService } from './settings.service'; import { SettingsService } from './settings.service';
import { I18nService } from './i18n.service';
import { ToastService } from './toast.service'; import { ToastService } from './toast.service';
/** The row (or empty space) the user last right-clicked; ctx-* actions apply here. */ /** The row (or empty space) the user last right-clicked; ctx-* actions apply here. */
@@ -21,30 +23,35 @@ export interface MenuTarget {
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class FileActionsService { export class FileActionsService {
private readonly i18n = inject(I18nService);
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> {
const res = await this.rpc.request.moveToTrash({ path: entry.path }); const res = await this.rpc.request.moveToTrash({ path: entry.path });
if (!res.ok) this.toast.error(res.error); if (!res.ok) this.toast.error(res.error);
else this.toast.info(`Moved "${entry.name}" to Trash`); else this.toast.info(this.i18n.t('toasts.movedToTrash', { name: entry.name }));
await panel.refresh(); await panel.refresh();
} }
@@ -58,9 +65,9 @@ export class FileActionsService {
mkdir(panel: PanelState): void { mkdir(panel: PanelState): void {
this.dialogs.prompt.set({ this.dialogs.prompt.set({
title: 'New folder', title: this.i18n.t('fileActions.newFolderTitle'),
placeholder: 'folder name', placeholder: this.i18n.t('fileActions.newFolderPlaceholder'),
confirmLabel: 'Create', confirmLabel: this.i18n.t('fileActions.createConfirm'),
onConfirm: async (name) => { onConfirm: async (name) => {
const res = await this.rpc.request.mkdir({ const res = await this.rpc.request.mkdir({
sessionId: panel.sessionId(), sessionId: panel.sessionId(),
@@ -74,9 +81,9 @@ export class FileActionsService {
createFile(panel: PanelState): void { createFile(panel: PanelState): void {
this.dialogs.prompt.set({ this.dialogs.prompt.set({
title: 'New file', title: this.i18n.t('fileActions.newFileTitle'),
placeholder: 'file name', placeholder: this.i18n.t('fileActions.newFilePlaceholder'),
confirmLabel: 'Create', confirmLabel: this.i18n.t('fileActions.createConfirm'),
onConfirm: async (name) => { onConfirm: async (name) => {
const res = await this.rpc.request.createFile({ const res = await this.rpc.request.createFile({
sessionId: panel.sessionId(), sessionId: panel.sessionId(),
@@ -101,7 +108,7 @@ export class FileActionsService {
if (!res.ok) { if (!res.ok) {
this.toast.error(res.error); this.toast.error(res.error);
} else if (res.watching) { } else if (res.watching) {
this.toast.info(`Editing ${entry.name} — saves upload automatically`); this.toast.info(this.i18n.t('toasts.editingExternal', { name: entry.name }));
} }
return; return;
} }
@@ -120,9 +127,9 @@ export class FileActionsService {
const entry = panel.sorted()[panel.cursor()]; const entry = panel.sorted()[panel.cursor()];
if (!entry || entry.name === '..') return; if (!entry || entry.name === '..') return;
this.dialogs.prompt.set({ this.dialogs.prompt.set({
title: `Rename "${entry.name}"`, title: this.i18n.t('fileActions.renameTitle', { name: entry.name }),
initial: entry.name, initial: entry.name,
confirmLabel: 'Rename', confirmLabel: this.i18n.t('fileActions.renameConfirm'),
onConfirm: async (name) => { onConfirm: async (name) => {
if (name === entry.name) return; if (name === entry.name) return;
const res = await this.rpc.request.rename({ const res = await this.rpc.request.rename({
@@ -140,11 +147,13 @@ export class FileActionsService {
const targets = this.targets(panel); const targets = this.targets(panel);
if (!targets.length) return; if (!targets.length) return;
const what = const what =
targets.length === 1 ? `"${targets[0].name}"` : `${targets.length} items`; targets.length === 1
? `"${targets[0].name}"`
: this.i18n.t('fileActions.itemsCount', { count: targets.length });
this.dialogs.confirm.set({ this.dialogs.confirm.set({
title: 'Delete', title: this.i18n.t('fileActions.deleteTitle'),
body: `Delete ${what}? Folders are deleted recursively. This cannot be undone.`, body: this.i18n.t('fileActions.deleteBody', { what }),
confirmLabel: 'Delete', confirmLabel: this.i18n.t('fileActions.deleteConfirm'),
destructive: true, destructive: true,
onConfirm: async () => { onConfirm: async () => {
const res = await this.rpc.request.deleteEntries({ const res = await this.rpc.request.deleteEntries({
@@ -64,22 +64,25 @@ function categoryOf(name: string): Category | null {
return EXT_CATEGORY.get(key) ?? null; return EXT_CATEGORY.get(key) ?? null;
} }
const KIND_LABELS: Record<Category, string> = { const KIND_KEYS: Record<Category, string> = {
image: 'Image', image: 'image',
audio: 'Audio', audio: 'audio',
video: 'Video', video: 'video',
archive: 'Archive', archive: 'archive',
code: 'Code', code: 'code',
doc: 'Document', doc: 'document',
}; };
/** Human label for the optional Kind column (category-based, like the icons). */ /**
export function fileKind(entry: FileEntry): string { * i18n key suffix for the optional Kind column (category-based, like the
* icons); '' for the synthetic '..' row. The caller resolves `kinds.<suffix>`.
*/
export function fileKindKey(entry: FileEntry): string {
if (entry.name === '..') return ''; if (entry.name === '..') return '';
if (entry.type === 'dir') return 'Folder'; if (entry.type === 'dir') return 'folder';
if (entry.type === 'symlink') return 'Symlink'; if (entry.type === 'symlink') return 'symlink';
const category = categoryOf(entry.name); const category = categoryOf(entry.name);
return category ? KIND_LABELS[category] : 'File'; return category ? KIND_KEYS[category] : 'file';
} }
export function fileGlyph(entry: FileEntry, multi: boolean): string { export function fileGlyph(entry: FileEntry, multi: boolean): string {
@@ -1,34 +1,51 @@
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB']; const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
export function formatSize(bytes: number): string { /** Locale-aware number formatting (decimal separator); cached per locale. */
const NUM_FORMATS = new Map<string, Intl.NumberFormat>();
function num(value: number, locale: string | undefined): string {
const key = locale ?? '';
let fmt = NUM_FORMATS.get(key);
if (!fmt) {
fmt = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 });
NUM_FORMATS.set(key, fmt);
}
return fmt.format(value);
}
export function formatSize(bytes: number, locale?: string): string {
let value = bytes; let value = bytes;
let unit = 0; let unit = 0;
while (value >= 1024 && unit < SIZE_UNITS.length - 1) { while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
value /= 1024; value /= 1024;
unit++; unit++;
} }
return `${unit === 0 ? value : value.toFixed(1)} ${SIZE_UNITS[unit]}`; return `${unit === 0 ? num(value, locale) : num(Number(value.toFixed(1)), locale)} ${SIZE_UNITS[unit]}`;
} }
const DATE_FORMAT = new Intl.DateTimeFormat(undefined, { const DATE_FORMATS = new Map<string, Intl.DateTimeFormat>();
year: '2-digit', export function formatDate(epochMs: number, locale?: string): string {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
export function formatDate(epochMs: number): string {
if (!epochMs) return ''; if (!epochMs) return '';
return DATE_FORMAT.format(new Date(epochMs)); const key = locale ?? '';
let fmt = DATE_FORMATS.get(key);
if (!fmt) {
fmt = new Intl.DateTimeFormat(locale, {
year: '2-digit',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
DATE_FORMATS.set(key, fmt);
}
return fmt.format(new Date(epochMs));
} }
/** bytes/sec -> "14.2 MB/s" or "113.8 Mbps" depending on the configured unit. */ /** bytes/sec -> "14.2 MB/s" or "113.8 Mbps" depending on the configured unit. */
export function formatSpeed(bytesPerSec: number, unit: 'MBps' | 'Mbps'): string { export function formatSpeed(bytesPerSec: number, unit: 'MBps' | 'Mbps', locale?: string): string {
if (unit === 'Mbps') { if (unit === 'Mbps') {
return `${((bytesPerSec * 8) / 1_000_000).toFixed(1)} Mbps`; return `${num(Number(((bytesPerSec * 8) / 1_000_000).toFixed(1)), locale)} Mbps`;
} }
return `${formatSize(bytesPerSec)}/s`; return `${formatSize(bytesPerSec, locale)}/s`;
} }
/** Seconds -> "0:42" / "3:07" / "1:02:15". */ /** Seconds -> "0:42" / "3:07" / "1:02:15". */
+61
View File
@@ -0,0 +1,61 @@
import { Injectable, computed, signal } from '@angular/core';
import { DICTS, type Messages } from '@shared/i18n';
import type { AppSettings, SupportedLocale } from '@shared/types';
import { formatDate, formatSize, formatSpeed } from './format';
type Params = Record<string, string | number>;
function lookup(dict: Messages, key: string): string | Messages | undefined {
let node: string | Messages | undefined = dict;
for (const part of key.split('.')) {
if (typeof node !== 'object') return undefined;
node = node[part];
}
return node;
}
function interpolate(template: string, params?: Params): string {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, name: string) =>
name in params ? String(params[name]) : `{${name}}`,
);
}
/**
* Runtime translator. `t()` reads the `locale` signal, so any template binding
* that calls it re-renders live on a language switch (the same reactive
* function-call tracking the file-list icon classes rely on). Missing keys fall
* back to English, then to the raw key so gaps are visible in dev.
*/
@Injectable({ providedIn: 'root' })
export class I18nService {
readonly locale = signal<SupportedLocale>('en');
private readonly dict = computed(() => DICTS[this.locale()]);
t(key: string, params?: Params): string {
let value = lookup(this.dict(), key) ?? lookup(DICTS.en, key);
// plural map: pick the CLDR category for params.count (fallback to English)
if (value && typeof value === 'object') {
const count = Number(params?.['count'] ?? NaN);
if (Number.isFinite(count)) {
const rule = new Intl.PluralRules(this.locale()).select(count);
const picked = value[rule] ?? value['other'] ?? lookup(DICTS.en, `${key}.other`);
value = typeof picked === 'string' ? picked : undefined;
} else {
value = undefined;
}
}
return typeof value === 'string' ? interpolate(value, params) : key;
}
// Locale-reactive formatting (live-updates the file list on switch).
date(epochMs: number): string {
return formatDate(epochMs, this.locale());
}
size(bytes: number): string {
return formatSize(bytes, this.locale());
}
speed(bytesPerSec: number, unit: AppSettings['speedUnit']): string {
return formatSpeed(bytesPerSec, unit, this.locale());
}
}
@@ -15,6 +15,7 @@ import {
mynaCode, mynaCode,
mynaCog, mynaCog,
mynaDots, mynaDots,
mynaDownload,
mynaEdit, mynaEdit,
mynaFile, mynaFile,
mynaFilePlus, mynaFilePlus,
@@ -51,6 +52,7 @@ export const APP_ICONS = {
mynaCode, mynaCode,
mynaCog, mynaCog,
mynaDots, mynaDots,
mynaDownload,
mynaEdit, mynaEdit,
mynaFile, mynaFile,
mynaFilePlus, mynaFilePlus,
+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);
}
@@ -1,9 +1,10 @@
import { Injectable, computed, inject, signal, type WritableSignal } from '@angular/core'; import { Injectable, computed, effect, inject, signal, type WritableSignal } from '@angular/core';
import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types'; import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
import { fileKind } from './file-icons'; import { fileKindKey } from './file-icons';
import { FocusService } from './focus.service'; import { FocusService } from './focus.service';
import { formatMode, isRootPath, joinPath } from './format'; import { formatMode, isRootPath, joinPath } from './format';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
import { I18nService } from './i18n.service';
import { ToastService } from './toast.service'; import { ToastService } from './toast.service';
export type Side = 'left' | 'right'; export type Side = 'left' | 'right';
@@ -12,7 +13,7 @@ export type SortKey = 'name' | ColumnKey;
/** Display string the text-based columns sort by. */ /** Display string the text-based columns sort by. */
function sortText(entry: FileEntry, key: 'kind' | 'rights' | 'owner' | 'group'): string { function sortText(entry: FileEntry, key: 'kind' | 'rights' | 'owner' | 'group'): string {
if (key === 'kind') return fileKind(entry); if (key === 'kind') return fileKindKey(entry);
if (key === 'rights') return formatMode(entry.mode); if (key === 'rights') return formatMode(entry.mode);
return (key === 'owner' ? entry.owner : entry.group) ?? ''; return (key === 'owner' ? entry.owner : entry.group) ?? '';
} }
@@ -108,19 +109,34 @@ export class PanelState {
} }
} }
/** Reload cwd, keeping cursor position and selection by name. */ private refreshing = false;
async refresh(): Promise<void> {
/**
* Reload cwd, keeping cursor position and selection by name. `silent` (used by
* auto-refresh) skips the loading/slowLoad flags — so no spinner, no dim
* overlay, input never blocked — and touches no DOM, so scroll stays put.
*/
async refresh(opts?: { silent?: boolean }): Promise<void> {
const silent = opts?.silent ?? false;
// a silent refresh must never fight the user or overlap itself
if (silent && (this.refreshing || this.busy())) return;
this.refreshing = true;
// snapshot what we're listing; a silent refresh has no busy-block, so the
// user can navigate mid-flight — discard the result if they did
const forSession = this.sessionId();
const forDir = this.cwd();
const keepCursorName = this.sorted()[this.cursor()]?.name; const keepCursorName = this.sorted()[this.cursor()]?.name;
const keepSelected = this.selected(); const keepSelected = this.selected();
this.loading.set(true); if (!silent) this.loading.set(true);
const slowTimer = setTimeout(() => this.slowLoad.set(true), 300); const slowTimer = silent ? null : setTimeout(() => this.slowLoad.set(true), 300);
try { try {
const res = await this.deps.rpc.request.listDir({ const res = await this.deps.rpc.request.listDir({
sessionId: this.sessionId(), sessionId: forSession,
path: this.cwd(), path: forDir,
}); });
if (this.sessionId() !== forSession || this.cwd() !== forDir) return;
if (!res.ok) { if (!res.ok) {
this.deps.toast.error(res.error); if (!silent) this.deps.toast.error(res.error);
return; return;
} }
this.cwd.set(res.path); this.cwd.set(res.path);
@@ -130,9 +146,12 @@ export class PanelState {
const idx = this.sorted().findIndex((e) => e.name === keepCursorName); const idx = this.sorted().findIndex((e) => e.name === keepCursorName);
this.cursor.set(idx >= 0 ? idx : Math.min(this.cursor(), Math.max(0, this.sorted().length - 1))); this.cursor.set(idx >= 0 ? idx : Math.min(this.cursor(), Math.max(0, this.sorted().length - 1)));
} finally { } finally {
clearTimeout(slowTimer); if (slowTimer) clearTimeout(slowTimer);
this.slowLoad.set(false); if (!silent) {
this.loading.set(false); this.slowLoad.set(false);
this.loading.set(false);
}
this.refreshing = false;
} }
} }
@@ -207,6 +226,7 @@ export interface Workspace {
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PanelsService { export class PanelsService {
private readonly i18n = inject(I18nService);
readonly workspaces = signal<Workspace[]>([]); readonly workspaces = signal<Workspace[]>([]);
readonly activeWorkspaceId = signal(0); readonly activeWorkspaceId = signal(0);
readonly activeWorkspace = computed(() => { readonly activeWorkspace = computed(() => {
@@ -230,19 +250,70 @@ export class PanelsService {
for (const workspace of this.workspaces()) { for (const workspace of this.workspaces()) {
for (const panel of [workspace.left, workspace.right]) { for (const panel of [workspace.left, workspace.right]) {
if (panel.sessionId() === event.sessionId) { if (panel.sessionId() === event.sessionId) {
this.toast.error(`${panel.title()}: disconnected (${event.reason})`); this.toast.error(
this.i18n.t('toasts.disconnectedReason', {
title: panel.title(),
reason: event.reason,
}),
);
this.resetToLocal(panel); this.resetToLocal(panel);
} }
} }
} }
}); });
// an editor window saved a file — refresh panels showing that directory // a watched local dir changed, or an editor saved a file — silently
// refresh every panel showing that directory (no spinner, view preserved)
this.rpc.onPathRefresh(({ sessionId, dir }) => { this.rpc.onPathRefresh(({ sessionId, dir }) => {
for (const panel of this.allPanels()) { for (const panel of this.allPanels()) {
if (panel.sessionId() === sessionId && panel.cwd() === dir) void panel.refresh(); if (panel.sessionId() === sessionId && panel.cwd() === dir) {
void panel.refresh({ silent: true });
}
} }
}); });
this.setupAutoRefresh();
}
/**
* Auto-refresh the active tab's panels without disturbing the view. Local
* panels are watched instantly via fs.watch (backend pushes pathRefresh);
* remote panels have no change notification, so they're polled. Only the
* active tab is watched/polled; switching tabs refreshes the new one at once.
*/
private setupAutoRefresh(): void {
// REMOTE_POLL_MS: also the cadence a future "auto-refresh interval" setting
// would drive (see the commented breadcrumbs in settings).
const REMOTE_POLL_MS = 3000;
setInterval(() => {
const ws = this.activeWorkspace();
if (!ws) return;
for (const panel of [ws.left, ws.right]) {
if (panel.sessionId() !== 'local') void panel.refresh({ silent: true });
}
}, REMOTE_POLL_MS);
// register the active tab's LOCAL dirs for fs.watch; re-runs when the active
// tab changes or either panel navigates/(dis)connects
effect(() => {
const ws = this.activeWorkspace();
const dirs = ws
? [ws.left, ws.right]
.filter((p) => p.sessionId() === 'local' && p.cwd())
.map((p) => p.cwd())
: [];
void this.rpc.request.setWatchedDirs({ dirs: [...new Set(dirs)] });
});
// switching tabs shows fresh data immediately instead of waiting a poll tick
let lastActive = this.activeWorkspaceId();
effect(() => {
const id = this.activeWorkspaceId();
if (id === lastActive) return;
lastActive = id;
const ws = this.activeWorkspace();
if (ws) for (const panel of [ws.left, ws.right]) void panel.refresh({ silent: true });
});
} }
// ---- panels of the active workspace ---- // ---- panels of the active workspace ----
@@ -327,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);
@@ -389,7 +500,7 @@ export class PanelsService {
const remoteTitles = [workspace.left, workspace.right] const remoteTitles = [workspace.left, workspace.right]
.filter((p) => p.sessionId() !== 'local') .filter((p) => p.sessionId() !== 'local')
.map((p) => p.title()); .map((p) => p.title());
return remoteTitles.length ? remoteTitles.join(' ⇄ ') : 'Local'; return remoteTitles.length ? remoteTitles.join(' ⇄ ') : this.i18n.t('common.local');
} }
workspaceIsRemote(workspace: Workspace): boolean { workspaceIsRemote(workspace: Workspace): boolean {
+67
View File
@@ -0,0 +1,67 @@
// Injected by the bun process via BrowserWindow preload (src/bun/windows.ts
// buildPreload) before any page script. Brand-neutral names so rebrands never
// touch this file.
import type { EditorOpenContext } from '@shared/rpc';
declare global {
interface Window {
__APP_PLATFORM?: string;
/** Which root component this window boots. */
__APP_MODE?: 'main' | 'editor' | 'manager';
/** Which skin tree this window boots. Baked into the preload because
* settings resolve too late to decide layout without a flash. */
__APP_SKIN?: 'modern' | 'win7';
/** Window-kind specific context (EditorOpenContext for editor windows). */
__APP_CTX?: unknown;
}
}
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
export const IS_MAC = PLATFORM === 'darwin';
export const IS_WINDOWS = PLATFORM === 'win32';
export const IS_LINUX = PLATFORM === 'linux';
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. Keyed on the real
* platform — the keyboard does not change with the window chrome. */
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
export const APP_MODE: 'main' | 'editor' | 'manager' =
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
export const EDITOR_CONTEXT =
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
/**
* Which skin tree this window booted. Read from the preload rather than from
* SettingsService because both are needed before first paint — main.ts picks a
* root component from it, and it must not change under a running window (the
* native frame is fixed at BrowserWindow construction).
*/
export const APP_SKIN: 'modern' | 'win7' =
typeof window !== 'undefined' ? (window.__APP_SKIN ?? 'modern') : 'modern';
export const IS_WIN7_CHROME = APP_SKIN === 'win7';
/**
* True when macOS traffic lights overlay the app's own header row — the fused
* `hiddenInset` layout, which reserves `pl-24` for them. False under the win7
* skin even on macOS: `titleBarStyle: 'hidden'` removes the traffic lights
* altogether, so reserving space would leave a gap. Only the shadlike tree
* reads this; 7like draws its own title bar and never defers to native chrome.
*/
export const IS_MAC_CHROME = IS_MAC && !IS_WIN7_CHROME;
/**
* 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
* system text menu (cut/copy/paste, spelling).
*/
export function blockNativeContextMenu(event: Event): void {
const target = event.target as HTMLElement | null;
if (target?.closest('input, textarea, [contenteditable="true"]')) return;
event.preventDefault();
}
@@ -3,6 +3,13 @@ import { Electroview } from 'electrobun/view';
import type { AppRPC } from '@shared/rpc'; import type { AppRPC } from '@shared/rpc';
import type { AppSettings, TransferSnapshot } from '@shared/types'; import type { AppSettings, TransferSnapshot } from '@shared/types';
export type UpdateStatus = {
phase: 'available' | 'downloading' | 'error';
version?: string;
progress?: number;
error?: string;
};
// Matches MAX_REQUEST_TIME_MS on the bun side — SFTP ops can be slow. // Matches MAX_REQUEST_TIME_MS on the bun side — SFTP ops can be slow.
const MAX_REQUEST_TIME_MS = 10 * 60_000; const MAX_REQUEST_TIME_MS = 10 * 60_000;
@@ -24,6 +31,8 @@ export class RpcService {
private editorCloseListeners = new Set<() => void>(); private editorCloseListeners = new Set<() => void>();
private managerStateListeners = new Set<(open: boolean) => void>(); private managerStateListeners = new Set<(open: boolean) => void>();
private connectionsListeners = new Set<() => void>(); private connectionsListeners = new Set<() => void>();
private updateStatusListeners = new Set<(status: UpdateStatus) => void>();
private windowStateListeners = new Set<(maximized: boolean) => void>();
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined; private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
private ready: Promise<void> = Promise.resolve(); private ready: Promise<void> = Promise.resolve();
@@ -60,6 +69,12 @@ export class RpcService {
connectionsChanged: () => { connectionsChanged: () => {
for (const listener of this.connectionsListeners) listener(); for (const listener of this.connectionsListeners) listener();
}, },
updateStatus: (status) => {
for (const listener of this.updateStatusListeners) listener(status);
},
windowState: ({ maximized }) => {
for (const listener of this.windowStateListeners) listener(maximized);
},
}, },
}, },
}); });
@@ -88,6 +103,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);
} }
@@ -108,6 +132,16 @@ export class RpcService {
this.connectionsListeners.add(listener); this.connectionsListeners.add(listener);
} }
onUpdateStatus(listener: (status: UpdateStatus) => void): void {
this.updateStatusListeners.add(listener);
}
/** win7 skin: the frameless window's maximize state changed (derived from
* the native resize event, so Aero Snap and Win+Up land here too). */
onWindowState(listener: (maximized: boolean) => void): void {
this.windowStateListeners.add(listener);
}
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */ /** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
get request() { get request() {
const rpc = this.rpc; const rpc = this.rpc;
@@ -1,23 +1,33 @@
import { Injectable, computed, inject, signal } from '@angular/core'; import { Injectable, computed, inject, signal } from '@angular/core';
import { DEFAULT_SETTINGS, type AppSettings, type ColumnKey } from '@shared/types'; import { resolveLocale } from '@shared/i18n';
import { clampUiScale, DEFAULT_SETTINGS, type AppSettings, type ColumnKey } from '@shared/types';
import { I18nService } from './i18n.service';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SettingsService { export class SettingsService {
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
readonly skin = signal<AppSettings['skin']>(DEFAULT_SETTINGS.skin);
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 accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
readonly iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors); 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);
readonly editor = signal<AppSettings['editor']>(DEFAULT_SETTINGS.editor); readonly editor = signal<AppSettings['editor']>(DEFAULT_SETTINGS.editor);
readonly editorApp = signal(DEFAULT_SETTINGS.editorApp); readonly editorApp = signal(DEFAULT_SETTINGS.editorApp);
// Auto-refresh is always on for now (see panels.service). To make it
// user-controllable: uncomment the field in types.ts, then here add
// readonly autoRefresh = signal(DEFAULT_SETTINGS.autoRefresh);
// load it in the constructor, add setAutoRefresh(), include it in settings(),
// gate the poll/watch in panels.service on it, and add the tab-bar row below.
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
private readonly i18n = inject(I18nService);
/** Whether the app is actually rendering dark right now (drives the Tone row). */ /** Whether the app is actually rendering dark right now (drives the Tone row). */
readonly effectiveDark = computed( readonly effectiveDark = computed(
@@ -40,22 +50,58 @@ export class SettingsService {
} }
private applyAll(settings: AppSettings): void { private applyAll(settings: AppSettings): void {
this.locale.set(settings.locale);
this.skin.set(settings.skin);
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.accent.set(settings.accent);
this.iconColors.set(settings.iconColors); 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);
this.editor.set(settings.editor); this.editor.set(settings.editor);
this.editorApp.set(settings.editorApp); this.editorApp.set(settings.editorApp);
// NO applySkin here, deliberately — see setSkin. applyAll also runs on the
// settingsChanged push, so flipping the class here would break every OPEN
// editor and manager window too, not just the one that changed the setting.
this.applyTheme(); this.applyTheme();
this.applyDarkTone(); this.applyDarkTone();
this.applyAccent(); this.applyAccent();
this.applyTextSize(); this.applyUiScale();
this.applyLocale();
}
setLocale(locale: AppSettings['locale']): void {
this.locale.set(locale);
this.applyLocale();
void this.persist();
}
/**
* Saves the preference and NOTHING else — the skin is a launch-time choice.
*
* It deliberately does not touch the `.win7` class, which is the whole bug
* this replaced: each skin's CSS is written against its OWN component tree's
* markup, and the tree is fixed at bootstrap from the preload-baked
* __APP_SKIN. Flipping the class live therefore painted 7.css onto shadlike
* markup that has no .window.glass, no .title-bar, no menu[role=tablist] and
* no <table> ListView — a broken window, not a reskinned one.
*
* A reload would not have helped either: the preload string is frozen at
* webview construction (a persistent WebKitUserScript), and titleBarStyle /
* transparent have no setters, so the native frame cannot change on a live
* window. win7 -> modern without a reframe is the dangerous direction — the
* modern tree draws no title bar, so it would land in a frameless window
* with no way to move, minimize or close it.
*
* The options row shows the saved value as pending until the next start.
*/
setSkin(skin: AppSettings['skin']): void {
this.skin.set(skin);
void this.persist();
} }
setTheme(theme: AppSettings['theme']): void { setTheme(theme: AppSettings['theme']): void {
@@ -94,9 +140,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();
} }
@@ -125,6 +171,12 @@ export class SettingsService {
void this.persist(); void this.persist();
} }
// There is deliberately no applySkin(). The `.win7` class is written once by
// buildPreload at DOCUMENT_START for the skin this window actually booted,
// and must never change afterwards — see setSkin for why. Every other apply*
// below is safe to run live because it only re-points tokens, and both skins
// consume the same token system.
private applyTheme(): void { private applyTheme(): void {
const theme = this.theme(); const theme = this.theme();
if (theme === 'system') { if (theme === 'system') {
@@ -152,24 +204,35 @@ export class SettingsService {
} }
} }
private applyTextSize(): void { /** The whole interface scale is ONE declaration: every size in the app is
const size = this.textSize(); * rem, so moving the root font-size moves both skins in lockstep. 1 = the
if (size === 'default') { * browser-default 16px root; delete-when-default keeps the pristine state
delete document.documentElement.dataset['text']; * 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['text'] = size; document.documentElement.style.fontSize = `${16 * scale}px`;
} }
} }
private applyLocale(): void {
this.i18n.locale.set(resolveLocale(this.locale(), navigator.language));
}
protected settings(): AppSettings { protected settings(): AppSettings {
return { return {
locale: this.locale(),
skin: this.skin(),
theme: this.theme(), theme: this.theme(),
darkTone: this.darkTone(), darkTone: this.darkTone(),
accent: this.accent(), accent: this.accent(),
iconColors: this.iconColors(), 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(),
+111
View File
@@ -0,0 +1,111 @@
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
// 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' answers 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';
/**
* 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') return null; // any-window shortcuts only
if (scope === 'main') {
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;
}
}
@@ -4,6 +4,7 @@ import { DialogsService } from './dialogs.service';
import { PanelsService, type Side } from './panels.service'; import { PanelsService, type Side } from './panels.service';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
import { SettingsService } from './settings.service'; import { SettingsService } from './settings.service';
import { I18nService } from './i18n.service';
import { ToastService } from './toast.service'; import { ToastService } from './toast.service';
const AUTO_DISMISS_COMPLETED_MS = 5000; const AUTO_DISMISS_COMPLETED_MS = 5000;
@@ -13,6 +14,7 @@ const SPEED_SMOOTHING = 0.7;
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class TransfersService { export class TransfersService {
private readonly i18n = inject(I18nService);
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
private readonly panels = inject(PanelsService); private readonly panels = inject(PanelsService);
private readonly toast = inject(ToastService); private readonly toast = inject(ToastService);
@@ -53,19 +55,19 @@ export class TransfersService {
(e) => e.type !== 'symlink' && e.name !== '..', (e) => e.type !== 'symlink' && e.name !== '..',
); );
if (!targets.length) { if (!targets.length) {
this.toast.info('Nothing to copy (symlinks are skipped)'); this.toast.info(this.i18n.t('toasts.nothingToCopy'));
return; return;
} }
const sameFs = src.fsKey() === dst.fsKey(); const sameFs = src.fsKey() === dst.fsKey();
if (sameFs && src.cwd() === dst.cwd()) { if (sameFs && src.cwd() === dst.cwd()) {
this.toast.info('Both panels show the same folder'); this.toast.info(this.i18n.t('toasts.sameFolder'));
return; return;
} }
const finalTargets = await this.resolveCollisions(targets, dst.entries(), sameFs); const finalTargets = await this.resolveCollisions(targets, dst.entries(), sameFs);
if (finalTargets === null) return; // user cancelled if (finalTargets === null) return; // user cancelled
if (!finalTargets.length) { if (!finalTargets.length) {
this.toast.info('Nothing to copy — all conflicting items were skipped'); this.toast.info(this.i18n.t('toasts.nothingToCopyAllSkipped'));
return; return;
} }
@@ -105,11 +107,11 @@ export class TransfersService {
} }
if (sameFs && dstEntry.path === target.path) { if (sameFs && dstEntry.path === target.path) {
// replacing a file with ITSELF would only destroy it — never offer that // replacing a file with ITSELF would only destroy it — never offer that
this.toast.info(`Skipped "${target.name}" — it is the same file`); this.toast.info(this.i18n.t('toasts.sameFile', { name: target.name }));
continue; continue;
} }
if ((dstEntry.type === 'dir') !== (target.type === 'dir')) { if ((dstEntry.type === 'dir') !== (target.type === 'dir')) {
this.toast.info(`Skipped "${target.name}" — a different kind of item is in the way`); this.toast.info(this.i18n.t('toasts.typeMismatch', { name: target.name }));
continue; continue;
} }
if (mode === 'all') { if (mode === 'all') {
@@ -178,7 +180,12 @@ export class TransfersService {
} }
} else if (snapshot.state === 'failed') { } else if (snapshot.state === 'failed') {
this.expanded.set(true); this.expanded.set(true);
this.toast.error(`Transfer "${snapshot.name}" failed: ${snapshot.error ?? 'unknown error'}`); this.toast.error(
this.i18n.t('toasts.transferFailed', {
name: snapshot.name,
error: snapshot.error ?? this.i18n.t('toasts.unknownError'),
}),
);
this.refreshPanelsOn(snapshot.dstSessionId); this.refreshPanelsOn(snapshot.dstSessionId);
} else if (snapshot.state === 'cancelled') { } else if (snapshot.state === 'cancelled') {
this.refreshPanelsOn(snapshot.dstSessionId); this.refreshPanelsOn(snapshot.dstSessionId);
+57
View File
@@ -0,0 +1,57 @@
import { Injectable, inject, signal } from '@angular/core';
import { RpcService } from './rpc.service';
export type UpdaterState = 'idle' | 'available' | 'downloading' | 'ready' | 'error';
/**
* Drives the tab-bar update pill. The backend pushes updateStatus events
* (available / downloading progress / error); "ready" is derived locally when
* downloadUpdate resolves ok. Two-step UX: available -> download -> ready ->
* apply (relaunch).
*/
@Injectable({ providedIn: 'root' })
export class UpdaterService {
private rpc = inject(RpcService);
readonly state = signal<UpdaterState>('idle');
readonly version = signal('');
readonly progress = signal(0);
constructor() {
if (!this.rpc.available) return;
this.rpc.onUpdateStatus((s) => {
if (s.phase === 'available') {
this.version.set(s.version ?? '');
this.state.set('available');
} else if (s.phase === 'downloading') {
this.state.set('downloading');
this.progress.set(s.progress ?? 0);
} else if (s.phase === 'error') {
this.state.set('error');
}
});
}
async download(): Promise<void> {
if (this.state() !== 'available') return;
this.state.set('downloading');
this.progress.set(0);
const r = await this.rpc.request.downloadUpdate({});
this.state.set(r?.ok ? 'ready' : 'error');
}
apply(): void {
if (this.state() !== 'ready') return;
void this.rpc.request.applyUpdate({}); // quits + relaunches
}
/** Manual re-check (menu item). */
async check(): Promise<void> {
if (!this.rpc.available) return;
const r = await this.rpc.request.checkForUpdate({});
if (r?.ok && r.updateAvailable) {
this.version.set(r.version);
this.state.set('available');
}
}
}
-42
View File
@@ -1,42 +0,0 @@
// Injected by the bun process via BrowserWindow preload (src/bun/windows.ts
// buildPreload) before any page script. Brand-neutral names so rebrands never
// touch this file.
import type { EditorOpenContext } from '@shared/rpc';
declare global {
interface Window {
__APP_PLATFORM?: string;
/** Which root component this window boots. */
__APP_MODE?: 'main' | 'editor' | 'manager';
/** Window-kind specific context (EditorOpenContext for editor windows). */
__APP_CTX?: unknown;
}
}
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_CHROME = PLATFORM === 'darwin';
export const IS_WINDOWS = PLATFORM === 'win32';
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
export const MOD_LABEL = IS_MAC_CHROME ? '⌘' : 'Ctrl+';
export const ALT_LABEL = IS_MAC_CHROME ? '⌥' : 'Alt+';
export const APP_MODE: 'main' | 'editor' | 'manager' =
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
export const EDITOR_CONTEXT =
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
/**
* 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
* system text menu (cut/copy/paste, spelling).
*/
export function blockNativeContextMenu(event: Event): void {
const target = event.target as HTMLElement | null;
if (target?.closest('input, textarea, [contenteditable="true"]')) return;
event.preventDefault();
}
+840
View File
@@ -0,0 +1,840 @@
/* The SCOPED 7.css build: postcss-prefix-selector rewrote every selector to
`.win7` (root rules) or `.win7 <sel>` (the rest), so the whole framework is
inert until the `.win7` class is on <html> — written once by buildPreload
for the skin this window booted, and never changed after. All 61
icons are data URIs, so nothing needs copying into assets — and NOT the
`7.inline.css` build, which flattens away the --w7-* vars the dark palette
below depends on. The deep path is required: 7.css ships no `exports` map
and Tailwind's resolver only reads `style`/`mainFields`, so a bare
`@import "7.css"` finds no entry. */
@import "7.css/dist/7.scoped.css" layer(skin);
/* ==========================================================================
Windows 7 skin — buildPreload puts `.win7` on <html> at first paint
==========================================================================
Three tiers, and the split matters:
- `@layer skin` (the 7.css import at the top of this file) owns the
CONTROLS: buttons, inputs, tabs, progress bars, tree views, the Aero
title bar. It sits ABOVE `utilities`, so a bare `button` rule in 7.css
beats `.rounded-md` on that same button. That inversion is the whole
trick — it restyles ~1900 inline utility classes with no template edits.
- This unlayered section owns the BRIDGE: it re-points the app's own
semantic tokens at Win7 values, so utilities Tailwind still controls
(bg-card, border-border, text-muted-foreground) also come out
Win7-correct. It adds NO new tokens on purpose — a new colour token
would need a matching `--color-*` line in `@theme inline`.
- `@layer skinfix` (last, above `skin`) hands individual properties BACK to
the utilities layer with `revert-layer`, for the places where 7.css's
Win7-dialog metrics are wrong for this app.
WARNING for future edits: because `skin` outranks `utilities`, any Tailwind
utility added later to a <button>, <input>, <select>, <textarea> or <table>
silently loses under this skin. `skinfix` is the only place to fix that.
Specificity notes — the blocks below must beat the token blocks above:
:root.win7 (0,2,0) beats base :root (0,1,0)
:root.win7[data-theme='dark'] (0,3,0) beats :root[data-theme=…] (0,2,0)
and ties the darker-tone block (0,3,0),
winning on source order (it is last).
The accent formula blocks are deliberately NOT overridden: they only set
--primary/--ring/--cursor-tint, and letting them through is what makes the
accent picker recolour the Aero chrome — authentic, Win7 shipped exactly
that ("Window Color" personalization). */
/* Segoe UI is not on macOS/Linux and is not redistributable. Noto Sans is the
substitute 7.css itself names in its --w7-font fallback stack. Vendored
(OFL-1.1) from @fontsource/noto-sans@5.3.0, files/noto-sans-latin-{400,700}
-normal.woff2 -> ./fonts/, i.e. INSIDE this skin: the font exists only for
the Win7 look, and keeping it here means the url() below stays relative to
this file wherever the skin folder moves. (It used to sit at ui/src/fonts/
next to the old single stylesheet — splitting this file out rebased the
url() three directories deeper and would have silently 404'd the font.)
Vendored rather than @imported from node_modules because Tailwind inlines
imported CSS, which would leave @fontsource's relative url() rebased against
the wrong directory. They live under src/ (not public/) so esbuild emits them
as hashed assets and rewrites the url() — a public/ path is not resolvable at
build time. */
@font-face {
font-family: 'Noto Sans App';
font-style: normal;
font-weight: 400;
font-display: block;
src: url('./fonts/noto-sans-latin-400-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Noto Sans App';
font-style: normal;
font-weight: 700;
font-display: block;
src: url('./fonts/noto-sans-latin-700-normal.woff2') format('woff2');
}
:root.win7 {
/* Real Segoe first (Windows), bundled substitute everywhere else. */
--w7-family: 'Segoe UI', 'SegoeUI', 'Noto Sans App', system-ui, sans-serif;
/* ~7.css's 9pt, in REM so it follows the interface scale (the root
font-size lever) like every other size in the app — 0.75rem = 12px at the
16px root. Restating the `font` SHORTHAND (not just the family) is
deliberate: 7.css applies `font: var(--w7-font)` on 15 component rules,
and the shorthand also resets weight and line-height — pinning 400/1.4
here stops those rules from flattening the app's `font-medium` / leading. */
--w7-fs: 0.75rem;
--w7-font: 400 var(--w7-fs) / 1.4 var(--w7-family);
/* Surface ramp, Win7 Explorer. This INVERTS the modern ramp's direction:
there --chrome (frame strips) sits below --background (content panes);
here the content pane is the brightest thing on screen and the frame is
the #f0f0f0 window surface. Deliberate — that is what Explorer was. */
--background: #ffffff;
--chrome: #f0f0f0;
--card: #f0f0f0;
--foreground: #000000;
--card-foreground: #000000;
--muted-foreground: #6d6d6d;
--secondary: #e8e8e8;
--secondary-foreground: #000000;
--muted: #e8e8e8;
/* Win7's hover is a pale blue wash, not a grey — matches --w7-el-grad-h */
--accent: #eaf6fd;
--accent-foreground: #000000;
--border: #d5d5d5;
--input: #abadb3; /* 7.css's own text-input border */
--destructive: #c62828;
--destructive-foreground: #ffffff;
--success: #06b025; /* the Win7 progress-bar green */
/* 0.3125rem = 5px at the 16px root — NOT 3px: `rounded-md` is 74 of the 112
radius utilities in the templates and compiles to
calc(var(--radius) - 0.125rem), so 5 lands it exactly on --w7-el-bdr (3px).
--radius itself feeds `rounded-lg` (12 uses) at 5px, near the 6px window
radius. Bare `rounded` (26 uses) is a Tailwind literal 0.25rem. Rem so the
corners follow the interface scale with everything else. */
--radius: 0.3125rem;
}
/* Light values for every 7.css literal the skinfix rules below restate through
vars (7.css hardcodes these outside its var system). The values are 7.css's
own, verbatim — light must render exactly as stock. Dark overrides live in
the duplicated dark blocks above, which is the house pattern: rules are
written ONCE and theme through tokens. (The previous attribute-keyed dark
rules silently missed SYSTEM-dark — the media-query path sets no attribute.) */
:root.win7 {
/* ListView (listview.css) */
--w7-lv-bg: #fff;
--w7-lv-bd: #c0c1cd;
--w7-lv-c: #000;
--w7-lv-head-grad: linear-gradient(180deg, #fff 45%, #fafafa 0, #f0f0f0);
--w7-lv-head-bd: #d7d7d7;
--w7-lv-cell-bd: #eee;
/* Selected row. Explorer painted a FLAT blue, not a gradient — 7.css reuses
its Aero element-hover gradient for the band, which is what made the list
look glossy. These are dedicated tokens on purpose: --w7-el-grad-h is
consumed by buttons and tabs and must keep its gradient.
`-idle` is the unfocused pane's band. Explorer greyed the selection out
when the list lost focus rather than dropping it, which is exactly the
commander convention this app already follows (see the pane-focus model
below): the band never disappears, only its strength changes. */
--w7-sel-bg: #cbe8f6;
--w7-sel-bg-idle: #dedede;
/* Tabs (tabs.css). 7.css hardcodes `color: #222` on every tablist button,
outside its own var system, which is unreadable on the dark surface. */
--w7-tab-c: #222;
/* Menu (menu.css) */
--w7-mn-c: #000;
--w7-mn-bd: #0006;
--w7-mn-stripe: inset 1px 0 #00000026, inset -1px 0 #fff;
--w7-mn-divider: inset 0 1px #00000026, inset 0 -1px #fff;
--w7-mn-check-ring: #b3d3f9;
--w7-mn-check-c: #0c12a1;
/* Glass caption streaks (window.css .window.glass>.title-bar): a literal.
The two #fff5 corner shines wash a DARK caption silver, so dark subdues
every white in the stack while keeping the streak structure. */
--w7-glass-title: linear-gradient(135deg, #fff5 70px, transparent 100px), linear-gradient(225deg, #fff5 70px, transparent 100px), linear-gradient(54deg, #0002 0 4%, #6661 6% 6%, #0002 8% 10%, #0002 15% 16%, #aaa1 17% 18%, #0002 23% 24%, #bbb2 25% 26%, #0002 31% 33%, #0002 34% 34.5%, #bbb2 36% 40%, #0002 41% 41.5%, #bbb2 44% 45%, #bbb2 46% 47%, #0002 48% 49%, #0002 50% 50.5%, #0002 56% 56.5%, #bbb2 57% 63%, #0002 67% 69%, #bbb2 69.5% 70%, #0002 73.5% 74%, #bbb2 74.5% 79%, #0002 80% 84%, #aaa2 85% 86%, #0002 87%, #bbb1 90%) 0 / 100vw 100vh no-repeat fixed;
/* Caption text (window.css .title-bar-text: black + 8-fold white halo) */
--w7-cap-c: #000;
--w7-cap-glow: 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff,
0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff;
}
/* Hand-authored dark Win7. Microsoft never shipped one — this exists so the
theme and tone settings stay live under the skin. Only the colour-carrying
--w7-* vars are listed: --w7-el-grad and --w7-w-grad are DERIVED from the
values below and recolour for free, while --w7-el-grad-h/-a and
--w7-li-bg-hl are literal hex in 7.css and so must be restated. The ~25
geometry vars and ~20 base64 icon vars are theme-independent.
DUPLICATED for forced-dark and system-dark, per the rule the token blocks
above set: every dark value goes in BOTH. */
:root.win7[data-theme='dark'] {
--w7-surface: #2b2b2b;
--w7-el-bg: #3a3a3a;
--w7-el-bg-d: #333333;
--w7-el-bg-s-1: #333333;
--w7-el-bg-s-2: #262626;
--w7-el-bd: #565656;
--w7-el-bd-h: #4a8cbe;
--w7-el-bd-a: #3f6c8c;
--w7-el-bd-d: #3f3f3f;
--w7-el-c: #f0f0f0;
--w7-el-c-d: #6f6f6f;
--w7-el-sd: inset 0 0 0 1px #ffffff14;
--w7-el-grad-h: linear-gradient(#3c5a6b 45%, #315062 45%, #2b4557);
--w7-el-grad-a: linear-gradient(#2c4657, #24384a 30% 50%, #1d2f3f 50%, #16242f);
--w7-li-bd-hl: #3f6c8c;
--w7-li-bg-hl: linear-gradient(#ffffff14, #4a6b8433 90%, #ffffff1a);
--w7-w-bd: #000000e6;
/* Ares-style black glass: near-black ground; 7.css's own alpha streaks and
highlight gradients become the gloss. Blue lives in accents (selection,
active tab), not in the glass itself. */
--w7-w-bg: #101013;
--w7-tab-bg: #1c1c1e;
--w7-title-c: #f5f5f5;
--w7-link-c: #6fb3ff;
/* literal restatements consumed by the skinfix rules (see :root.win7) */
--w7-lv-bg: var(--w7-el-bg-s-2);
--w7-lv-bd: var(--w7-el-bd-d);
--w7-lv-c: var(--w7-el-c);
--w7-lv-head-grad: linear-gradient(180deg, var(--w7-el-bg) 45%, var(--w7-el-bg-d) 0, var(--w7-el-bg-s-1));
--w7-lv-head-bd: var(--w7-el-bd-d);
--w7-lv-cell-bd: var(--w7-el-bd-d);
--w7-sel-bg: #2a4a63;
--w7-sel-bg-idle: #3a3a3a;
--w7-tab-c: var(--w7-el-c);
--w7-mn-c: var(--w7-el-c);
--w7-mn-bd: var(--w7-el-bd-d);
--w7-mn-stripe: inset 1px 0 #00000059, inset -1px 0 #ffffff14;
--w7-mn-divider: inset 0 1px #00000059, inset 0 -1px #ffffff14;
--w7-mn-check-ring: var(--w7-li-bd-hl);
--w7-mn-check-c: var(--w7-el-c);
--w7-glass-title: linear-gradient(135deg, #ffffff1a 70px, transparent 100px), linear-gradient(225deg, #ffffff1a 70px, transparent 100px), linear-gradient(54deg, #0003 0 4%, #ffffff08 6% 6%, #0003 8% 10%, #0003 15% 16%, #ffffff0d 17% 18%, #0003 23% 24%, #ffffff10 25% 26%, #0003 31% 33%, #0003 34% 34.5%, #ffffff10 36% 40%, #0003 41% 41.5%, #ffffff10 44% 45%, #ffffff10 46% 47%, #0003 48% 49%, #0003 50% 50.5%, #0003 56% 56.5%, #ffffff10 57% 63%, #0003 67% 69%, #ffffff10 69.5% 70%, #0003 73.5% 74%, #ffffff10 74.5% 79%, #0003 80% 84%, #ffffff0d 85% 86%, #0003 87%, #ffffff0a 90%) 0 / 100vw 100vh no-repeat fixed;
/* Ares: white caption on black glass, dark halo instead of white */
--w7-cap-c: var(--w7-title-c);
--w7-cap-glow: 0 0 8px #000c, 0 0 8px #000c, 0 1px 2px #000;
--w7-link-c-h: #9acbff;
--background: #1f1f1f;
--chrome: #2b2b2b;
--card: #2b2b2b;
--foreground: #f0f0f0;
--card-foreground: #f0f0f0;
--muted-foreground: #a0a0a0;
--secondary: #383838;
--secondary-foreground: #f0f0f0;
--muted: #383838;
--accent: #2f4a5c;
--accent-foreground: #f0f0f0;
--border: #3c3c3c;
--input: #55585c;
}
@media (prefers-color-scheme: dark) {
:root.win7:not([data-theme='light']) {
--w7-surface: #2b2b2b;
--w7-el-bg: #3a3a3a;
--w7-el-bg-d: #333333;
--w7-el-bg-s-1: #333333;
--w7-el-bg-s-2: #262626;
--w7-el-bd: #565656;
--w7-el-bd-h: #4a8cbe;
--w7-el-bd-a: #3f6c8c;
--w7-el-bd-d: #3f3f3f;
--w7-el-c: #f0f0f0;
--w7-el-c-d: #6f6f6f;
--w7-el-sd: inset 0 0 0 1px #ffffff14;
--w7-el-grad-h: linear-gradient(#3c5a6b 45%, #315062 45%, #2b4557);
--w7-el-grad-a: linear-gradient(#2c4657, #24384a 30% 50%, #1d2f3f 50%, #16242f);
--w7-li-bd-hl: #3f6c8c;
--w7-li-bg-hl: linear-gradient(#ffffff14, #4a6b8433 90%, #ffffff1a);
--w7-w-bd: #000000e6;
--w7-w-bg: #101013;
--w7-tab-bg: #1c1c1e;
--w7-title-c: #f5f5f5;
--w7-link-c: #6fb3ff;
/* literal restatements consumed by the skinfix rules (see :root.win7) */
--w7-lv-bg: var(--w7-el-bg-s-2);
--w7-lv-bd: var(--w7-el-bd-d);
--w7-lv-c: var(--w7-el-c);
--w7-lv-head-grad: linear-gradient(180deg, var(--w7-el-bg) 45%, var(--w7-el-bg-d) 0, var(--w7-el-bg-s-1));
--w7-lv-head-bd: var(--w7-el-bd-d);
--w7-lv-cell-bd: var(--w7-el-bd-d);
--w7-sel-bg: #2a4a63;
--w7-sel-bg-idle: #3a3a3a;
--w7-tab-c: var(--w7-el-c);
--w7-mn-c: var(--w7-el-c);
--w7-mn-bd: var(--w7-el-bd-d);
--w7-mn-stripe: inset 1px 0 #00000059, inset -1px 0 #ffffff14;
--w7-mn-divider: inset 0 1px #00000059, inset 0 -1px #ffffff14;
--w7-mn-check-ring: var(--w7-li-bd-hl);
--w7-mn-check-c: var(--w7-el-c);
--w7-glass-title: linear-gradient(135deg, #ffffff1a 70px, transparent 100px), linear-gradient(225deg, #ffffff1a 70px, transparent 100px), linear-gradient(54deg, #0003 0 4%, #ffffff08 6% 6%, #0003 8% 10%, #0003 15% 16%, #ffffff0d 17% 18%, #0003 23% 24%, #ffffff10 25% 26%, #0003 31% 33%, #0003 34% 34.5%, #ffffff10 36% 40%, #0003 41% 41.5%, #ffffff10 44% 45%, #ffffff10 46% 47%, #0003 48% 49%, #0003 50% 50.5%, #0003 56% 56.5%, #ffffff10 57% 63%, #0003 67% 69%, #ffffff10 69.5% 70%, #0003 73.5% 74%, #ffffff10 74.5% 79%, #0003 80% 84%, #ffffff0d 85% 86%, #0003 87%, #ffffff0a 90%) 0 / 100vw 100vh no-repeat fixed;
/* Ares: white caption on black glass, dark halo instead of white */
--w7-cap-c: var(--w7-title-c);
--w7-cap-glow: 0 0 8px #000c, 0 0 8px #000c, 0 1px 2px #000;
--w7-link-c-h: #9acbff;
--background: #1f1f1f;
--chrome: #2b2b2b;
--card: #2b2b2b;
--foreground: #f0f0f0;
--card-foreground: #f0f0f0;
--muted-foreground: #a0a0a0;
--secondary: #383838;
--secondary-foreground: #f0f0f0;
--muted: #383838;
--accent: #2f4a5c;
--accent-foreground: #f0f0f0;
--border: #3c3c3c;
--input: #55585c;
}
}
/* Dark tone "darker": Aero has no meaningful true black, so rather than leave
the Tone row a dead control the skin just goes a step deeper. Duplicated
forced + system like everything else. */
:root.win7[data-theme='dark'][data-dark-tone='darker'] {
--w7-surface: #1a1a1a;
--w7-el-bg: #262626;
--w7-w-bg: #060607;
--background: #121212;
--chrome: #1a1a1a;
--card: #1a1a1a;
}
@media (prefers-color-scheme: dark) {
:root.win7:not([data-theme='light'])[data-dark-tone='darker'] {
--w7-surface: #1a1a1a;
--w7-el-bg: #262626;
--w7-w-bg: #060607;
--background: #121212;
--chrome: #1a1a1a;
--card: #1a1a1a;
}
}
/* Accent -> Aero window colour, so the existing accent picker keeps meaning
under the skin. --w7-w-bg is the one colour 7.css documents as settable.
LIGHT ONLY: in dark the glass is Ares black regardless of accent (the accent
keeps tinting selection etc. through --primary). These arms out-rank the
accent rule below on specificity — without them it wins the tie on source
order and dark glass turns accent-coloured. */
:root.win7[data-accent] {
--w7-w-bg: var(--primary);
}
:root.win7[data-theme='dark'][data-accent] {
--w7-w-bg: #101013;
}
:root.win7[data-theme='dark'][data-dark-tone='darker'][data-accent] {
--w7-w-bg: #060607;
}
@media (prefers-color-scheme: dark) {
:root.win7:not([data-theme='light'])[data-accent] {
--w7-w-bg: #101013;
}
:root.win7:not([data-theme='light'])[data-dark-tone='darker'][data-accent] {
--w7-w-bg: #060607;
}
}
/* Unlayered on purpose: the html/body rule above sets font-family unlayered,
so a layered override would lose. Family only — NOT font-size: the app's
rem-based utilities are root-relative, and shifting the root would move
every line box for no gain. */
.win7 body {
font-family: var(--w7-family);
}
/* The win7 window is a FRAMELESS, natively transparent window whose only
opaque surface is meant to be `.window` — that is the whole premise of the
6px rounded frame, since the area outside the radius must let the desktop
through. The unlayered `html, body { background: var(--background) }` above
broke that premise silently: it painted an opaque rectangle behind the
frame, so the rounded corners had an opaque ground filling them in and the
window read as a rectangle with a drawn-on curve.
Unlayered on purpose, same reason as the font-family rule: the html/body
background it overrides is itself unlayered, and unlayered declarations beat
every @layer regardless of specificity — a `skinfix` rule could not touch
it. Scoped to .win7 so the modern skin keeps its opaque ground. */
html.win7,
html.win7 body {
background: transparent;
}
/* Neutralizers live in `skin`, NOT `skinfix`, and this is load-bearing:
`revert-layer` rolls a property back to the previous LAYER, so from inside
`skin` it lands on `utilities` (the Tailwind classes already in the
templates) — which is the whole point. From `skinfix` it would land back on
7.css and do nothing. Within `skin` these beat the imported rules on equal
specificity by source order, since the import is at the top of this file. */
@layer skin {
/* 7.css sizes every <button> like a Win7 dialog push button
(min-width 75px, min-height 23px, padding 0 12px). Most of this app's 84
buttons are ~24px icon squares sized by utilities, so without this the tab
bar wraps and the options popover overflows. Hand the BOX MODEL back to
the utilities layer and keep 7.css's paint. The title bar's own controls
are matched by a MORE specific 7.css selector (.title-bar-controls button)
and so keep their Aero glyph metrics untouched. */
.win7 button {
min-width: 0;
min-height: 0;
padding: revert-layer;
text-align: revert-layer;
color: revert-layer;
}
/* 7.css hardcodes #fff / #abadb3 / #222 on form controls and tables, outside
its own var system. Reverting routes them back through the bridged tokens
above, which is what makes dark mode nearly free. */
.win7 input[type='text'],
.win7 input[type='password'],
.win7 input[type='number'],
.win7 input[type='search'],
.win7 textarea {
background-color: revert-layer;
border-color: revert-layer;
}
.win7 table {
background-color: revert-layer;
color: revert-layer;
}
/* Win7 toolbar buttons are FLAT with a hover glow, not Aero slabs —
Explorer's command bar worked exactly that way. `w7-toolbar` marks the
three header rows; a plain `.title-bar` sibling is deliberately NOT one of
them, so its controls keep the real Aero treatment. `w7-flat` is the
per-button escape hatch. Both classes are inert under the modern skin.
`:not([role='tab'])` is load-bearing: the workspace strip IS a w7-toolbar,
so without the exemption this rule would strip the border, gradient and
both pseudo-elements off every tab and leave 7.css's tab component drawing
nothing. Tabs want the full Aero slab; the +/Connections/options buttons
beside them want the flat treatment.
Only `background` stays here — it is the one property that needs to land
on `utilities` via revert-layer. The literals that suppress the Aero slab
moved to `skinfix`, because 7.css restates border-color at
`.win7 button:not(:disabled):not(:hover)` = (0,3,1), which outranks
`.win7 .w7-flat` = (0,2,0). The toolbar arm only ever won that fight by
tying on specificity and landing later in the same layer — an accident,
and one w7-flat did not share: it rendered a bordered slab regardless. */
.win7 .w7-toolbar button:not([role='tab']),
.win7 .w7-flat {
background: revert-layer;
}
.win7 .w7-toolbar button:not([role='tab'])::before,
.win7 .w7-toolbar button:not([role='tab'])::after,
.win7 .w7-flat::before,
.win7 .w7-flat::after {
content: none;
}
}
/* Above `skin`, for rules that must state a value rather than defer to a
neighbouring layer. */
@layer skinfix {
/* ---- Scale bridge: re-express 7.css's px metrics in rem ----
7.css sizes everything in px (370 of them, zero rem) and its --w7-font
shorthand reaches only 15 rules' TYPOGRAPHY — the boxes stay frozen. Under
the interface scale (root font-size lever) win7 text would grow inside
23px buttons, 14px table rows and a 15px-line-height caption. Every value
below is 7.css's own, converted at the 16px root (23px -> 1.4375rem …),
so scale 1.0 renders pixel-identical to stock and every other scale keeps
the boxes proportional to the text.
Deliberately NOT converted: 1px hairlines and focus rects (the tr border
that stops selection shifting rows, .is-cursor's 1px dotted outline, menu
bevels, the frame's inset glass ring) — those stay crisp at any scale. */
/* 7.css's own size vars — defined on .win7 in px; rem lands on the same
pixels at scale 1. Covers checkbox/radio/slider/tooltip/treeview/menu
gutters and both window metrics in one shot. */
.win7 {
--w7-cb-size: 0.875rem; /* 14px */
--w7-rd-size: 0.875rem;
--w7-rdd-size: 0.5rem; /* 8px */
--w7-rdl-space: 0.375rem; /* 6px */
--w7-sdt-w: 0.625rem; /* 10px */
--w7-sdt-h: 1.125rem; /* 18px */
--w7-blt-size: 1.125rem;
--w7-tvb-size: 0.5rem;
--w7-tv-left: 1.25rem; /* 20px */
--w7-clc-size: 0.3125rem; /* 5px */
--w7-lvc-size: 0.3125rem;
--w7-mn-left: 1.75rem; /* 28px */
--w7-el-bdr: 0.1875rem; /* 3px */
--w7-w-space: 0.375rem; /* 6px */
--w7-w-bdr: 0.375rem;
}
/* The hardcoded box metrics the vars do not reach, restated in rem; skinfix
outranks skin by layer, so these win over 7.css.
NO blanket `.win7 button` rule here, deliberately: the `skin`-layer
neutralizer strips 7.css's 75x23 metrics off every button (most are icon
squares sized by utilities), and a skinfix restatement would outrank that
neutralizer by LAYER and re-inflate all of them. The only buttons that
legitimately wear Win7 push-button metrics are .w7-btn (opted in below)
and the title-bar controls (which beat the neutralizer on specificity
inside `skin` and are restated here). */
.win7 input[type='email'],
.win7 input[type='number'],
.win7 input[type='password'],
.win7 input[type='search'],
.win7 input[type='text'],
.win7 input[type='url'] {
height: 1.4375rem;
}
.win7 input[type='email'],
.win7 input[type='number'],
.win7 input[type='password'],
.win7 input[type='search'],
.win7 input[type='text'],
.win7 input[type='url'],
.win7 textarea {
padding: 0.1875rem 0.25rem 0.3125rem; /* 3px 4px 5px */
}
.win7 table > thead > tr > * {
height: 1.375rem; /* 22px */
padding: 0 0.5rem; /* 0 8px */
}
.win7 table > tbody > tr > * {
height: 0.875rem; /* 14px */
padding: 0.125rem 0.5rem; /* 2px 8px */
}
.win7 .title-bar-text {
line-height: 0.9375rem; /* 15px */
}
.win7 .title-bar-controls button {
min-height: 1.1875rem; /* 19px */
min-width: 1.8125rem; /* 29px */
padding: 0;
}
/* BOTH of 7.css's wide-close selectors, not just .is-close: the app's title
bars key the buttons on aria-label, and the base rule above outranks the
`skin`-layer variant BY LAYER — restating only one arm shrank the close
button from 48px to 29px (caught by the harness pixel diff). */
.win7 .title-bar-controls button.is-close,
.win7 .title-bar-controls button[aria-label='Close'] {
min-width: 3rem; /* 48px */
}
.win7 ul[role='menu'] {
min-width: 9.375rem; /* 150px */
padding: 0.125rem; /* 2px */
}
.win7 ul[role='menu'] > [role='menuitem'] > a,
.win7 ul[role='menu'] > [role='menuitem'] > button,
.win7 ul[role='menu'] > [role='menuitem'] > label {
padding: 0.25rem 0.625rem 0.25rem 2rem; /* 4px 10px 4px 32px */
}
.win7 menu[role='tablist'] button {
padding: 0.125rem 0.375rem; /* 2px 6px */
}
/* The raised active tab: 7.css gives it padding-bottom 4px at (0,2,2) inside
`skin`, but the shorthand above outranks that BY LAYER and would flatten
the raise — so the variant is restated here too. */
.win7 menu[role='tablist'] button[aria-selected='true'] {
padding-bottom: 0.25rem; /* 4px */
}
/* The 10pt checkmark glyph in menu checkables — one of five pt sizes in
7.css that bypass --w7-font entirely. 10pt = 13.333px ≈ 0.8333rem. */
.win7 ul [role='menuitem'] > input[type='checkbox']:checked + label:after {
font-size: 0.8333rem;
}
/* Opt back IN to real Win7 push-button metrics, for the dialog and options
buttons where they are right. Same rem values as the button rule above —
this class exists to undo the `skin`-layer neutralizer, not to differ. */
.win7 .w7-btn {
min-width: 4.6875rem;
min-height: 1.4375rem;
padding: 0 0.75rem;
}
/* ---- ListView (file panels) ----
7.css's listview.css hardcodes light literals (#fff, #c0c1cd, #eee and the
white header gradient) instead of --w7-* vars, so unlike the rest of the
skin it does NOT follow the hand-authored dark palette — the header labels
came out invisible, dark on dark. These restate the same rules in dark
terms; light keeps 7.css's own values untouched.
CRITICAL: the base surface goes on `table`, NEVER on `tbody > tr > *`.
7.css paints the selection band on the ROW, and a cell background paints
over a row background — setting one erased the selection highlight
entirely in dark mode. */
.win7 table {
background-color: var(--w7-lv-bg);
border-color: var(--w7-lv-bd);
color: var(--w7-lv-c);
}
.win7 table > thead > tr > * {
background: var(--w7-lv-head-grad);
border-color: var(--w7-lv-head-bd);
color: var(--w7-lv-c);
}
.win7 table > tbody > tr > :not(:last-child) {
border-right-color: var(--w7-lv-cell-bd);
}
/* 7.css drops the cell separators inside a selected row; the rule above is
more specific than its, so restore that behaviour explicitly. */
.win7 table > tbody > tr.highlighted > :not(:last-child) {
border-right: none;
}
.win7 table > tbody > tr.highlighted {
background: var(--w7-sel-bg-idle);
border-color: transparent;
}
/* ---- Window frame ----
The native window is transparent (chromeOptions in src/bun/windows.ts), and
nothing but .window may paint an opaque surface — that is what lets the 6px
corner radius actually be transparent at the corners. That invariant is NOT
free: html/body paint an opaque background unlayered, and had to be reset
to transparent for .win7 (see the rule above @layer skin). Anything else
that paints full-bleed will fill the corners the same way.
NO drop shadow, by request: 7.css's `2px 2px 10px 1px #000000b3` needed a
margin gutter to fall into and still read heavy against a real desktop, so
the frame keeps only the inset 1px glass highlight from that shorthand
(dropping the whole box-shadow would lose the highlight too — it lives in
the same declaration). No shadow -> no gutter -> full-bleed 100vh.
Keyed on `w7-frame` — the app hook the three window ROOTS carry — never on
bare .window: on any other .window (dialogs, demos) this sizing is
nonsense and 7.css's own shadow is fine. */
.win7 .window.w7-frame {
margin: 0;
height: 100vh;
box-shadow: inset 0 0 0 1px #fffa;
}
/* A maximized Win7 window has SQUARE corners — the radius only exists to let
the desktop show past a floating frame, and a rounded maximized window
leaves four notches of desktop in the screen corners.
Read off the title bar's own `is-maximized` (title-bar.ts publishes it on
its host) with :has(), so the frame reacts without a second windowState
subscription. .title-bar is a DIRECT child of .window — app-title-bar
carries the class on its host element, which is why `>` holds here.
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 must be zeroed or
a rounded pseudo-element still cuts the corner off the square frame. */
.win7 .window.w7-frame:has(> .title-bar.is-maximized),
.win7 .window.w7-frame:has(> .title-bar.is-maximized)::before,
.win7 .window.w7-frame:has(> .title-bar.is-maximized)::after {
border-radius: 0;
}
/* STOPGAP — the synthesized resize handles (window-resize/window-resize.ts,
which carries the removal checklist). A maximized window has no edges to
grab, so the overlay goes away entirely while it is maximized, and comes
back on restore.
Read off the title bar's own `is-maximized` with :has(), exactly like the
corner-squaring rule above — that is what keeps this component free of a
second `windowState` subscription (RpcService's push registries have no
off(), so every subscription is permanent). */
.win7 .window.w7-frame:has(> .title-bar.is-maximized) .w7-resize {
display: none;
}
/* Hovering the close button paints 7.css's red glow ::before
(box-shadow: 0 0 7px 3px #e68e75), and box-shadows of CHILDREN are not
clipped by the parent's border or radius — the caption buttons sit flush
against the window's top edge (border-top: 0), so the glow spilled ABOVE
the frame: a red tint across the top border and a red halo floating on the
desktop past it (scripts/hover-probe.ts renders the forced-hover state and
pixel-scans for exactly this; it caught 100 red pixels above the edge).
Clip the title bar's children to its own box on top/left/right — the glow
now dies at the frame border, which is where the real Win7 DWM stopped it
too — while the bottom stays open (-1rem) so the glow's bleed toward the
caption below survives; that part is authentic.
clip-path also clips HIT-TESTING, but only for content outside the region:
the title-bar's own box (the drag region) is entirely inside it.
Scoped to w7-frame: demo/harness windows keep stock 7.css behaviour. */
.win7 .window.w7-frame > .title-bar {
clip-path: inset(0 0 -1rem 0);
}
/* 7.css's glass layer is `backdrop-filter: blur(4px)` on .window::after —
but CSS cannot sample the DESKTOP behind a transparent native window
(there is no backdrop to read), and WebKitGTK paints the unsampleable
layer BLACK: every see-through part of the glass — the frame ring, the
area around the title-bar buttons — composited over black instead of over
the desktop. With it off, the real desktop shows through the glass tint,
which is closer to Aero than a blur we cannot have. (True blur-behind
needs compositor support; GNOME offers none to X11/XWayland clients.) */
.win7 .window.glass::after {
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
/* Constant row geometry. 7.css gives ONLY tr.highlighted a 1px border, and
the table is border-collapse: collapse — so a selected row physically grew
1px at top and bottom and shoved every row below it down by 2px as the
cursor moved. Every row reserves that border instead, transparent when not
selected, so selecting changes colour and nothing else.
The border stays TRANSPARENT even when selected: it exists purely to
reserve space. 7.css outlines the selected row (#aaddfa) on top of its
fill, which stacked a second edge inside the band and, with the cursor
row's dotted rect, drew three concentric outlines around one row. The fill
paints under the transparent border (background-clip defaults to
border-box), so the band still covers the full row. */
.win7 table > tbody > tr {
border: 1px solid transparent;
}
/* Pane-focus model (commander convention, matching Win7 Explorer):
the CURSOR ROW always carries the selection band in BOTH panels —
tr.highlighted covers "selected via Space" AND "cursor row" (the template
ORs them). What changes with pane focus is the band's strength, never
whether it exists:
- focused pane -> the flat blue selection (below)
- unfocused pane -> the grey idle band, which is what Explorer drew
when the list lost focus — faint by design, not a bug.
The dotted focus rectangle is NOT the focus signal; it is layered on top
of the band in the focused pane only, the way Win7 drew the keyboard
focus rect over the selected item.
FLAT, and only a fill: 7.css bands the row with --w7-el-grad-h, its Aero
element-hover ramp, which reads as a glossy button rather than a file
selection, and outlines it as well. One solid colour, no edge — the
dotted cursor rect is then the only line on the row, which is what makes
it legible as the cursor. */
.win7 table.is-active > tbody > tr.highlighted {
background: var(--w7-sel-bg);
}
/* One dotted rectangle around the ROW; per cell (tr > *) draws a dotted box
around every cell instead — visibly wrong. */
.win7 table > tbody > tr.is-cursor {
outline: 1px dotted var(--w7-el-c);
outline-offset: -1px;
}
/* ---- Menu (context menu) ----
The menu itself is pure 7.css — ul[role=menu] > li[role=menuitem], see
ui/src/app/context-menu.ts for the exact contract. (A previous iteration
claimed "7.css has no menu component" and hand-built a lookalike here;
false — the markup was one level off and its selectors never matched.)
Only menu.css's hardcoded LIGHT literals need restating for the dark
palette, same as the ListView above: the black `color:initial`, the
white bevel halves of the gutter stripe and dividers, and the checked-box
ring (#b3d3f9). */
.win7 ul[role='menu'] {
color: var(--w7-mn-c);
border-color: var(--w7-mn-bd);
}
/* THE POSITIONING ONE — this is why right-click looked dead under the skin.
menu.css declares `position: relative` on ul[role=menu] (it needs it for
the gutter stripe's ::before and for submenu anchoring), and `skin`
outranks `utilities`, so Tailwind's `.fixed` on the app's menu lost. Its
[style.left/top.px] then became FLOW offsets from the menu's static
position — the last child of an h-screen flex column — and every menu
rendered below the viewport, unreachable behind `overflow: hidden`. The
backdrop is a plain <div>, which 7.css does not match, so it stayed truly
fixed and swallowed the next click: "right click does nothing, then the
app ignores me once".
Scoped to the app menu's own marker class, never blanket: 7.css's own
relative positioning must survive for any other ul[role=menu].
Regression test: scripts/menu-probe.ts (a resting pixel diff cannot see a
positioning bug — nothing about the idle window changes). */
.win7 ul[role='menu'].w7-ctx {
position: fixed;
}
.win7 ul[role='menu']:before {
box-shadow: var(--w7-mn-stripe);
}
.win7 ul [role='menuitem'].has-divider:after {
box-shadow: var(--w7-mn-divider);
}
.win7 ul [role='menuitem'] > input[type] + label:before {
box-shadow: 0 0 0 1px var(--w7-mn-check-ring);
}
.win7 ul [role='menuitem'] > input[type='checkbox']:checked + label:after {
color: var(--w7-mn-check-c);
}
/* Menu items: flat highlight, and no Aero afterglow trailing the pointer.
Menu items ARE <button>s, so button.css reaches them with three effects
that do not belong on a menu:
(1) the highlight is 7.css's translucent wash
`linear-gradient(#fff9, #e6ecf5cc 90%, #fffc)` + an #aaddfa edge — the
same gradient the ListView selection above just dropped;
(2) `button::before` is a blue glow overlay whose opacity fades out over
a FULL SECOND (`button:not(:hover):before { transition: opacity 1s
linear }`). menu.css sets `content: none` on the :HOVER arm only, so
the pseudo reappears the instant you leave an item and then fades 1→0
— a ghost dragging behind the pointer. Killing `content` in EVERY
state is what removes it; suppressing the transition alone would not,
because the glow itself is legitimate on real push buttons;
(3) `button:focus` runs `pulse-anim` infinitely, and this menu moves REAL
focus between items for keyboard nav — so a focused item would breathe.
Scoped to the app's own menu (w7-ctx): 7.css's demo menus keep stock Aero. */
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label {
transition: none;
animation: none;
}
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button::before,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button::after {
content: none;
}
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:hover,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:focus,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:focus-visible,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label:hover,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label:focus-visible {
background: var(--w7-sel-bg);
border-color: transparent;
}
/* ---- Flat buttons (toolbars + the w7-flat escape hatch) ----
The paint-suppression half of the rule in `skin` (see there for the split).
It lives in `skinfix` because 7.css restates border-color at (0,3,1) via
`.win7 button:not(:disabled):not(:hover)` and again on :hover, which no
reasonable `.w7-flat` selector outranks — layer order settles it instead
of a specificity arms race. */
.win7 .w7-toolbar button:not([role='tab']),
.win7 .w7-flat {
border-color: transparent;
box-shadow: none;
}
/* ---- Tabs (workspace strip) ----
7.css owns the tab CHROME; these restore the tab CONTENT layout it breaks.
`menu[role=tablist] button` sets `display: block` at (0,2,1), which
outranks Tailwind's `.flex` in `utilities` — so the icon and the label
rendered jammed together with no gap and no truncation. Restate the flex
box here (skinfix outranks skin) and leave every painted property alone.
The gap matches the gap-1.5 the template asks for. */
.win7 menu[role='tablist'] button[role='tab'] {
display: flex;
align-items: center;
gap: 0.375rem;
}
/* tabs.css hardcodes `color: #222` on every tablist button — a light-mode
literal outside 7.css's var system, which left the labels near-invisible
on the dark surface. Same failure and same remedy as the ListView and menu
literals above: route it through a token that both dark blocks override. */
.win7 menu[role='tablist'] button {
color: var(--w7-tab-c);
}
/* The workspace strip scrolls horizontally when tabs overflow. Setting
`overflow-x: auto` forces the computed `overflow-y` from visible to auto
as well (CSS will not pair visible with a non-visible axis), so the strip
clips vertically — and 7.css raises the ACTIVE tab with `margin-top: -2px`,
which put its top border outside the scroll box and silently deleted it.
Reserve those 2px as padding so the pulled-up tab lands inside the box.
7.css already reserves the matching 3px on the left (padding-left: 3px)
for the same tab's -3px left margin; it just never anticipated a scroller.
Verified by pixel scan: the active tab's top edge must be #8e8f8f, not a
straight jump from the toolbar grey to the tab fill. */
.win7 .w7-toolbar menu[role='tablist'] {
padding-top: 2px;
}
.win7 .window.glass > .title-bar {
background: var(--w7-glass-title);
background-attachment: fixed;
}
/* Caption text: 7.css hardcodes black + a white halo; in dark that becomes
the Ares treatment (white caption, dark halo) through the same vars. */
.win7 .title-bar-text {
color: var(--w7-cap-c);
text-shadow: var(--w7-cap-glow);
}
}
+73
View File
@@ -0,0 +1,73 @@
<!-- .window.glass is 7.css's Aero frame (radius, 1px border, inset highlight,
drop shadow, backdrop blur); .active is what un-greys the title bar and its
control glyphs — every .title-bar-controls rule is keyed on it. -->
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
@if (!rpc.available) {
<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">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ i18n.t('app.notConnectedBody') }}
</p>
</div>
</div>
} @else {
<!-- the window is frameless, so this IS the chrome -->
<app-title-bar [title]="appName" (closeRequested)="closeWindow()" />
<!-- 7.css's .window-body: the recessed inner panel, with the 6px inset and
its own border that give a Win7 window its depth. -->
<div class="window-body flex min-h-0 flex-1 flex-col overflow-hidden">
<app-tab-bar />
<main class="flex min-h-0 flex-1 divide-x divide-border">
<app-file-panel side="left" />
<app-file-panel side="right" />
</main>
<app-transfer-bar />
<app-status-bar />
</div>
}
<!-- modal block: the connection-manager window owns the app until closed -->
@if (managerOpen()) {
<div
class="fixed inset-0 z-60 flex items-center justify-center bg-black/40"
(mousedown)="focusManager()"
>
<div class="rounded-lg border border-border bg-card px-5 py-4 text-center shadow-xl">
<ng-icon name="mynaServers" size="1.25rem" class="text-muted-foreground" />
<p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p>
<button
class="mt-3 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{{ i18n.t('app.showConnections') }}
</button>
</div>
</div>
}
<!-- dialogs -->
@if (dialogs.prompt(); as req) {
<app-prompt-dialog [request]="req" />
}
@if (dialogs.confirm(); as req) {
<app-confirm-dialog [request]="req" />
}
@if (dialogs.chmod(); as req) {
<app-chmod-dialog [request]="req" />
}
@if (dialogs.overwrite(); as req) {
<app-overwrite-dialog [request]="req" />
}
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
own handles. Delete with the rest of the hack — checklist in
window-resize/window-resize.ts. -->
<app-window-resize />
<app-context-menu />
<app-toasts />
</div>
+307
View File
@@ -0,0 +1,307 @@
import { Component, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import { BRANDING } from '@shared/branding';
import type { ColumnKey } from '@shared/types';
import { ConnectionsService } from '@core/connections.service';
import { ContextMenu } from './context-menu';
import { ChmodDialog } from './dialogs/chmod-dialog';
import { ConfirmDialog } from './dialogs/confirm-dialog';
import { OverwriteDialog } from './dialogs/overwrite-dialog';
import { PromptDialog } from './dialogs/prompt-dialog';
import { DialogsService } from '@core/dialogs.service';
import { FileActionsService } from '@core/file-actions.service';
import { FilePanel } from './file-panel/file-panel';
import { PanelsService } from '@core/panels.service';
import { blockNativeContextMenu } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { StatusBar } from './status-bar/status-bar';
import { TabBar } from './tab-bar/tab-bar';
import { TitleBar } from './title-bar/title-bar';
import { ToastService } from '@core/toast.service';
import { Toasts } from './toasts/toasts';
import { TransferBar } from './transfer-bar/transfer-bar';
import { WindowResize } from './window-resize/window-resize';
import { TransfersService } from '@core/transfers.service';
import { UpdaterService } from '@core/updater.service';
@Component({
selector: 'app-root',
imports: [
NgIcon,
FilePanel,
TabBar,
StatusBar,
PromptDialog,
ConfirmDialog,
ChmodDialog,
OverwriteDialog,
Toasts,
TransferBar,
TitleBar,
ContextMenu,
WindowResize,
],
templateUrl: './app.html',
host: {
'(window:keydown)': 'onWindowKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)',
// Aero greys an inactive window's title bar and controls. The webview gets
// DOM focus/blur when the native window's focus changes, so this needs no
// RPC — see the `active` binding on .window in app.html.
'(window:focus)': 'windowFocused.set(true)',
'(window:blur)': 'windowFocused.set(false)',
},
})
export class App {
readonly i18n = inject(I18nService);
readonly rpc = inject(RpcService);
readonly toast = inject(ToastService);
readonly dialogs = inject(DialogsService);
readonly panels = inject(PanelsService);
readonly actions = inject(FileActionsService);
readonly connections = inject(ConnectionsService);
private readonly transfers = inject(TransfersService);
private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
private readonly updater = inject(UpdaterService);
/** The connection-manager window is open — this window is modal-blocked. */
readonly managerOpen = signal(false);
/** Drives `.window.active`: Aero desaturates an unfocused window's chrome. */
readonly windowFocused = signal(document.hasFocus());
/** Shown in the win7 title bar (no native titlebar to carry it). */
readonly appName = BRANDING.appName;
/**
* win7 title bar's X. Closing the main window quits, and the app-level
* before-quit guard (dirty editors + running transfers) still runs — it is
* the one close path electrobun lets us cancel.
*/
closeWindow(): void {
void this.rpc.request.windowClose({});
}
constructor() {
document.title = BRANDING.appName;
if (this.rpc.available) {
this.rpc.onMenuAction((action) => void this.handleMenuAction(action));
this.rpc.onManagerState((open) => this.managerOpen.set(open));
void this.promptForEditLeftovers();
}
}
focusManager(): void {
void this.rpc.request.openConnectionManager({});
}
blockContextMenu(event: Event): void {
blockNativeContextMenu(event);
}
/** Temp files from external edits in a previous run (crash / quit mid-edit). */
private async promptForEditLeftovers(): Promise<void> {
const res = await this.rpc.request.getEditLeftovers({});
if (!res.ok || !res.names.length) return;
const shown = res.names.slice(0, 5).join(', ');
const more =
res.names.length > 5 ? this.i18n.t('toasts.leftoverMore', { count: res.names.length - 5 }) : '';
this.dialogs.confirm.set({
title: this.i18n.t('toasts.leftoverTitle'),
body: this.i18n.t('toasts.leftoverBody', { count: res.names.length, names: shown + more }),
confirmLabel: this.i18n.t('toasts.leftoverClear'),
destructive: true,
onConfirm: () => void this.rpc.request.clearEditLeftovers({}),
});
}
/** A connection by id; reloads once if unseen (a manager-window save can
* race the connectionsChanged push). */
private async findConnection(id: string) {
const lookup = () => this.connections.connections().find((c) => c.id === id);
if (!lookup()) await this.connections.reload();
return lookup();
}
/** Native application-menu / manager-window actions forwarded from bun. */
private async handleMenuAction(action: string): Promise<void> {
if (action.startsWith('ctx-')) {
this.handleContextAction(action);
return;
}
if (action.startsWith('connect:')) {
const connection = await this.findConnection(action.slice('connect:'.length));
if (connection) {
// remote goes right, local stays left — the commander convention
void this.panels.connectPanel('right', connection, connection.name, connection.localDir);
}
return;
}
if (action.startsWith('col:')) {
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
return;
}
if (action.startsWith('tab:')) {
this.handleTabAction(action.slice('tab:'.length));
return;
}
if (action.startsWith('open-stack:')) {
const id = action.slice('open-stack:'.length);
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
if (!stackConnections.length) {
await this.connections.reload();
stackConnections = this.connections.connections().filter((c) => c.stackId === id);
}
if (stackConnections.length) this.panels.openStack(stackConnections);
return;
}
switch (action) {
case 'new-tab':
this.panels.newWorkspace();
break;
case 'close-tab':
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
break;
case 'new-folder':
this.actions.mkdir(this.panels.active());
break;
case 'new-file':
this.actions.createFile(this.panels.active());
break;
case 'edit-file':
void this.actions.edit(this.panels.active());
break;
case 'refresh-active':
void this.panels.active().refresh();
break;
case 'check-for-updates':
void this.updater.check();
break;
}
}
/** 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 {
if (this.managerOpen()) return; // modal-blocked behind the manager
const match = this.shortcuts.resolve(event, 'main');
if (!match) return;
// scale keys are hostile mid-typing ('-' in a rename prompt must type '-');
// the tab shortcuts keep firing there, as they always did
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
event.preventDefault();
switch (match.id) {
case 'new-tab':
this.panels.newWorkspace();
break;
case 'close-tab':
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
break;
case 'cycle-tab':
this.panels.cycleWorkspace(1);
break;
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 '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. */
/**
* 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 {
const target = this.actions.menuTarget;
if (!target) return;
const { panel, side, entry } = target;
// the empty-space menu acts on the directory itself
const path = entry?.path ?? panel.cwd();
switch (action) {
case 'ctx-open':
if (entry) panel.open(entry);
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':
void this.actions.edit(panel);
break;
case 'ctx-copy':
void this.transfers.copyToOtherPanel(side);
break;
case 'ctx-rename':
this.actions.rename(panel);
break;
case 'ctx-chmod':
this.actions.chmod(panel);
break;
case 'ctx-delete':
this.actions.delete(panel);
break;
case 'ctx-trash':
if (entry) void this.actions.trash(panel, entry);
break;
case 'ctx-new-folder':
this.actions.mkdir(panel);
break;
case 'ctx-new-file':
this.actions.createFile(panel);
break;
case 'ctx-refresh':
void panel.refresh();
break;
}
}
}
@@ -0,0 +1,390 @@
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
<!-- Frameless window. Fixed-size modal, so Close only. -->
<app-title-bar
[title]="i18n.t('connMgr.title')"
[resizable]="false"
(closeRequested)="close()"
/>
<!-- Toolbar strip under the title bar. h-9/pl-3 are load-bearing: without a
height this row collapses onto the icon and the screen reads as broken. -->
<header
class="w7-toolbar flex h-9 shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2 pl-3"
>
<ng-icon name="mynaServers" size="0.95rem" class="shrink-0 text-muted-foreground" />
<h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1>
<div class="flex-1"></div>
<button
(click)="importWinScp()"
[disabled]="importing()"
[title]="i18n.t('connMgr.importWinscpHint')"
class="flex items-center gap-1.5 rounded-md 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()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
} @else {
<!-- <ng-icon name="mynaArchive" size="0.85rem" /> -->
}
{{ i18n.t('connMgr.importWinscp') }}
</button>
</header>
<div class="flex min-h-0 flex-1">
<!-- stacks + connections list -->
<div class="flex w-72 shrink-0 flex-col border-r border-border bg-chrome">
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
<button
(click)="newConnection()"
[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"
>
<ng-icon name="mynaPlus" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }}
</button>
<button
(click)="newStack()"
[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"
>
<ng-icon name="mynaFolderPlus" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }}
</button>
</div>
<div class="has-scrollbar flex-1 overflow-y-auto py-1">
<!-- stacks -->
@for (group of store.grouped().stacks; track group.stack.id) {
<div
(dragover)="onDragOver($event, group.stack.id)"
(dragleave)="dropTarget() === group.stack.id && dropTarget.set(null)"
(drop)="onDrop($event, group.stack.id)"
class="rounded-md"
[class.bg-primary/10]="dropTarget() === group.stack.id"
[class.ring-1]="dropTarget() === group.stack.id"
[class.ring-inset]="dropTarget() === group.stack.id"
[class.ring-primary]="dropTarget() === group.stack.id"
>
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
<button
(click)="toggleStack(group.stack)"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground"
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
>
<ng-icon
[name]="group.stack.collapsed ? 'mynaChevronRight' : 'mynaChevronDown'"
size="0.8rem"
/>
</button>
<span class="truncate text-[0.6875rem] font-medium text-muted-foreground uppercase">
{{ group.stack.name }}
</span>
<div class="min-w-0 flex-1"></div>
<!-- count and actions swap on hover: they never share the space,
so a long stack name can't collide with the buttons -->
<span
class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-hover:hidden"
>
{{ group.connections.length }}
</span>
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
<button
(click)="renameStack(group.stack)"
[title]="i18n.t('connMgr.renameStack')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-foreground"
>
<ng-icon name="mynaEdit" size="0.75rem" />
</button>
<button
(click)="deleteStack(group.stack)"
[title]="i18n.t('connMgr.deleteStack')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-destructive"
>
<ng-icon name="mynaTrash" size="0.75rem" />
</button>
@if (group.connections.length) {
<button
(click)="openStack(group.stack.id)"
[title]="i18n.t('connMgr.openStack')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-primary"
>
<ng-icon name="mynaArrowRight" size="0.8rem" />
</button>
}
</div>
</div>
@if (!group.stack.collapsed) {
@for (connection of group.connections; track connection.id) {
<button
(click)="edit(connection)"
draggable="true"
(dragstart)="onDragStart($event, connection)"
(dragend)="onDragEnd()"
class="flex w-full items-center gap-1.5 py-1.5 pr-3 pl-6 text-left text-xs hover:bg-accent"
[class.bg-accent]="draft()?.id === connection.id"
[class.opacity-50]="draggedId() === connection.id"
>
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span>
</button>
} @empty {
<div class="py-1 pl-6 text-[0.6875rem] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div>
}
}
</div>
}
<!-- loose connections -->
<div
(dragover)="onDragOver($event, 'loose')"
(dragleave)="dropTarget() === 'loose' && dropTarget.set(null)"
(drop)="onDrop($event, null)"
class="min-h-8 rounded-md pb-1"
[class.bg-primary/10]="dropTarget() === 'loose'"
[class.ring-1]="dropTarget() === 'loose'"
[class.ring-inset]="dropTarget() === 'loose'"
[class.ring-primary]="dropTarget() === 'loose'"
>
@if (store.grouped().stacks.length && store.grouped().loose.length) {
<div class="px-2 pt-2 pb-0.5 text-[0.6875rem] font-medium text-muted-foreground uppercase">
{{ i18n.t('connMgr.connectionsHeader') }}
</div>
}
@for (connection of store.grouped().loose; track connection.id) {
<button
(click)="edit(connection)"
draggable="true"
(dragstart)="onDragStart($event, connection)"
(dragend)="onDragEnd()"
class="flex w-full items-center gap-1.5 px-3 py-1.5 text-left text-xs hover:bg-accent"
[class.bg-accent]="draft()?.id === connection.id"
[class.opacity-50]="draggedId() === connection.id"
>
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span>
</button>
}
</div>
@if (!store.connections().length && !store.stacks().length) {
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('connMgr.noneTitle') }}
</p>
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('connMgr.noneBody') }}
</p>
}
</div>
</div>
<!-- edit form -->
<div class="flex min-w-0 flex-1 flex-col">
@if (draft(); as d) {
<div class="has-scrollbar grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5">
<label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
<input
[value]="d.name"
(input)="patch('name', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.namePlaceholder')"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.host') }}</span>
<input
[value]="d.host"
(input)="patch('host', $any($event.target).value)"
placeholder="192.168.1.10"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<div class="grid grid-cols-2 gap-3">
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.port') }}</span>
<input
type="number"
[value]="d.port"
(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"
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.stack') }}</span>
<select
(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"
>
<!-- [selected] per option: binding [value] on the <select> runs
before the options render and silently shows "(none)" -->
<option value="" [selected]="!d.stackId">{{ i18n.t('connMgr.stackNone') }}</option>
@for (stack of store.stacks(); track stack.id) {
<option [value]="stack.id" [selected]="d.stackId === stack.id">
{{ stack.name }}
</option>
}
</select>
</label>
</div>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.username') }}</span>
<input
[value]="d.username"
(input)="patch('username', $any($event.target).value)"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.remotePath') }}</span>
<input
[value]="d.initialPath"
(input)="patch('initialPath', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.remotePathPlaceholder')"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<div class="col-span-2 flex items-center gap-4 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.auth') }}</span>
<label class="flex items-center gap-1.5">
<input
type="radio"
name="authKind"
[checked]="d.authKind === 'password'"
(change)="patch('authKind', 'password')"
class="accent-primary"
/>
{{ i18n.t('connMgr.authPassword') }}
</label>
<label class="flex items-center gap-1.5">
<input
type="radio"
name="authKind"
[checked]="d.authKind === 'key'"
(change)="patch('authKind', 'key')"
class="accent-primary"
/>
{{ i18n.t('connMgr.authKey') }}
</label>
</div>
@if (d.authKind === 'password') {
<label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.password') }}</span>
<input
type="password"
[value]="d.password"
(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"
/>
</label>
} @else {
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.privateKeyFile') }}</span>
<div class="flex items-center gap-1">
<input
[value]="d.privateKeyPath"
(input)="patch('privateKeyPath', $any($event.target).value)"
placeholder="~/.ssh/id_ed25519"
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"
/>
<button
type="button"
(click)="pickKeyFile()"
class="h-7 shrink-0 rounded-md border border-border px-2 text-[0.6875rem] hover:bg-accent"
>
{{ i18n.t('common.choose') }}
</button>
</div>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.passphrase') }}</span>
<input
type="password"
[value]="d.passphrase"
(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"
/>
</label>
}
@if (moreOpen()) {
<label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.localDir') }}</span>
<input
[value]="d.localDir"
(input)="patch('localDir', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.localDirPlaceholder')"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
<span class="text-[0.625rem] text-muted-foreground/80">
{{ i18n.t('connMgr.localDirHint') }}
</span>
</label>
}
</div>
<div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5">
<button
(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"
>
<ng-icon name="mynaTrash" size="0.85rem" /> {{ i18n.t('common.delete') }}
</button>
<button
(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"
>
{{ moreOpen() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }}
</button>
<div class="flex-1"></div>
<button
(click)="save()"
[disabled]="!valid()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
>
{{ i18n.t('common.save') }}
</button>
<button
(click)="saveAndConnect()"
[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"
>
@if (busy()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
}
{{ i18n.t('common.connect') }}
</button>
</div>
} @else {
<div class="grid flex-1 place-items-center">
<div class="text-center">
<ng-icon name="mynaServers" size="1.75rem" class="text-muted-foreground" />
<p class="mt-2 text-xs text-muted-foreground">
{{ i18n.t('connMgr.selectPrompt') }}
</p>
</div>
</div>
}
</div>
</div>
<!-- in-window dialogs (safe here: they depend only on FocusService) -->
@if (dialogs.prompt(); as req) {
<app-prompt-dialog [request]="req" />
}
@if (dialogs.confirm(); as req) {
<app-confirm-dialog [request]="req" />
}
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
own handles. Delete with the rest of the hack — checklist in
window-resize/window-resize.ts. -->
<app-window-resize />
<app-toasts />
</div>
@@ -0,0 +1,291 @@
import { Component, computed, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import type { SavedConnection, Stack } from '@shared/types';
import { ConnectionsService } from '@core/connections.service';
import { ConfirmDialog } from '../dialogs/confirm-dialog';
import { PromptDialog } from '../dialogs/prompt-dialog';
import { DialogsService } from '@core/dialogs.service';
import { blockNativeContextMenu } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { TitleBar } from '../title-bar/title-bar';
import { Toasts } from '../toasts/toasts';
import { WindowResize } from '../window-resize/window-resize';
interface Draft {
id: string;
name: string;
stackId: string | null;
host: string;
port: number;
username: string;
authKind: 'password' | 'key';
password: string;
privateKeyPath: string;
passphrase: string;
initialPath: string;
localDir: string;
}
function toDraft(c: SavedConnection): Draft {
return {
id: c.id,
name: c.name,
stackId: c.stackId,
host: c.host,
port: c.port,
username: c.username,
// agent auth only exists on ~/.ssh/config hosts, which never reach this form
authKind: c.auth.kind === 'password' ? 'password' : 'key',
password: c.auth.kind === 'password' ? c.auth.password : '',
privateKeyPath: c.auth.kind === 'key' ? c.auth.privateKeyPath : '',
passphrase: c.auth.kind === 'key' ? (c.auth.passphrase ?? '') : '',
initialPath: c.initialPath ?? '',
localDir: c.localDir ?? '',
};
}
function fromDraft(d: Draft): SavedConnection {
return {
id: d.id,
name: d.name.trim() || `${d.username}@${d.host}`,
stackId: d.stackId,
host: d.host.trim(),
port: d.port || 22,
username: d.username.trim(),
auth:
d.authKind === 'password'
? { kind: 'password', password: d.password }
: {
kind: 'key',
privateKeyPath: d.privateKeyPath.trim(),
...(d.passphrase ? { passphrase: d.passphrase } : {}),
},
initialPath: d.initialPath.trim() || null,
localDir: d.localDir.trim() || null,
};
}
/**
* Root component of the connection-manager window (__APP_MODE === 'manager').
* Modal to the main window (enforced bun-side); connect/open-stack requests
* are executed by the MAIN window — this window never touches panels.
*/
@Component({
selector: 'app-root',
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts, TitleBar, WindowResize],
templateUrl: './connection-manager.html',
host: {
'(window:keydown.escape)': 'onEscape()',
'(window:keydown)': 'onWindowKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)',
// Aero desaturates an unfocused window's chrome — see .window.active.
'(window:focus)': 'windowFocused.set(true)',
'(window:blur)': 'windowFocused.set(false)',
},
})
export class ConnectionManager {
readonly i18n = inject(I18nService);
/** Drives `.window.active` on this window's Aero frame. */
readonly windowFocused = signal(document.hasFocus());
readonly store = inject(ConnectionsService);
readonly dialogs = inject(DialogsService);
private readonly rpc = inject(RpcService);
// injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root
private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
readonly draft = signal<Draft | null>(null);
readonly busy = signal(false);
readonly importing = signal(false);
/** The More… section (local directory) in the edit form. */
readonly moreOpen = signal(false);
/** Connection id being dragged between stacks. */
readonly draggedId = signal<string | null>(null);
/** Highlighted drop target: a stack id, 'loose', or null. */
readonly dropTarget = signal<string | null>(null);
readonly valid = computed(() => {
const d = this.draft();
if (!d) return false;
if (!d.host.trim() || !d.username.trim()) return false;
if (d.authKind === 'key' && !d.privateKeyPath.trim()) return false;
return true;
});
patch<K extends keyof Draft>(key: K, value: Draft[K]): void {
this.draft.update((d) => (d ? { ...d, [key]: value } : d));
}
newConnection(): void {
this.moreOpen.set(false);
this.draft.set({
id: '',
name: '',
stackId: null,
host: '',
port: 22,
username: 'root',
authKind: 'password',
password: '',
privateKeyPath: '',
passphrase: '',
initialPath: '',
localDir: '',
});
}
edit(connection: SavedConnection): void {
this.draft.set(toDraft(connection));
this.moreOpen.set(!!connection.localDir);
}
close(): void {
void this.rpc.request.managerClose({});
}
onEscape(): void {
// in-window dialogs (confirm/prompt) handle their own Escape first
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 {
blockNativeContextMenu(event);
}
async save(): Promise<string | null> {
const d = this.draft();
if (!d || !this.valid()) return null;
const id = await this.store.upsert(fromDraft(d));
if (id) this.patch('id', id);
return id;
}
async saveAndConnect(): Promise<void> {
if (!this.draft()) return;
this.busy.set(true);
try {
const id = await this.save();
if (!id) return;
// the main window connects (and this window closes); a failed connect
// surfaces as an error there
await this.rpc.request.managerConnect({ id });
} finally {
this.busy.set(false);
}
}
deleteCurrent(): void {
const d = this.draft();
if (!d?.id) {
this.draft.set(null);
return;
}
this.dialogs.confirm.set({
title: this.i18n.t('connMgr.deleteConnectionTitle'),
body: this.i18n.t('connMgr.deleteConnectionBody', { name: d.name || d.host }),
confirmLabel: this.i18n.t('common.delete'),
destructive: true,
onConfirm: async () => {
await this.store.remove(d.id);
this.draft.set(null);
},
});
}
async pickKeyFile(): Promise<void> {
const res = await this.rpc.request.pickKeyFile({});
if (res.ok && res.path) this.patch('privateKeyPath', res.path);
}
async importWinScp(): Promise<void> {
this.importing.set(true);
try {
await this.store.importWinScp();
} finally {
this.importing.set(false);
}
}
// ---- stacks ----
openStack(stackId: string): void {
// opened as tabs in the main window; bun closes this window after
void this.rpc.request.managerOpenStack({ id: stackId });
}
toggleStack(stack: Stack): void {
void this.store.upsertStack({ ...stack, collapsed: !stack.collapsed });
}
newStack(): void {
this.dialogs.prompt.set({
title: this.i18n.t('connMgr.newStackTitle'),
placeholder: this.i18n.t('connMgr.newStackPrompt'),
confirmLabel: this.i18n.t('fileActions.createConfirm'),
onConfirm: (name) => void this.store.upsertStack({ id: '', name }),
});
}
renameStack(stack: Stack): void {
this.dialogs.prompt.set({
title: this.i18n.t('connMgr.renameStackTitle'),
initial: stack.name,
confirmLabel: this.i18n.t('connMgr.renameStack'),
onConfirm: (name) => void this.store.upsertStack({ ...stack, name }),
});
}
deleteStack(stack: Stack): void {
this.dialogs.confirm.set({
title: this.i18n.t('connMgr.deleteStackTitle'),
body: this.i18n.t('connMgr.deleteStackBody', { name: stack.name }),
confirmLabel: this.i18n.t('common.delete'),
destructive: true,
onConfirm: () => void this.store.removeStack(stack.id),
});
}
// ---- drag & drop (move a connection between stacks / loose) ----
onDragStart(event: DragEvent, connection: SavedConnection): void {
this.draggedId.set(connection.id);
event.dataTransfer?.setData('text/plain', connection.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
}
onDragOver(event: DragEvent, target: string): void {
if (!this.draggedId()) return;
event.preventDefault();
this.dropTarget.set(target);
}
onDrop(event: DragEvent, stackId: string | null): void {
event.preventDefault();
const id = this.draggedId();
this.draggedId.set(null);
this.dropTarget.set(null);
const connection = this.store.connections().find((c) => c.id === id);
if (!connection || connection.stackId === stackId) return;
void this.store.upsert({ ...connection, stackId });
}
onDragEnd(): void {
this.draggedId.set(null);
this.dropTarget.set(null);
}
}
+220
View File
@@ -0,0 +1,220 @@
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
import { FocusService } from '@core/focus.service';
import { remPx } from '@core/metrics';
/** Rough per-item metrics in REM, used to size the menu and decide whether it
* must flip. Rem because the interface scale is a root font-size lever: the
* old `MENU_W = 200` was pinned into the DOM via [style.width.px], so the box
* stayed 200px while the text inside grew with the scale and overflowed.
* Values mirror the skinfix restatement of 7.css's menu metrics in 7like.css
* (item padding 0.25rem x2 + the ~0.9375rem line). */
const ITEM_H_REM = 1.5; // 24px @16
const DIVIDER_H_REM = 0.4375; // 7px @16: 2px bevel + 3px/2px margins
const MENU_W_REM = 12.5; // 200px @16
const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
/** One rendered <li>: separators collapse onto the item BEFORE them. */
interface MenuRow {
item: MenuItem;
hasDivider: boolean;
}
/**
* 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.
*
* The markup is 7.css's menu contract EXACTLY — `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). Get
* any of that wrong and 7.css silently styles nothing: an earlier version
* rendered div > button, concluded "7.css has no menu component", and
* hand-built a lookalike. It does have one; the selectors just never matched.
*
* `w7-ctx` is OURS, not 7.css's: `ul[role=menu]` carries `position: relative`
* in 7.css (for its gutter stripe and submenus), and the skin layer outranks
* Tailwind's `.fixed` — so the menu laid out in flow, at the bottom of the
* h-screen frame, off the viewport. The class scopes a `position: fixed`
* restatement in 7like.css to THIS menu; see scripts/menu-probe.ts.
*
* Keyboard follows the platform: arrows move REAL focus between items (7.css
* styles :focus-visible identically to :hover), Enter/Space on a button
* activate it natively; only labels need a synthetic click.
*/
@Component({
selector: 'app-context-menu',
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>
<ul
#menuEl
role="menu"
tabindex="-1"
(keydown)="onKeydown($event)"
[style.left.px]="position().x"
[style.top.px]="position().y"
[style.width.rem]="menuWidthRem"
class="w7-ctx fixed z-50 outline-none"
>
@for (row of rows(); track $index) {
<!-- aria-disabled on the LI, not the button: that is where 7.css
hangs its disabled state (opacity + pointer-events: none). -->
<li
role="menuitem"
[class.has-divider]="row.hasDivider"
[attr.aria-disabled]="row.item.disabled ? true : null"
[title]="row.item.tooltip ?? ''"
>
@if (row.item.checked !== undefined) {
<input type="checkbox" [id]="'ctx-check-' + $index" [checked]="row.item.checked" tabindex="-1" />
<label [for]="'ctx-check-' + $index" tabindex="-1" (click)="pick(row.item, $event)">
{{ row.item.label }}
</label>
} @else {
<button type="button" (click)="pick(row.item)">
{{ row.item.label }}
@if (row.item.accelerator) {
<span>{{ row.item.accelerator }}</span>
}
</button>
}
</li>
}
</ul>
}
`,
// 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<HTMLUListElement>>('menuEl');
/** Rendered width AND math input are the same rem value, so the flip point
* and the box can never disagree — the shadlike menu's old bug. */
protected readonly menuWidthRem = MENU_W_REM;
/** Model items → rendered rows: a separator becomes has-divider on its predecessor. */
readonly rows = computed<MenuRow[]>(() => {
const items = this.menu.request()?.items ?? [];
const rows: MenuRow[] = [];
for (const item of items) {
if (item.type === 'separator') {
if (rows.length) rows[rows.length - 1].hasDivider = true;
} else {
rows.push({ item, hasDivider: false });
}
}
return rows;
});
/** Click point, flipped/clamped so the menu never overflows the viewport.
* UI metrics convert through the live root font-size (remPx); the click
* point and window.inner* are both viewport CSS px already. */
readonly position = computed(() => {
const req = this.menu.request();
if (!req) return { x: 0, y: 0 };
const rows = this.rows();
const menuW = remPx(MENU_W_REM);
const height =
rows.length * remPx(ITEM_H_REM) +
rows.filter((r) => r.hasDivider).length * remPx(DIVIDER_H_REM) +
remPx(0.25); // ul padding 0.125rem 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.focus.bump();
}
pick(item: MenuItem, event?: Event): void {
// labels: don't let the click also toggle the real checkbox — the fresh
// `checked` state comes back through settings when the menu reopens
event?.preventDefault();
// 7.css already blocks the pointer on [aria-disabled]; this covers the
// keyboard path, which routes through focus and never consults CSS
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 {
switch (event.key) {
case 'Escape':
event.preventDefault();
event.stopPropagation();
this.close();
break;
case 'ArrowDown':
event.preventDefault();
this.step(1);
break;
case 'ArrowUp':
event.preventDefault();
this.step(-1);
break;
case 'Enter':
case ' ': {
// buttons activate natively from their own focus; labels don't, so
// synthesize the click (which routes through pick via the template)
const active = document.activeElement;
if (active instanceof HTMLLabelElement && this.menuEl()?.nativeElement.contains(active)) {
event.preventDefault();
active.click();
}
break;
}
}
}
/**
* Move REAL focus by one item, wrapping. 7.css styles :focus-visible the
* same as :hover, and keyboard-initiated programmatic focus counts as
* focus-visible, so the highlight comes from the framework for free.
*/
private step(delta: number): void {
const root = this.menuEl()?.nativeElement;
if (!root) return;
// disabled items are skipped, not merely unclickable: pointer-events:none
// does not make an element unfocusable, so arrows would still land on them
const targets = Array.from(
root.querySelectorAll<HTMLElement>(
'li:not([aria-disabled]) > button, li:not([aria-disabled]) > label',
),
);
if (!targets.length) return;
const current = targets.indexOf(document.activeElement as HTMLElement);
const next = current < 0 && delta < 0 ? targets.length - 1 : (current + delta + targets.length) % targets.length;
targets[next].focus();
}
}
@@ -0,0 +1,129 @@
import { Component, computed, inject, input, signal } from '@angular/core';
import { I18nService } from '@core/i18n.service';
import { DialogsService, type ChmodRequest } from '@core/dialogs.service';
import { FocusService } from '@core/focus.service';
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
const PERMS = ['r', 'w', 'x'] as const;
@Component({
selector: 'app-chmod-dialog',
template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div
class="w-80 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()"
>
<h2 class="text-sm font-semibold">{{ i18n.t('dialogs.chmodTitle') }}</h2>
<p class="mt-1 truncate text-xs text-muted-foreground">{{ targetLabel() }}</p>
<table class="mt-3 w-full text-xs">
<thead>
<tr class="text-muted-foreground">
<th class="pb-1 text-left font-normal"></th>
<th class="pb-1 font-normal">{{ i18n.t('dialogs.read') }}</th>
<th class="pb-1 font-normal">{{ i18n.t('dialogs.write') }}</th>
<th class="pb-1 font-normal">{{ i18n.t('dialogs.execute') }}</th>
</tr>
</thead>
<tbody>
@for (role of roles; track role; let r = $index) {
<tr>
<td class="py-1 pr-2 text-muted-foreground">{{ i18n.t('dialogs.' + role) }}</td>
@for (perm of perms; track perm; let p = $index) {
<td class="py-1 text-center">
<input
type="checkbox"
[checked]="hasBit(r, p)"
(change)="toggleBit(r, p)"
class="size-3.5 accent-primary"
/>
</td>
}
</tr>
}
</tbody>
</table>
<div class="mt-3 flex items-center gap-2">
<label class="text-xs text-muted-foreground">{{ i18n.t('dialogs.octal') }}</label>
<input
[value]="octal()"
(input)="setOctal($any($event.target).value)"
maxlength="4"
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"
/>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
(click)="cancel()"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('common.cancel') }}
</button>
<button
(click)="submit()"
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{{ i18n.t('common.apply') }}
</button>
</div>
</div>
</div>
`,
})
export class ChmodDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<ChmodRequest>();
readonly roles = ROLES;
readonly perms = PERMS;
private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService);
// mode is initialized lazily from the request on first read
private readonly modeOverride = signal<number | null>(null);
readonly mode = computed(() => this.modeOverride() ?? this.request().initialMode & 0o777);
readonly octal = computed(() => this.mode().toString(8).padStart(3, '0'));
targetLabel(): string {
const targets = this.request().targets;
return targets.length === 1
? targets[0]
: this.i18n.t('dialogs.chmodItems', { count: targets.length });
}
hasBit(role: number, perm: number): boolean {
return !!(this.mode() & (0o400 >> (role * 3 + perm)));
}
toggleBit(role: number, perm: number): void {
this.modeOverride.set(this.mode() ^ (0o400 >> (role * 3 + perm)));
}
setOctal(text: string): void {
if (/^[0-7]{1,4}$/.test(text)) {
this.modeOverride.set(parseInt(text, 8) & 0o777);
}
}
submit(): void {
const req = this.request();
const mode = this.mode();
this.close();
req.onConfirm(mode);
}
cancel(): void {
this.close();
}
private close(): void {
this.dialogs.chmod.set(null);
this.focus.bump();
}
}
@@ -0,0 +1,66 @@
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
import { I18nService } from '@core/i18n.service';
import { DialogsService, type ConfirmRequest } from '@core/dialogs.service';
import { FocusService } from '@core/focus.service';
@Component({
selector: 'app-confirm-dialog',
template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()"
>
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
<p class="mt-2 text-xs text-muted-foreground">{{ request().body }}</p>
<div class="mt-4 flex justify-end gap-2">
<button
(click)="cancel()"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('common.cancel') }}
</button>
<button
#confirmBtn
(click)="submit()"
[class]="
request().destructive
? 'w7-btn rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90'
: 'w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90'
"
>
{{ request().confirmLabel }}
</button>
</div>
</div>
</div>
`,
})
export class ConfirmDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<ConfirmRequest>();
private readonly confirmBtn = viewChild.required<ElementRef<HTMLButtonElement>>('confirmBtn');
private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService);
constructor() {
afterNextRender(() => this.confirmBtn().nativeElement.focus());
}
submit(): void {
const req = this.request();
this.close();
req.onConfirm();
}
cancel(): void {
this.close();
}
private close(): void {
this.dialogs.confirm.set(null);
this.focus.bump();
}
}
@@ -0,0 +1,149 @@
import {
Component,
ElementRef,
afterNextRender,
computed,
inject,
input,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service';
import { FocusService } from '@core/focus.service';
@Component({
selector: 'app-overwrite-dialog',
imports: [NgIcon],
template: `
<div
class="fixed inset-0 z-40 grid place-items-center bg-black/40"
(keydown.escape)="pick('cancel')"
>
<div
class="w-[26rem] rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
>
<div class="flex items-start justify-between gap-2">
<h2 class="min-w-0 text-sm font-semibold">
{{ isDir() ? i18n.t('dialogs.mergeFolder') : i18n.t('dialogs.replace') }}
<span class="font-mono">"{{ request().srcEntry.name }}"</span>?
</h2>
@if (request().total > 1) {
<span
class="shrink-0 rounded-full 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 }) }}
</span>
}
</div>
<p class="mt-1 text-xs text-muted-foreground">
@if (isDir()) {
{{ i18n.t('dialogs.overwriteDirBody') }}
} @else {
{{ i18n.t('dialogs.overwriteFileBody') }}
}
</p>
<!-- comparison -->
<div class="mt-3 grid grid-cols-2 gap-2">
@for (side of comparison(); track side.label) {
<div class="rounded-md border border-border p-2.5">
<div class="flex items-center justify-between">
<span class="text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
{{ i18n.t('dialogs.' + side.label) }}
</span>
@if (side.newer) {
<span class="rounded-full bg-success/15 px-1.5 text-[0.625rem] font-medium text-success">
{{ i18n.t('dialogs.newer') }}
</span>
}
</div>
<div class="mt-1.5 flex items-center gap-1.5 text-xs">
<ng-icon
[name]="isDir() ? 'mynaFolder' : 'mynaFile'"
size="0.9rem"
class="shrink-0 text-muted-foreground"
/>
<span class="font-mono tabular-nums">
{{ isDir() ? '—' : i18n.size(side.entry.size) }}
</span>
</div>
<div class="mt-1 font-mono text-[0.6875rem] tabular-nums text-muted-foreground">
{{ i18n.date(side.entry.mtime) }}
</div>
</div>
}
</div>
<!-- actions -->
<div class="mt-4 flex items-center gap-2">
<button
(click)="pick('cancel')"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ i18n.t('common.cancel') }}
</button>
<div class="flex-1"></div>
@if (request().total > 1) {
<button
(click)="pick('skipAll')"
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ i18n.t('dialogs.skipAll') }}
</button>
<button
(click)="pick('overwriteAll')"
class="rounded-md 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') }}
</button>
<div class="h-4 w-px bg-border"></div>
}
<button
(click)="pick('skip')"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('dialogs.skip') }}
</button>
<button
#primaryBtn
(click)="pick('overwrite')"
class="w7-btn rounded-md 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') }}
</button>
</div>
</div>
</div>
`,
})
export class OverwriteDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<OverwriteRequest>();
private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService);
private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn');
constructor() {
afterNextRender(() => this.primaryBtn().nativeElement.focus());
}
readonly isDir = computed(() => this.request().srcEntry.type === 'dir');
readonly comparison = computed(() => {
const { srcEntry, dstEntry } = this.request();
return [
{ label: 'source', entry: srcEntry, newer: srcEntry.mtime > dstEntry.mtime },
{ label: 'destination', entry: dstEntry, newer: dstEntry.mtime > srcEntry.mtime },
];
});
pick(choice: OverwriteChoice): void {
const req = this.request();
this.dialogs.overwrite.set(null);
this.focus.bump();
req.resolve(choice);
}
}
@@ -0,0 +1,86 @@
import {
Component,
ElementRef,
afterNextRender,
inject,
input,
signal,
viewChild,
} from '@angular/core';
import { I18nService } from '@core/i18n.service';
import { DialogsService, type PromptRequest } from '@core/dialogs.service';
import { FocusService } from '@core/focus.service';
@Component({
selector: 'app-prompt-dialog',
template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
>
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
<input
#box
[value]="value()"
(input)="value.set($any($event.target).value)"
(keydown.enter)="submit()"
(keydown.escape)="cancel()"
[placeholder]="request().placeholder ?? ''"
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"
/>
<div class="mt-4 flex justify-end gap-2">
<button
(click)="cancel()"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('common.cancel') }}
</button>
<button
(click)="submit()"
[disabled]="!value().trim()"
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{{ request().confirmLabel }}
</button>
</div>
</div>
</div>
`,
})
export class PromptDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<PromptRequest>();
readonly value = signal('');
private readonly box = viewChild.required<ElementRef<HTMLInputElement>>('box');
private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService);
constructor() {
afterNextRender(() => {
this.value.set(this.request().initial ?? '');
const el = this.box().nativeElement;
el.focus();
el.select();
});
}
submit(): void {
const value = this.value().trim();
if (!value) return;
const req = this.request();
this.close();
req.onConfirm(value);
}
cancel(): void {
this.close();
}
private close(): void {
this.dialogs.prompt.set(null);
this.focus.bump();
}
}
@@ -0,0 +1,212 @@
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
@if (!ctx) {
<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">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ i18n.t('editor.noFileBody') }}
</p>
</div>
</div>
} @else {
<!-- The window is frameless. The X runs the SAME dirty prompt as ⌘W and
the Close button — never a raw window close. -->
<app-title-bar [title]="ctx.name + (dirty() ? ' *' : '')" (closeRequested)="requestClose()" />
<!-- Toolbar strip under the title bar. h-9/pl-3 are load-bearing: without a
height this row collapses onto the icon and the screen reads as broken. -->
<header
class="w7-toolbar flex h-9 shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2 pl-3"
>
<ng-icon name="mynaFileText" size="0.95rem" class="shrink-0 text-muted-foreground" />
<h1 class="min-w-0 truncate font-mono text-sm font-semibold">
{{ ctx.name }}{{ dirty() ? ' •' : '' }}
</h1>
<span class="shrink-0 text-[0.6875rem] text-muted-foreground">{{ ctx.sessionTitle }}</span>
<div class="flex-1"></div>
<button
(click)="toggleWrap()"
[title]="i18n.t('editor.wrapHint', { alt: altLabel })"
class="rounded-md px-2 py-1 text-[0.6875rem] electrobun-webkit-app-region-no-drag hover:bg-accent"
[class.bg-accent]="wrap()"
[class.text-foreground]="wrap()"
[class.text-muted-foreground]="!wrap()"
>
{{ i18n.t('editor.wrap') }}
</button>
<button
(click)="openFind()"
[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"
>
<ng-icon name="mynaSearch" size="0.85rem" />
</button>
</header>
@if (findOpen()) {
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome px-3 py-1.5">
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
<input
#findBox
[value]="findQuery()"
(input)="onFindInput($any($event.target).value)"
(keydown)="onFindKeydown($event)"
[placeholder]="i18n.t('editor.findPlaceholder')"
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"
/>
<span
class="text-[0.6875rem] tabular-nums"
[class.text-destructive]="findQuery() && !matches().length"
[class.text-muted-foreground]="!findQuery() || matches().length"
>
{{ countLabel() }}
</span>
<button
(click)="findStep(-1)"
[title]="i18n.t('editor.prevMatch')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaChevronUp" size="0.8rem" />
</button>
<button
(click)="findStep(1)"
[title]="i18n.t('editor.nextMatch')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaChevronDown" size="0.8rem" />
</button>
<div class="flex-1"></div>
<button
(click)="closeFind()"
[title]="i18n.t('editor.closeHint')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.8rem" />
</button>
</div>
}
@if (sessionDead()) {
<div
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" />
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
</div>
}
@if (loading()) {
<div class="flex flex-1 items-center justify-center">
<ng-icon name="mynaSpinner" size="1.25rem" class="animate-spin text-primary" />
</div>
} @else if (loadError(); as error) {
<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">
<ng-icon name="mynaXCircle" 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-1 text-xs text-muted-foreground">{{ error }}</p>
<button
(click)="discardAndClose()"
class="mt-4 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('editor.closeWindow') }}
</button>
</div>
</div>
} @else {
<div class="flex min-h-0 flex-1">
<!-- gutter and textarea share font/leading/vertical padding — that's
the whole alignment contract; keep the classes in lockstep -->
@if (!wrap()) {
<div
#gutter
aria-hidden="true"
[style.width.ch]="gutterDigits() + 3"
class="shrink-0 overflow-hidden border-r border-border py-4 pr-2 text-right font-mono text-xs leading-relaxed whitespace-pre text-muted-foreground select-none"
>{{ gutterText() }}</div>
}
<textarea
#box
[value]="text()"
(input)="onInput($any($event.target).value)"
(scroll)="onScroll()"
spellcheck="false"
[attr.wrap]="wrap() ? 'soft' : 'off'"
class="min-h-0 flex-1 resize-none overflow-auto bg-transparent px-4 py-4 font-mono text-xs leading-relaxed outline-none"
[class.whitespace-pre]="!wrap()"
></textarea>
</div>
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2">
<span class="text-[0.6875rem] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
<span class="text-[0.6875rem] text-muted-foreground">·</span>
<span class="text-[0.6875rem] tabular-nums text-muted-foreground">
{{ i18n.t('editor.lines', { count: lineCount() }) }}
</span>
<div class="flex-1"></div>
<button
(click)="requestClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('editor.close') }}
</button>
<button
(click)="save()"
[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"
>
@if (saving()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
}
{{ i18n.t('common.save') }}
</button>
</footer>
}
<!-- dirty-close prompt. Component-local on purpose: the shared confirm
dialog pulls in PanelsService, which must not boot in this window. -->
@if (closePrompt()) {
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()">
<div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
>
<h2 class="text-sm font-semibold">{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}</h2>
<p class="mt-1.5 text-xs text-muted-foreground">
{{ i18n.t('editor.saveChangesBody') }}
</p>
<div class="mt-4 flex items-center gap-2">
<button
(click)="discardAndClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent"
>
{{ i18n.t('editor.dontSave') }}
</button>
<div class="flex-1"></div>
<button
(click)="cancelClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('common.cancel') }}
</button>
<button
(click)="saveAndClose()"
[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"
>
{{ i18n.t('common.save') }}
</button>
</div>
</div>
</div>
}
}
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
own handles. Delete with the rest of the hack — checklist in
window-resize/window-resize.ts. -->
<app-window-resize />
<app-toasts />
</div>
@@ -0,0 +1,389 @@
import {
Component,
DOCUMENT,
ElementRef,
afterNextRender,
computed,
effect,
inject,
signal,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, MOD_LABEL } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { ToastService } from '@core/toast.service';
import { TitleBar } from '../title-bar/title-bar';
import { Toasts } from '../toasts/toasts';
import { WindowResize } from '../window-resize/window-resize';
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
const DRAFT_DEBOUNCE_MS = 400;
/**
* Root component of an editor window (window.__APP_MODE === 'editor').
*
* Deliberately injects only Rpc/Toast/Settings: main-window root services
* (PanelsService, ConnectionsService, TransfersService — and the dialogs that
* inject them) have constructor side effects like creating workspaces and
* loading connections that must not run here.
*/
@Component({
selector: 'app-root',
imports: [NgIcon, Toasts, TitleBar, WindowResize],
templateUrl: './editor-window.html',
host: {
'(window:keydown)': 'onKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)',
// Aero desaturates an unfocused window's chrome — see .window.active.
// blur already had to flush the draft; both run from the one binding
// (a duplicated key is a TS1117 build error, not a merge).
'(window:focus)': 'windowFocused.set(true)',
'(window:blur)': 'flushDraft(); windowFocused.set(false)',
},
})
export class EditorWindow {
readonly i18n = inject(I18nService);
/** Drives `.window.active` on this window's Aero frame. */
readonly windowFocused = signal(document.hasFocus());
readonly ctx = EDITOR_CONTEXT;
readonly modLabel = MOD_LABEL;
readonly altLabel = ALT_LABEL;
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly text = signal('');
readonly savedContent = signal('');
readonly dirty = computed(() => this.text() !== this.savedContent());
readonly saving = signal(false);
readonly closePrompt = signal(false);
readonly sessionDead = signal(false);
/** Soft-wrap long lines. Hides the gutter: correct wrapped line numbers
* would need per-edit height measurement — not worth it without an editor lib. */
readonly wrap = signal(false);
readonly findOpen = signal(false);
readonly findQuery = signal('');
readonly activeMatch = signal(0);
/** Line count without allocating a split array on every keystroke. */
readonly lineCount = computed(() => {
const t = this.text();
let n = 1;
let i = -1;
while ((i = t.indexOf('\n', i + 1)) !== -1) n++;
return n;
});
readonly gutterText = computed(() =>
Array.from({ length: this.lineCount() }, (_, i) => i + 1).join('\n'),
);
readonly gutterDigits = computed(() => String(this.lineCount()).length);
/** Case-insensitive match start offsets for the find query. */
readonly matches = computed(() => {
const query = this.findQuery().toLowerCase();
if (!query) return [];
const hay = this.text().toLowerCase();
const out: number[] = [];
let i = hay.indexOf(query);
while (i !== -1) {
out.push(i);
i = hay.indexOf(query, i + query.length);
}
return out;
});
readonly countLabel = computed(() =>
this.matches().length ? `${this.activeMatch() + 1}/${this.matches().length}` : '0/0',
);
private readonly box = viewChild<ElementRef<HTMLTextAreaElement>>('box');
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
private cachedCharWidth = 0;
private cachedCharFont = '';
private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService);
private readonly document = inject(DOCUMENT);
// injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root
private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
private draftTimer: ReturnType<typeof setTimeout> | null = null;
private lastSentDirty = false;
constructor() {
const ctx = this.ctx;
if (!ctx) return; // opened outside the app (plain browser) — error card renders
const baseTitle = `${ctx.name} — ${ctx.sessionTitle}`;
effect(() => {
this.document.title = `${this.dirty() ? '• ' : ''}${baseTitle}`;
});
this.rpc.onEditorCloseRequest(() => this.requestClose());
this.rpc.onSessionClosed((event) => {
if (event.sessionId === ctx.sessionId) this.sessionDead.set(true);
});
afterNextRender(() => void this.load(ctx.sessionId, ctx.path));
}
private async load(sessionId: string, path: string): Promise<void> {
const res = await this.rpc.request.readFile({ sessionId, path });
if (!res.ok) {
this.loadError.set(res.error);
this.loading.set(false);
return;
}
this.savedContent.set(res.content);
this.text.set(res.content);
this.loading.set(false);
queueMicrotask(() => this.box()?.nativeElement.focus());
}
blockContextMenu(event: Event): void {
blockNativeContextMenu(event);
}
onInput(value: string): void {
this.text.set(value);
this.scheduleDraft();
}
/** Keep the line-number gutter locked to the textarea's scroll position. */
onScroll(): void {
const gutter = this.gutter()?.nativeElement;
if (gutter) gutter.scrollTop = this.box()!.nativeElement.scrollTop;
}
toggleWrap(): void {
this.wrap.update((w) => !w);
queueMicrotask(() => this.onScroll());
}
// ---- find ----
openFind(): void {
const el = this.box()?.nativeElement;
// prefill from the current selection when it's a sane single-line snippet
if (el && el.selectionStart !== el.selectionEnd) {
const selected = el.value.slice(el.selectionStart, el.selectionEnd);
if (selected.length < 200 && !selected.includes('\n')) this.findQuery.set(selected);
}
this.findOpen.set(true);
this.activeMatch.set(0);
queueMicrotask(() => {
const input = this.findBox()?.nativeElement;
input?.focus();
input?.select();
});
}
closeFind(): void {
this.findOpen.set(false);
// selection was left on the match — the caret lands there
this.box()?.nativeElement.focus();
}
onFindInput(value: string): void {
this.findQuery.set(value);
const list = this.matches();
if (!list.length) return;
// jump to the first match at/after the caret, else wrap to the first
const from = this.box()?.nativeElement.selectionStart ?? 0;
const at = list.findIndex((start) => start >= from);
this.goTo(at >= 0 ? at : 0);
}
findStep(delta: 1 | -1): void {
const count = this.matches().length;
if (!count) return;
this.goTo((this.activeMatch() + delta + count) % count);
}
onFindKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter') {
event.preventDefault();
this.findStep(event.shiftKey ? -1 : 1);
} else if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.closeFind();
}
}
/** Select match `index` and scroll it into the middle of the viewport. */
private goTo(index: number): void {
const el = this.box()?.nativeElement;
const start = this.matches()[index];
if (!el || start === undefined) return;
this.activeMatch.set(index);
el.setSelectionRange(start, start + this.findQuery().length);
const text = this.text();
// 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 i = text.indexOf('\n');
while (i !== -1 && i < start) {
line++;
i = text.indexOf('\n', i + 1);
}
el.scrollTop = Math.max(0, line * lineHeight - el.clientHeight / 2);
if (!this.wrap()) {
const column = start - (text.lastIndexOf('\n', start - 1) + 1);
el.scrollLeft = Math.max(0, column * this.charWidth(el) - el.clientWidth / 2);
}
this.onScroll();
}
/** 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 {
const font = getComputedStyle(el).font;
if (font !== this.cachedCharFont) {
const canvas = this.document.createElement('canvas').getContext('2d')!;
canvas.font = font;
this.cachedCharWidth = canvas.measureText('0').width || parseFloat(getComputedStyle(el).fontSize) * 0.6;
this.cachedCharFont = font;
}
return this.cachedCharWidth;
}
// ---- draft mirror: bun holds the latest content for the native-close prompt ----
private scheduleDraft(): void {
if (this.dirty() !== this.lastSentDirty) {
// dirty flag flipped — tell bun immediately so the native title updates
this.flushDraft();
return;
}
if (this.draftTimer) clearTimeout(this.draftTimer);
this.draftTimer = setTimeout(() => this.flushDraft(), DRAFT_DEBOUNCE_MS);
}
flushDraft(): void {
if (!this.ctx || this.loading()) return;
if (this.draftTimer) {
clearTimeout(this.draftTimer);
this.draftTimer = null;
}
this.lastSentDirty = this.dirty();
this.rpc.send.editorState({ dirty: this.dirty(), content: this.text() });
}
// ---- save / close ----
async save(): Promise<boolean> {
if (!this.dirty() || this.saving()) return true;
const content = this.text();
this.saving.set(true);
try {
const res = await this.rpc.request.editorSave({ content });
if (!res.ok) {
this.toast.error(res.error);
return false;
}
this.savedContent.set(content);
this.lastSentDirty = false; // bun record already cleaned by the handler
this.toast.success(this.i18n.t('toasts.saved', { name: this.ctx!.name }));
return true;
} finally {
this.saving.set(false);
}
}
/** ⌘W / Close button / bun's editorCloseRequest: prompt if dirty, else close. */
requestClose(): void {
if (this.dirty()) {
this.closePrompt.set(true);
} else {
void this.rpc.request.editorClose({});
}
}
async saveAndClose(): Promise<void> {
if (await this.save()) void this.rpc.request.editorClose({});
}
discardAndClose(): void {
void this.rpc.request.editorClose({});
}
cancelClose(): void {
this.closePrompt.set(false);
queueMicrotask(() => this.box()?.nativeElement.focus());
}
// ---- keyboard ----
onKeydown(event: KeyboardEvent): void {
// Chord shortcuts resolve through the shared keymap (ShortcutsService);
// 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();
switch (match.id) {
case 'save':
void this.save();
break;
case 'find':
this.openFind();
break;
case 'close-window':
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W
// and bun routes it back as editorCloseRequest
this.requestClose();
break;
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();
this.cancelClose();
} else if (event.key === 'Escape' && this.findOpen()) {
event.preventDefault();
this.closeFind();
} else if (event.key === 'Tab' && event.target === this.box()?.nativeElement) {
// keep Tab as an input character inside the editor
event.preventDefault();
this.insertTab();
}
}
private insertTab(): void {
const el = this.box()!.nativeElement;
// execCommand keeps the native undo stack intact where supported
if (!this.document.execCommand('insertText', false, '\t')) {
const { selectionStart, selectionEnd, value } = el;
el.value = `${value.slice(0, selectionStart)}\t${value.slice(selectionEnd)}`;
el.selectionStart = el.selectionEnd = selectionStart + 1;
}
this.onInput(el.value);
}
}

Some files were not shown because too many files have changed in this diff Show More