Compare commits

...
88 Commits
Author SHA1 Message Date
jgamboaandClaude 5be41db032 Redraw every icon on the pixel grid it renders at
On a 1x screen TDesign's glyphs smeared at 14 and 16px: their 24-unit
strokes land between pixels, so every ~1px line covered two at half ink.
All 37 icon/size pairs are now TDesign's shapes redrawn on their own grid
(viewBox 0 0 N N, so one unit is one CSS pixel):

- Straight lines are runs of whole pixels that end on pixel edges, with
  no caps or joins. WebKit drops square caps on a path of several
  subpaths, and Skia strokes 1px lines as hairlines that leave a joined
  corner pixel three-quarters inked.
- Slants and curves are 1.25px so they read as heavy as the crisp runs.
  Their ends, vertices and circle extremes sit on pixel centres.
- server24 keeps TDesign's 24px geometry; only its dots move onto whole
  pixels.

Only closeCircle24 (TDesign's own 24px grid) and the spinner still come
from @ng-icons/tdesign-icons. This also fixes add14 from 5d23809, whose
square-capped ends rendered half-inked in WebKit. icons.spec.ts now also
checks that every grid name's glyph uses that grid's viewBox.

Checked at 1x and 2x in WebKit (the app's macOS engine) and in Chrome
(Skia, as in WebView2 and WebKitGTK); straight runs fill whole pixels in
both. In WebKit at 1x, a copy of the tab bar, toolbar, file rows, menu
and shortcut-row markup rendered every straight-line glyph with zero
blur, including icons laid out on fractional pixels.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-09 13:49:42 +02:00
jgamboaandClaude 5d23809d48 Name every icon by the pixel grid it is drawn for
Groundwork for redrawing the icons on their pixel grids. A name is now
<icon><px> (server14, server16, server24) and must match the size token
where it renders: 14 at --icon-size-sm, 16 at -md, 24 at -xl.
tdesignLoading is the one exception, since it spins. Every name not yet
redrawn aliases its TDesign glyph, so nothing changes on screen except
the manager-open overlay icon, which moves from lg to xl ("a status
mark on a card").

- pixel-icons.ts holds the redrawn glyphs, so far add14 (the + from
  3f7145d, renamed from pixelAdd).
- TS code that picks icons takes Icon14 / Icon16 (menu items, file
  glyphs, the copy arrow, toasts, transfer states), so build:ui
  rejects a wrong name.
- icons.spec.ts checks every <ng-icon> in the skins: it has a size
  token, and each literal name, both sides of a [name] ternary
  included, is registered and drawn for that token's grid.
- TDesign's file1 is now fileText, since file1 plus a suffix would
  read file114.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-09 13:24:38 +02:00
jgamboaandClaude 3f7145d69a Draw the + on the pixel grid so it stays sharp on 1x screens
On a 1x display TDesign's + smeared at every small size: its 24-grid
strokes are centred on the box's middle, which at 12 or 14px is a pixel
boundary, so each ~1px bar covered two pixels at half ink. A thinner
stroke alone keeps the bar on the boundary. This is not Electrobun:
headless Chrome renders the same.

pixelAdd (icons.ts) redraws the + on the 14px grid with 1px bars at 7.5,
so they fill whole pixels at 1x and 2x, half a pixel off-centre. It
replaces tdesignAdd in the tab bar, the shortcut rows, the connection
manager and the tab menu. Checked at 1x in the real tab-bar and
shortcut-row markup: single-pixel bars at full ink.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-09 12:09:20 +02:00
jgamboaandClaude fa2cec7136 Size every icon on TDesign's scale: four rem tokens, whole pixels
TDesign draws its glyphs on a 24px grid with a 2px stroke and sizes a
component's icon 2px above its text, in whole pixels. The templates had
drifted to 12 one-off sizes, most of them fractional (0.9rem = 14.4px), so
strokes blurred and neighbours never matched: the tab bar's 14.4px cog
beside a 12.8px +, the shortcut rows' 12px + beside an 11.2px reset and
9.6px chip x.

Every <ng-icon> now takes one of --icon-size-sm (14px: beside 11-12px
text, small icon buttons), -md (16px: file rows, window titles, path-bar
buttons), -lg (20px) or -xl (24px: lone spinners and status marks).
--action-icon goes; architecture.md and gotchas.md carry the rule.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-09 10:19:58 +02:00
jgamboaandClaude e28314054f Drive the connection manager from the keyboard
The manager had only Esc and the scale keys: its list took one Tab stop
per row with no arrows, a stack's rename / delete / open-in-tabs buttons
existed only on hover, and no action had a key.

- Commands (the manager keymap scope, remappable like the rest): new
  connection with focus in Name, new stack, save, connect — or open the
  focused stack in tabs — delete the focused row, rename the focused
  stack, import from WinSCP, close the window.
- The list: up/down/Home/End move focus between rows (focus only:
  loading a connection would discard a half-edited form), Enter opens the
  focused connection, left/right collapse and expand a stack, and a
  stack's actions show while the keyboard is in its row.
- Enter in a form field connects; Esc leaves a field before it closes the
  window; Tab is walked by hand (WKWebView skips buttons and radios); focus
  comes back after a dialog and never stays lost on <body>.
- Tooltips show the keys; the Settings → Keyboard fixed-key list gains the
  manager's.

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-05 14:57:27 +02:00
jgamboaandClaude d361297690 Put every shortcut on one shared keymap and make the app keyboard-reachable
The keymap was scattered over ShortcutsService if-chains, the file panel's
key switch, macOS-only menu accelerators and hand-typed hints in 16
tooltips x 7 locales, and large parts of the app had no keyboard path at
all. src/shared/keymap.ts is now the one table: scopes, per-platform
defaults, exact matching, formatting with localized key names, conflicts,
reserved combos and native-menu accelerators, proven by keymap.spec.ts.

- ShortcutsService resolves against the effective keymap: claim() takes
  the key (preventDefault, held keys repeat only movement, never mid-IME),
  and from a text field only chords and F-keys fire. The file panel, App,
  editor, manager and Settings windows all dispatch through it.
- FileActionsService.run is the one file-command dispatcher for the list
  keys, the row menu, the macOS menu bar and the toolbar.
- New keys: the row menu (Shift+F10 / Menu / Ctrl+Return), the source
  picker (Mod+K), the path bar (Mod+L), transfers (Mod+J), sorting
  (Alt+1-7), Shift+movement selection, close other tabs, move and
  disconnect tabs, refresh and edit on Windows/Linux, permissions, copy
  path, reveal, trash, open in default app, editor find next/previous.
- Right-click menus: soft shortcut hints, icons on the everyday verbs
  (the toolbar's glyphs), section headers, scrolling, item keys while
  open, a highlighted first item when opened from the keyboard. The panel
  source picker now renders through the same menu.
- Dialogs: role=dialog, a Tab trap via cycleFocus (WKWebView skips
  buttons on Tab without Keyboard navigation), initial focus, Esc that
  stops propagating; the chmod dialog takes focus and applies on Enter.
- Fixed: Ctrl+Tab also flipped the active panel; Esc in a manager dialog
  closed the manager; Ctrl-F/T fired Cmd shortcuts on macOS; shortcuts
  fired under dialogs; keys died after any button click on macOS; the
  editor's save prompt and find box never got focus; WebView2 reload keys;
  a window could save default settings before its first load.
- Settings closes on its own close-window key (new settingsClose request).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-05 14:48:11 +02:00
jgamboaandClaude 0eb5608873 Migrate to Electrobun 2.0.2 (Hutch)
Electrobun 2.x replaces the vendored CLI with Hutch: the npm package is now a
bootstrap whose exports are a throwing stub, and the SDK is projected into
.hutch/devkit. The app stays on the Bun main process — `mainProcess: "bun"` is
load-bearing, since the 2.x default (Cottontail) ignores build.bun and with it
the ssh2 externals — and the bundle now ships the pinned Bun 1.4.0.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-05 09:55:15 +02:00
jgamboaandClaude f3bec7aeaf Shade the window frame, panel headers and file rows apart
Every surface was one flat shade. The window now reads in layers: a darker
frame (tab, transfer and status bars), a lighter header strip per file panel
(session strip, path bar, column headers), the file list, and popovers above
them all. The active panel's header carries a tint of the accent, and odd file
rows get a faint zebra stripe (per row, so it stays aligned at any interface
scale; the cursor and selection replace it).

Hover and selected states become a translucent overlay: an opaque hover
vanished on a surface of its own lightness (light's header strip). Dark cards
and badges step up so popovers never read darker than the strip they cover.
AMOLED is unchanged: every new layer collapses to black there.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-23 16:17:38 +02:00
JokinandClaude 6c05e39ce1 Re-sign electrobun's CLI binary so the app actually launches
`bun run start` printed nothing and exited 0 without ever opening a window.
electrobun's prebuilt CLI is a Bun single-file executable whose payload is
appended after the linker ad-hoc signed it, so `codesign -v` reports "code or
signature have been modified" and the macOS kernel SIGKILLs it at exec. The
failure was invisible because bin/electrobun.cjs spawns the real binary and
forwards `process.exit(code || 0)` — a signal death has `code === null`.

Confirmed upstream, not local corruption: a fresh download from the GitHub
release hashes identical to the shipped copy and fails the same check. Both
the darwin-arm64 and darwin-x64 CLI tarballs are affected.

scripts/fix-electrobun-cli.ts re-seals the binary with `codesign --force
--sign -`. It runs from postinstall AND ahead of every electrobun invocation,
because the wrapper downloads the CLI lazily on first use rather than at
install time — postinstall alone would no-op on a fresh checkout. When the
binary is missing it provokes that download first, so a clean tree self-heals.
It also fixes .cache/electrobun, which the wrapper restores bin/ from, and
release.ts re-signs the Rosetta x64 CLI on download and on cache reuse.

Idempotent (~0.3s when already valid) and a no-op off macOS. Verified from a
deleted-binary state: `bun run start` downloads, re-signs and launches.

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

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

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

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

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

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

Three things that would have made this quietly wrong:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The app CANNOT apply this to itself. WebKit reads the variable when it forks
its web process, and Bun's `process.env` writes do not propagate to child
processes (verified: a spawned child sees it unset), so setting it in
src/bun/index.ts is inert even from a module imported before `electrobun/bun`.
It has to be in the environment before the app process starts, which means
every launch path must inject it:

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-14 15:02:27 +02:00
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
jgamboa 39b1858957 connection manager paragraphs tweak 2026-07-14 12:39:26 +02:00
jgamboa 5863ae1079 Revert back to more informative text in Import button 2026-07-14 10:22:56 +02:00
jgamboa 2d95857de1 Button ui tweaks 2026-07-14 09:43:06 +02:00
JokinandClaude Fable 5 ef34104cf0 Fix white/inverted flash when windows open
Three layers: windows are created hidden and revealed on the webview's
first dom-ready (800ms fallback); the bun preload bakes the persisted
theme data-attributes so the first paint is already themed; and
index.html carries an inline background style covering the
pre-stylesheet paint (values mirrored from styles.css tokens).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 09:17:31 +02:00
JokinandClaude Fable 5 dc5cb1f707 Developer documentation: architecture, build/release, platforms, gotchas
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:25:38 +02:00
JokinandClaude Fable 5 2585dd128b Multi-arch release script + Gitea workflow; retire legacy BunSCP name
bun run release -- --target mac-arm64|mac-x64|win-x64: gen version/icons,
build UI, electrobun build --env=stable, collect artifacts as
release/<target>/miniSCP-Build<n>-<target>.*. mac-x64 builds on the
arm64 Mac by running electrobun's x64 CLI under Rosetta 2 (verified:
x86_64 launcher in the produced app). Windows also zips a portable
build next to the Setup installer. Tag-triggered Gitea Actions matrix
included, inert until runners exist. legacyNames emptied — the BunSCP
migration has served its purpose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:25:10 +02:00
JokinandClaude Fable 5 c7271fb7cf Windows compatibility pass (beta target)
External editor via cmd/start + exe picker; Permissions hidden for
Windows-local (chmod only flips read-only there); Explorer/Recycle Bin
wording; Ctrl vs Cmd shortcut labels; separator-aware path joins +
drive-root detection; drives list in the panel source picker; icon.ico
+ colored tray icon; icacls ACL on connections.json; README Windows
(beta) section with upstream known issues and a smoke-test checklist.
Runtime paths pending first boot on a real Windows machine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:02:42 +02:00
JokinandClaude Fable 5 d80d5c23dc Sublime-style Build-n versioning + About box
Build n = commit count, baked at build time into a gitignored
version.gen.ts (postinstall + all build scripts regenerate it). About
lives in the macOS app menu and as a footer row in the options popover
(the cross-platform path), showing build, commit, date and engines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:55:39 +02:00
JokinandClaude Fable 5 14b92b33a4 Suppress the webview's default right-click menu
Reload/Inspect Element no longer appear on uncontrolled right-clicks
in any window; text fields keep the system text menu.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:30:23 +02:00
JokinandClaude Fable 5 ab59c785b5 Configurable list columns via header right-click
Native checkable menu on the column headers toggles Size, Modified,
Kind, Rights, Owner and Group (persisted, both panels). Owner/group
come from the ls-style SFTP longname remotely and cached uid/gid
lookups locally (POSIX; undefined on Windows). All columns sort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:30:23 +02:00
JokinandClaude Fable 5 485603fd30 Saved connections and stacks open remote on the right panel
Local left, remote right — the commander convention. Panel-dropdown
connects still target the panel they were opened from.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:18:34 +02:00
JokinandClaude Fable 5 d22bc9b4e2 Connection manager design refresh; fix stack-row button crowding
Import moves to the window header; sidebar widens with a muted tint;
the stack row's count and action buttons swap on hover instead of
sharing the row, so long names can't collide with them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:13:53 +02:00
JokinandClaude Fable 5 7dd59a9752 Connection manager in its own native window, modal to main
Single window centered over the main app. Electrobun has no native
modality, so it's approximated: bun bounces the main window's focus back
to the manager, the main webview shows a click-swallowing overlay, and
main-targeted menu/keyboard actions are dropped while open. Editor
windows stay fully usable. Connect/open-stack execute in the main
window via the existing connect: action channel; store edits push a
connectionsChanged refresh. Trigger moved from the panel dropdown to a
tab-bar button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:11:58 +02:00
Jokin 27d0ac81d6 file icons setting text 2026-07-13 23:04:16 +02:00
JokinandClaude Fable 5 62ff845944 Decouple dialog refocus from PanelsService via FocusService
Dialogs only needed refocus(); injecting PanelsService boots a whole
workspace, which secondary windows must not do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:02:25 +02:00
JokinandClaude Fable 5 93a9b07722 Editor: live settings sync + session-death banner
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:39:31 +02:00
JokinandClaude Fable 5 6a19097b25 Editor: line numbers, wrap toggle, find bar
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:38:38 +02:00
JokinandClaude Fable 5 1652b9e298 Editor opens in native windows (one per file)
Real BrowserWindow per edited file with its own RPC instance, deduped by
sessionId+path. The same Angular bundle boots an editor-only root via
preload globals. Native close isn't cancellable in electrobun, so the
editor mirrors its draft to bun; traffic-light closes get a native
Save/Discard prompt after the fact, and failed saves park the draft in
the edit-leftovers dir. In-window closes (Cmd+W, Close) use a styled
prompt; the quit guard now also counts unsaved editors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 22:36:11 +02:00
141 changed files with 14592 additions and 3648 deletions
+47
View File
@@ -0,0 +1,47 @@
# Release matrix — INERT until act_runner instances with these labels are
# registered on git.teknes.es. Each OS builds its own artifacts (electrobun
# cannot cross-compile: packaging needs codesign/hdiutil on macOS and
# rcedit/PowerShell on Windows). No mac-x64: Electrobun 2.x has no macOS x64
# core.
#
# Runner prerequisites:
# macos — Apple Silicon Mac: bun, Xcode CLT, network to github.com and the
# Electrobun/Hutch release hosts (`bun install` runs `electrobun
# prepare`; the first build per Electrobun version fills ~/.hutch
# with Hutch, the core, Cottontail and the pinned Bun, ~200 MB)
# windows — Windows 11 x64: bun, the same network access; WebView2 runtime
# is preinstalled on Win 11
#
# Trigger: push a tag like build-62 (git tag build-$(git rev-list --count HEAD)).
name: release
on:
push:
tags:
- "build-*"
jobs:
mac:
runs-on: macos
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history — the build number is the commit count
- run: bun install --frozen-lockfile
- run: bun scripts/release.ts --target mac-arm64
- uses: actions/upload-artifact@v3
with:
name: mac
path: release/**
windows:
runs-on: windows
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: bun install --frozen-lockfile
- run: bun scripts/release.ts --target win-x64
- uses: actions/upload-artifact@v3
with:
name: windows
path: release/**
+5
View File
@@ -6,3 +6,8 @@ artifacts/
.DS_Store .DS_Store
ui/dist/ ui/dist/
ui/.angular/ ui/.angular/
src/shared/version.gen.ts
release/
# Hutch-generated (electrobun 2.x): the projected SDK devkit and build scratch
.hutch/
.cottontail-tmp/
+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.
+210 -30
View File
@@ -10,12 +10,20 @@ remote server over SSH/SFTP, so you can move files **local↔remote**,
> **Name & icon are placeholders.** See [Rebranding](#rebranding) — it's a > **Name & icon are placeholders.** See [Rebranding](#rebranding) — it's a
> one-file change. > one-file change.
## Developer docs
Dense reference material lives in [docs/](docs/): [architecture](docs/architecture.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)
(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
@@ -27,15 +35,17 @@ remote server over SSH/SFTP, so you can move files **local↔remote**,
a standard titlebar. a standard titlebar.
- **Menu-bar (tray) icon**: template icon that adapts to light/dark; click it - **Menu-bar (tray) icon**: template icon that adapts to light/dark; click it
to bring the app to the front. to bring the app to the front.
- **Edit files** (F4 / toolbar / File menu): a built-in editor works on local - **Edit files** (F4 / ⌘E / toolbar / File menu): a built-in editor works on local
and remote files directly; or pick an external app (Options → Editor → and remote files directly; or pick an external app (Settings → Editor →
Custom → Choose…). External edits of remote files download to a temp folder, Custom → Choose…). External edits of remote files download to a temp folder,
are watched, and every save auto-uploads (visible in the transfer list) until are watched, and every save auto-uploads (visible in the transfer list) until
the session disconnects. Leftover temp files from a crash are offered for the session disconnects. Leftover temp files from a crash are offered for
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), full keyboard control
with remappable shortcuts (see below). Enter / double-click on a local file
can open it in its default app (Settings → File list; off by default).
- 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).
@@ -45,10 +55,10 @@ remote server over SSH/SFTP, so you can move files **local↔remote**,
| Layer | Choice | | Layer | Choice |
| ---------- | ------------------------------------------------------------- | | ---------- | ------------------------------------------------------------- |
| Runtime | Bun (main process, all SSH/SFTP + file IO) | | Runtime | Bun (main process, all SSH/SFTP + file IO) |
| Desktop | Electrobun 1.18 (native webview, typed RPC bridge) | | Desktop | Electrobun 2.0 via Hutch (native webview, typed RPC bridge) |
| Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` | | Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` |
| Styling | Tailwind CSS v4, shadcn-style tokens (no component library) | | Styling | Tailwind CSS v4, Oxide-derived tokens (no component library) |
| Icons | @ng-icons/mynaui | | Icons | TDesign shapes redrawn on 14/16px pixel grids (`@ng-icons`) |
| SSH/SFTP | `ssh2` (pure-JS under Bun) | | SSH/SFTP | `ssh2` (pure-JS under Bun) |
The repo is a Bun workspace: the Electrobun app at the root, the Angular app in The repo is a Bun workspace: the Electrobun app at the root, the Angular app in
@@ -68,8 +78,17 @@ ui/src/app/ Angular components + signal services
- [Bun](https://bun.sh) ≥ 1.3 (`bun --version`). - [Bun](https://bun.sh) ≥ 1.3 (`bun --version`).
- macOS: **Xcode Command Line Tools** (for the app build). - macOS: **Xcode Command Line Tools** (for the app build).
- Linux: **AppIndicator** — the app links it for the tray and will not start
without it (`ERR_DLOPEN_FAILED`). `libayatana-appindicator` (Arch),
`libayatana-appindicator3-1` (Debian/Ubuntu),
`libayatana-appindicator-gtk3` (Fedora), plus system `webkit2gtk`.
Details and diagnostics in [platform notes](docs/platform-notes.md).
- No Node install required — a preload shim (`scripts/node-version-shim.ts`) - No Node install required — a preload shim (`scripts/node-version-shim.ts`)
satisfies the Angular CLI's Node version check while running under Bun. satisfies the Angular CLI's Node version check while running under Bun.
- Network on the first `bun install`: Electrobun 2.x's npm package is a
bootstrap that downloads its build tool (Hutch), the Electrobun core and the
pinned Bun runtime into `~/.hutch` (~200 MB), and projects the SDK into the
gitignored `.hutch/devkit/`.
## Setup ## Setup
@@ -87,6 +106,29 @@ This starts the Angular dev server (hot reload for UI code) and launches the
Electrobun app pointed at it. Edits under `ui/` reload instantly; edits to the Electrobun app pointed at it. Edits under `ui/` reload instantly; edits to the
Bun main process (`src/bun/**`, RPC schema) trigger an app relaunch. Bun main process (`src/bun/**`, RPC schema) trigger an app relaunch.
## Releases
Versioning is Sublime-style: **Build n**, where n is the commit count
(`git rev-list --count HEAD`), regenerated into the gitignored
`src/shared/version.gen.ts` by every build script (and `postinstall`).
One artifact per invocation, on the matching host — electrobun cannot
cross-compile (Hutch builds for its own OS and arch):
```sh
bun run release -- --target mac-arm64 # on an Apple Silicon Mac
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`
on the Mac; on Windows a `-Setup.zip` installer **and** a `-portable.zip`
(unzip anywhere and run); on Linux Electrobun's `-Setup.tar.gz` installer.
There is no Intel Mac build: Electrobun 2.x publishes no macOS x64 core.
CI: `.gitea/workflows/release.yml` runs the
same script on a macos + windows runner matrix once act_runners are
registered; trigger by pushing a `build-<n>` tag.
## Build a packaged app ## Build a packaged app
```bash ```bash
@@ -98,7 +140,7 @@ compiled from `assets/icon.iconset` into `AppIcon.icns` automatically.
For a distributable (optimized, with auto-update channel) build, use For a distributable (optimized, with auto-update channel) build, use
`bunx electrobun build --env=stable` (see the `bunx electrobun build --env=stable` (see the
[Electrobun docs](https://framework.blackboard.sh/electrobun/) for signing, [Electrobun docs](https://electrobun.dev/electrobun/) for signing,
notarization, and release hosting). notarization, and release hosting).
## Rebranding ## Rebranding
@@ -109,7 +151,7 @@ Everything user-facing reads from **[`src/shared/branding.ts`](src/shared/brandi
export const BRANDING = { export const BRANDING = {
appName: "miniSCP", // window title, bundle name, app-data folder appName: "miniSCP", // window title, bundle name, app-data folder
identifier: "es.teknes.miniscp", identifier: "es.teknes.miniscp",
legacyNames: ["BunSCP"], // previous names; their data dirs migrate on boot legacyNames: [], // previous names; their data dirs migrate on boot
version: "0.1.0", version: "0.1.0",
icon: "assets/icon.iconset", icon: "assets/icon.iconset",
} as const; } as const;
@@ -138,13 +180,102 @@ Saved connections live in a plain JSON file in the OS app-data directory
## Keyboard shortcuts ## Keyboard shortcuts
`Tab` switch panel · `↑`/`↓`/`PgUp`/`PgDn`/`Home`/`End` move cursor · The whole app works from the keyboard, and **every command can be remapped** in
`Space` select · `Enter` open folder · `Backspace` parent · **Settings → Keyboard**: press **+** on a row and type the keys, see conflicts,
`Ctrl/Cmd+A` select all · `F5` copy to other panel · `F2` rename · reset one row or all. Remaps apply live in every window, the right-click menus'
`F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab · hints and the toolbar tooltips follow, and so does the macOS menu bar. The
`Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab. defaults:
(On Macs, function keys may need `fn` — every action also has a toolbar button.) | Command | macOS | Windows / Linux |
| --- | --- | --- |
| **File list** | | |
| Move up | `↑` | `↑` |
| Move down | `↓` | `↓` |
| Page up | `PgUp` | `PgUp` |
| Page down | `PgDn` | `PgDn` |
| First item | `Home` | `Home` |
| Last item | `End` | `End` |
| Select / deselect | `Space` | `Space` `Ins` |
| Select all | `⌘A` | `Ctrl+A` |
| Open | `↩` `⌘↓` | `Enter` |
| Parent folder | `⌫` `⌘↑` | `Backspace` `Alt+↑` |
| Switch panel | `⇥` `⇧⇥` | `Tab` `Shift+Tab` |
| Show the item's menu | `⇧F10` `⌃↩` | `Shift+F10` `Menu` |
| **Files** | | |
| Send to the other panel | `F5` | `F5` |
| Edit | `⌘E` `F4` | `F4` `Ctrl+E` |
| Rename | `F2` | `F2` |
| New folder | `⇧⌘N` `F7` | `F7` `Ctrl+Shift+N` |
| New file | `⌘N` `⇧F4` | `Shift+F4` `Ctrl+N` |
| Permissions | `⌘I` | `Ctrl+I` |
| Delete | `F8` `⌦` | `F8` `Del` |
| Move to Trash | `⌘⌫` | `Ctrl+Del` |
| Copy path | `⌥⌘C` | `Ctrl+Shift+C` |
| Reveal in file manager | `⌥⌘R` | `Alt+Shift+R` |
| Open in default app | `⌘O` | `Ctrl+O` |
| Refresh | `⌘R` | `Ctrl+R` |
| Go to path | `⌘L` | `Ctrl+L` |
| Choose what the panel shows | `⌘K` | `Ctrl+K` |
| **Sorting** | | |
| Sort by name | `⌥1` | `Alt+1` |
| Sort by size | `⌥2` | `Alt+2` |
| Sort by modified | `⌥3` | `Alt+3` |
| Sort by kind | `⌥4` | `Alt+4` |
| Sort by rights | `⌥5` | `Alt+5` |
| Sort by owner | `⌥6` | `Alt+6` |
| Sort by group | `⌥7` | `Alt+7` |
| **Tabs** | | |
| New tab | `⌘T` | `Ctrl+T` |
| Close tab | `⌘W` | `Ctrl+W` |
| Close other tabs | `⌥⌘W` | `Ctrl+Shift+W` |
| Close tabs to the right | — | — |
| Next tab | `⌃⇥` `⇧⌘]` | `Ctrl+Tab` `Ctrl+PgDn` |
| Previous tab | `⌃⇧⇥` `⇧⌘[` | `Ctrl+Shift+Tab` `Ctrl+PgUp` |
| Go to tab 1 | `⌘1` | `Ctrl+1` |
| Go to tab 2 | `⌘2` | `Ctrl+2` |
| Go to tab 3 | `⌘3` | `Ctrl+3` |
| Go to tab 4 | `⌘4` | `Ctrl+4` |
| Go to tab 5 | `⌘5` | `Ctrl+5` |
| Go to tab 6 | `⌘6` | `Ctrl+6` |
| Go to tab 7 | `⌘7` | `Ctrl+7` |
| Go to tab 8 | `⌘8` | `Ctrl+8` |
| Go to last tab | `⌘9` | `Ctrl+9` |
| Move tab left | `⌃⇧PgUp` | `Ctrl+Shift+PgUp` |
| Move tab right | `⌃⇧PgDn` | `Ctrl+Shift+PgDn` |
| Disconnect tab | `⌘D` | `Ctrl+D` |
| **Window** | | |
| Settings | `⌘,` | `Ctrl+,` |
| Connection manager | `⇧⌘K` | `Ctrl+Shift+K` |
| Show / hide transfers | `⌘J` | `Ctrl+J` |
| Install update | — | — |
| Bigger interface | `⌘=` `⇧⌘=` `⌘Num +` | `Ctrl+=` `Ctrl+Shift+=` `Ctrl+Num +` |
| Smaller interface | `⌘-` | `Ctrl+-` |
| Interface at 100% | `⌘0` | `Ctrl+0` |
| Close window | `⌘W` | `Ctrl+W` |
| **Editor** | | |
| Save | `⌘S` | `Ctrl+S` |
| Find | `⌘F` | `Ctrl+F` |
| Find next | `⌘G` | `F3` |
| Find previous | `⇧⌘G` | `Shift+F3` |
| Wrap lines | `⌥Z` | `Alt+Z` |
| **Connection manager** | | |
| New connection | `⌘N` | `Ctrl+N` |
| New stack | `⇧⌘N` | `Ctrl+Shift+N` |
| Save connection | `⌘S` | `Ctrl+S` |
| Connect / open stack | `⌘↩` | `Ctrl+Enter` |
| Delete connection or stack | `⌘⌫` | `Del` |
| Rename stack | `F2` | `F2` |
| Import from WinSCP | `⌘O` | `Ctrl+O` |
**Fixed keys** (not remappable): Esc closes menus, dialogs and the find bar —
in the file list it clears the filter, then cancels a connect, then clears the
selection. Enter / Esc confirm / cancel dialogs and Tab walks their controls
(on macOS too, without the system's Keyboard navigation setting). Typing in the
file list filters it (Backspace trims); Shift + any movement key selects the
rows the cursor passes. ↑ ↓ Home End Enter work in every menu.
(On Macs, function keys may need `fn` — every F-key command also has a ⌘ key
or a toolbar button.)
## Same-file protection ## Same-file protection
@@ -173,30 +304,79 @@ Be careful when two panels reach the same data through different mount points.
## Portability notes ## Portability notes
The codebase is platform-neutral except for two seams, both macOS-only today: Platform seams are isolated behind `process.platform` / `PLATFORM` branches
marked with `PORTABILITY:` comments: editor launch (`open` / `cmd /c start` /
`xdg-open`), app pickers, owner/group lookups, tray images, window chrome,
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;
the in-app UI covers every menu action.
- **External editor launch** (`launchEditor` in [src/bun/edits.ts](src/bun/edits.ts)): Right-click menus are **HTML, on every platform**
uses macOS `open [-a app] file`. A Windows port should use ([ui/src/app/core/context-menu.service.ts](ui/src/app/core/context-menu.service.ts)) rather than native —
`cmd /c start "" [app] file`; Linux `xdg-open file` (or invoke the configured electrobun's native context menu is a documented no-op on Linux, and one menu
editor binary directly). The `.app` picker in the `pickEditorApp` handler implementation beats two that drift. Only reveal / open-with-default / copy-path
needs the equivalent executable picker per platform. cross to the backend, through the `shellItemAction` RPC.
- **Application menu** ([src/bun/menu.ts](src/bun/menu.ts)): electrobun menus
are macOS-only; the in-app UI covers every menu action. ## Windows (beta)
Windows 11+ x64 is supported as a **beta** target (win-arm64 deferred). All
electrobun APIs the app uses are implemented on Windows; known issues are
upstream:
- **HiDPI blur** ([electrobun#324](https://github.com/blackboardsh/electrobun/issues/324)):
WebView2 rendered at 1x on scaled displays on Electrobun 1.18; 2.x ships
per-monitor DPI setup — to re-check on the first 2.x Windows build.
- **Notifications** use legacy balloon style, not Windows toast.
- **Unsigned binaries**: both the Setup.exe installer and the portable exe
trigger SmartScreen/Mark-of-the-Web warnings on first run. The installer
unpacks to `%LOCALAPPDATA%` and, since Electrobun 2.x, ships an uninstaller.
- `connections.json` is protected with an `icacls` current-user ACL instead of
POSIX 0600 (which is meaningless on Windows).
First-Windows-boot smoke test: drives dropdown lists C:\ etc.; navigate to a
drive root ('..' disappears); local↔remote transfers; external editor launch
(`cmd /c start`) and the .exe picker; Show in Explorer / Recycle Bin; tray icon
renders colored; Permissions absent on local panels; Ctrl+labels in tooltips;
portable zip runs from any folder.
## Linux (beta)
Linux x64 is supported as a **beta** target and ships Electrobun's own
installer (`-Setup.tar.gz`).
One hard requirement: the AppIndicator library listed under
[Prerequisites](#prerequisites) — electrobun links it for the tray, so without
it the app aborts at startup with `ERR_DLOPEN_FAILED` rather than merely losing
its tray icon.
- **No native menus at all** upstream (both the app menubar and context menus
are no-ops). The menubar was already macOS-only and the context menus are now
HTML, so nothing is missing in practice.
- **Tray visibility** depends on the desktop: KDE, XFCE, MATE and Cinnamon show
it; GNOME 3.26+ needs the AppIndicator extension. The app works either way.
- **Unsigned**, and no AppImage or `.deb` — unpack the `-Setup.tar.gz` and run
its `installer`.
- **NVIDIA GPUs** need `WEBKIT_DISABLE_DMABUF_RENDERER=1` or the window renders
black. The dev scripts set it automatically when an NVIDIA driver is
detected; an installed build needs it set by hand (e.g. in its desktop
entry) — see [gotchas](docs/gotchas.md#linux).
Full dependency table, diagnostics and the first-boot smoke test:
[platform notes](docs/platform-notes.md).
## Known issues ## Known issues
- **Rare native crash under load.** A long-running session (multiple remote - **Rare native crash under load.** A long-running session (multiple remote
connections, extended uptime) can hit a Bun panic — a segfault inside Bun connections, extended uptime) could hit a Bun panic — a segfault inside Bun
1.3.13's FFI thread-safe callbacks executing Electrobun's native→worker 1.3.13's FFI thread-safe callbacks executing Electrobun's native→worker
bridge (`FFI_Callback_threadsafe_call` → `web_worker.spin` in the decoded bridge (`FFI_Callback_threadsafe_call` → `web_worker.spin` in the decoded
crash report). This is an upstream Bun × Electrobun bug, not app code; a crash report). This is an upstream Bun × Electrobun bug, not app code; a
restart recovers cleanly and in-flight work isn't corrupted. Expected to restart recovers cleanly and in-flight work isn't corrupted. Electrobun 2.0.2
resolve when Electrobun bumps its pinned Bun (currently 1.3.13; 1.3.14+ pins Bun 1.4.0, which may have fixed it — not yet confirmed under load. Bun's
available). Bun's own crash reporter link is printed to the console on panic. own crash reporter link is printed to the console on panic.
## Not in v1 ## Not in v1
Drag-and-drop, file preview/edit, transfer resume, symlink following Drag-and-drop, file preview/edit, transfer resume, symlink following
(symlinks are listed but skipped by transfers), OS keychain, tab persistence (symlinks are listed but skipped by transfers), OS keychain, tab persistence
across restarts, Windows/Linux packaging (the code is portable; only macOS across restarts, Linux AppImage/.deb packages (the release is Electrobun's
packaging is wired up). Setup installer).
BIN
View File
Binary file not shown.

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.

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

+19 -62
View File
@@ -5,7 +5,6 @@
"": { "": {
"name": "bunscp", "name": "bunscp",
"dependencies": { "dependencies": {
"electrobun": "1.18.1",
"ssh-config": "^5.2.0", "ssh-config": "^5.2.0",
"ssh2": "^1.17.0", "ssh2": "^1.17.0",
}, },
@@ -13,6 +12,8 @@
"@resvg/resvg-js": "^2.6.2", "@resvg/resvg-js": "^2.6.2",
"@types/bun": "latest", "@types/bun": "latest",
"@types/ssh2": "^1.15.5", "@types/ssh2": "^1.15.5",
"electrobun": "2.0.2",
"png-to-ico": "^3.0.2",
"typescript": "~5.9.3", "typescript": "~5.9.3",
}, },
}, },
@@ -25,7 +26,7 @@
"@angular/core": "^22.0.0", "@angular/core": "^22.0.0",
"@angular/platform-browser": "^22.0.0", "@angular/platform-browser": "^22.0.0",
"@ng-icons/core": "^34.0.0", "@ng-icons/core": "^34.0.0",
"@ng-icons/mynaui": "^34.0.0", "@ng-icons/tdesign-icons": "^34.0.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
}, },
@@ -132,8 +133,6 @@
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@babylonjs/core": ["@babylonjs/core@7.54.3", "", {}, "sha512-P5ncXVd8GEUJLhwloP9V0oVwQYIrvZztguVeLlvd5Rx+9aQnenKjpV8auJ6SRsUlAmNZU4pFTKzwF6o2EUfhAw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
@@ -252,8 +251,6 @@
"@lmdb/lmdb-win32-x64": ["@lmdb/lmdb-win32-x64@3.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-JF1BmLCm9kGEVZgYmJq43zeQVdHVgAJnTi/NURWEsy6L1ZrrlSmdltS+D17QN4LODwf+1LMXAA9auIZVXtWwzw=="], "@lmdb/lmdb-win32-x64": ["@lmdb/lmdb-win32-x64@3.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-JF1BmLCm9kGEVZgYmJq43zeQVdHVgAJnTi/NURWEsy6L1ZrrlSmdltS+D17QN4LODwf+1LMXAA9auIZVXtWwzw=="],
"@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@1.1.1", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
@@ -306,7 +303,7 @@
"@ng-icons/core": ["@ng-icons/core@34.0.0", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular-devkit/schematics": ">=21.0.0", "@angular/common": ">=21.0.0", "@angular/core": ">=21.0.0", "@schematics/angular": ">=21.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-SrL8dBApSKzXhihEGmiJ/tAj+wSvt6XmZJOAzUTVcgkuV0YuL1WCHVfAwXgQHVG5Te27JkjyXd2n76pNFMuekw=="], "@ng-icons/core": ["@ng-icons/core@34.0.0", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular-devkit/schematics": ">=21.0.0", "@angular/common": ">=21.0.0", "@angular/core": ">=21.0.0", "@schematics/angular": ">=21.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-SrL8dBApSKzXhihEGmiJ/tAj+wSvt6XmZJOAzUTVcgkuV0YuL1WCHVfAwXgQHVG5Te27JkjyXd2n76pNFMuekw=="],
"@ng-icons/mynaui": ["@ng-icons/mynaui@34.0.0", "", { "dependencies": { "tslib": "^2.3.0" } }, "sha512-Wstqby65RIOmlbBzrHfSEb9+HX87GbOHmIfSh3oEV8Wr6vulnOnQ11S8M3aYT5kuuIe9Bq00OzYwbHuqYBCNrw=="], "@ng-icons/tdesign-icons": ["@ng-icons/tdesign-icons@34.0.0", "", { "dependencies": { "tslib": "^2.3.0" } }, "sha512-FIRho+rAX3ludOeIMP+/Tr0Xp3zi3j/JXT5YooGeoMo8AF3qk0gvG4Cu1kI+8V5r3SxoOia9ktPxt0ADKt3LXg=="],
"@npmcli/agent": ["@npmcli/agent@4.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg=="], "@npmcli/agent": ["@npmcli/agent@4.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg=="],
@@ -474,8 +471,6 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "postcss": "^8.5.15", "tailwindcss": "4.3.2" } }, "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g=="], "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "postcss": "^8.5.15", "tailwindcss": "4.3.2" } }, "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g=="],
"@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="],
"@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="], "@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="],
"@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="], "@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="],
@@ -496,7 +491,7 @@
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
@@ -512,14 +507,10 @@
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="],
"basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="],
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
"beasties": ["beasties@0.4.2", "", { "dependencies": { "css-select": "^6.0.0", "css-what": "^7.0.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "htmlparser2": "^10.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.49", "postcss-media-query-parser": "^0.2.3", "postcss-safe-parser": "^7.0.1" } }, "sha512-NvcGjG/7AVUAfRbvrJmHunDQS9uHnE6Q/7AkaPr8oKE8HjOlpjRG5075z/th2Tmlezk3VlaaS8+X9I1RwHJMQw=="], "beasties": ["beasties@0.4.2", "", { "dependencies": { "css-select": "^6.0.0", "css-what": "^7.0.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "htmlparser2": "^10.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.49", "postcss-media-query-parser": "^0.2.3", "postcss-safe-parser": "^7.0.1" } }, "sha512-NvcGjG/7AVUAfRbvrJmHunDQS9uHnE6Q/7AkaPr8oKE8HjOlpjRG5075z/th2Tmlezk3VlaaS8+X9I1RwHJMQw=="],
@@ -582,18 +573,12 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"cross-spawn-windows-exe": ["cross-spawn-windows-exe@1.2.0", "", { "dependencies": { "@malept/cross-spawn-promise": "^1.1.0", "is-wsl": "^2.2.0", "which": "^2.0.2" } }, "sha512-mkLtJJcYbDCxEG7Js6eUnUNndWjyUZwJ3H7bErmmtOYU/Zb99DyUkpamuIZE0b3bhmJyZ7D90uS6f+CGxRRjOw=="],
"css-select": ["css-select@6.0.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^7.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "nth-check": "^2.1.1" } }, "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw=="], "css-select": ["css-select@6.0.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^7.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "nth-check": "^2.1.1" } }, "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw=="],
"css-what": ["css-what@7.0.0", "", {}, "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ=="], "css-what": ["css-what@7.0.0", "", {}, "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ=="],
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -610,7 +595,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"electrobun": ["electrobun@1.18.1", "", { "dependencies": { "@babylonjs/core": "^7.45.0", "@types/bun": "^1.3.8", "png-to-ico": "^2.1.8", "proxy-agent": "^6.5.0", "rcedit": "^4.0.1", "three": "^0.165.0" }, "bin": { "electrobun": "bin/electrobun.cjs" } }, "sha512-tgZ+WKGskn/1/Y5i1mpVCCkgRa1O31Tz7MIArBTK44GfPzT43uOq9c/HvxdQGrLeZV8ZvjK1lQrwqSXCgh6tvA=="], "electrobun": ["electrobun@2.0.2", "", { "bin": { "electrobun": "bin/electrobun.cjs" } }, "sha512-UDs2iTNs6qYCzdWiFch/0DtxrsijNDr1WFDQfRmSA3Iouv0iEzyCF5mh4uDxkwNnAbuwFyow36EiVTYP4DRzig=="],
"electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="], "electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="],
@@ -638,14 +623,6 @@
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
@@ -694,8 +671,6 @@
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="],
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
@@ -736,8 +711,6 @@
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
@@ -750,8 +723,6 @@
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
@@ -806,7 +777,7 @@
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
"lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
@@ -858,8 +829,6 @@
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"netmask": ["netmask@2.1.1", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="],
"node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="], "node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="],
"node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="], "node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="],
@@ -902,10 +871,6 @@
"p-map": ["p-map@7.0.5", "", {}, "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA=="], "p-map": ["p-map@7.0.5", "", {}, "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA=="],
"pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="],
"pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="],
"pacote": ["pacote@21.5.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg=="], "pacote": ["pacote@21.5.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "@npmcli/run-script": "^10.0.0", "cacache": "^20.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", "npm-package-arg": "^13.0.0", "npm-packlist": "^10.0.1", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" }, "bin": { "pacote": "bin/index.js" } }, "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg=="],
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
@@ -930,9 +895,9 @@
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"png-to-ico": ["png-to-ico@2.1.8", "", { "dependencies": { "@types/node": "^17.0.36", "minimist": "^1.2.6", "pngjs": "^6.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-Nf+IIn/cZ/DIZVdGveJp86NG5uNib1ZXMiDd/8x32HCTeKSvgpyg6D/6tUBn1QO/zybzoMK0/mc3QRgAyXdv9w=="], "png-to-ico": ["png-to-ico@3.0.2", "", { "dependencies": { "@types/node": "^25.5.0", "minimist": "^1.2.8", "pngjs": "^7.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-36vvp3/YF7LPYkUEOj08/WgB1wI63qW391YfOhOWckgOtGkarr9+EhPBimcCmRP7fJaG4EaXQhTTaQ8qqdj8aA=="],
"pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
"postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="],
@@ -944,18 +909,12 @@
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
"range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"rcedit": ["rcedit@4.0.1", "", { "dependencies": { "cross-spawn-windows-exe": "^1.1.0" } }, "sha512-bZdaQi34krFWhrDn+O53ccBDw0MkAT2Vhu75SqhtvhQu4OPyFM4RoVheyYiVQYdjhUi6EJMVWQ0tR6bCIYVkUg=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
@@ -1040,8 +999,6 @@
"tar": ["tar@7.5.19", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw=="], "tar": ["tar@7.5.19", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw=="],
"three": ["three@0.165.0", "", {}, "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
@@ -1104,10 +1061,10 @@
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@npmcli/agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"@npmcli/agent/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "@npmcli/agent/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
@@ -1148,12 +1105,14 @@
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"https-proxy-agent/agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@@ -1162,16 +1121,14 @@
"ora/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], "ora/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
"pac-proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
"png-to-ico/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="], "png-to-ico/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="],
"proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"ui/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "ui/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
@@ -1184,8 +1141,6 @@
"@angular/compiler-cli/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@angular/compiler-cli/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@npmcli/git/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], "@npmcli/git/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], "@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
@@ -1196,6 +1151,8 @@
"node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"png-to-ico/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"sass/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "sass/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
+531
View File
@@ -0,0 +1,531 @@
# miniSCP architecture
Dense reference for development and debugging. Companion files:
[build-and-release.md](build-and-release.md), [platform-notes.md](platform-notes.md),
[gotchas.md](gotchas.md).
## Process model
- **Bun main process** (`src/bun/`): the native launcher runs electrobun's
`main.js` on the Bun the 2.x devkit pins (1.4.0, shipped in the bundle —
`build.mainProcess: "bun"`), which starts our bundle in a Worker. Imports the
SDK as `electrobun/main` (projected into `.hutch/devkit/`). Owns every
privileged capability: filesystem, SSH, window management, native
menus/dialogs/tray, the connection/settings stores.
- **Webviews** (`ui/` Angular): one per window, system WebKit on macOS /
WebView2 on Windows / WebKitGTK on Linux. Zero privileged access — everything
goes through RPC.
- **RPC transport**: one WebSocket server in the bun process, multiplexed by
`webviewId` (each webview auto-connects using natively injected globals;
packets AES-GCM-encrypted per webview). 2.x Electroview queues sends while
its `hostSocket` connects and falls back to a native bridge when the socket
is unavailable. Requests are typed by the `AppRPC` schema in
`src/shared/rpc.ts`.
### The one-schema rule
`AppRPC` is a single superset schema shared by all window kinds. This works
because electrobun allows **partial handler sets** (a side only implements
what it serves) and **silently drops messages nobody listens to**. Every
response crosses the bridge as `Result<T>` (`{ok:true,…}|{ok:false,error}`) —
exceptions never cross; `wrap()` in `src/bun/rpc-handlers.ts` enforces it.
### Per-window RPC instances (critical)
Each `BrowserWindow` MUST receive its own `defineRPC(...)` instance: binding a
BrowserView calls `rpc.setTransport(...)`, so a shared instance would have its
transport stolen by the most recent window. `createAppRPC` (main),
`createEditorRPC` (per editor window, hooks close over that window's record),
`createManagerRPC` (the single manager window), `createSettingsRPC` (the single
Settings window). `rpc.send.*` on an instance targets exactly its own webview.
### The `events` late-binding pattern
`createAppRPC` needs to trigger things that need `rpc.send` (which doesn't
exist until after construction). `AppDeps.events` is a mutable object of
no-op functions, reassigned in `src/bun/index.ts` after everything exists:
`sessionClosed`, `connectionsChanged`, `openEditor`, `settingsChanged`,
`openConnectionManager`, `openSettings`, `showAbout`, `applyUpdate` (the
update restart lives in index.ts beside the before-quit guard it must get past —
see build-and-release.md "Auto-updater").
## Window kinds
`src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects
`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager|settings`), `__APP_CTX`
before any page script, plus the persisted theme attributes and interface
scale so the first paint is already right. `ui/src/main.ts` picks the root
component from `APP_MODE`; all four roots use selector `app-root` (index.html
hosts only that tag). `chromeOptions()` is the ONLY place the native frame is
decided, spread by the main window and `openSecondaryWindow`: macOS `hiddenInset` +
`trafficLightOffset {x:12,y:4}` (2.x semantics — see gotchas; the UI renders an
`h-10 pl-24` drag-region header via `IS_MAC_CHROME`); other platforms keep the
native titlebar and a compact `h-9` non-drag header.
`WindowRegistry<Rec>` (same file): keyed window map with dedupe/activate,
cascade placement off an anchor frame, focus tracking. electrobun's emitter
has **no `off()`** — `track()` closures capture only `(this, key)` so closed
records can be garbage collected. A future "window follows window" grouping
belongs here (`anchor.on('move')` + `setPosition`).
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/skins/shadlike/editor-window/`)
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
through a concrete session, and `local` is one shared session anyway).
Re-opening focuses the existing window. The webview fetches its own content
(`readFile`) — `openEditor` just creates the window with ctx in the preload.
**Close safety** (designed when electrobun window `close` was NOT cancellable —
1.18.1 had no `allow` response like `before-quit`; 2.x adds a cancellable
`will-close` for the titlebar close, not used yet — see gotchas):
1. ⌘W (the page's own `close-window` key; a ⌘W it doesn't claim falls through
to the app menu, which bun routes back as `editorCloseRequest`) / Close
button → styled in-window Save/Don't Save/Cancel, which takes focus (Save;
⌘S = Save, Esc = Cancel, Tab cycles) so keys can't edit the text behind it;
the confirm path calls `editorClose` which sets `suppressPrompt` before
`win.close()`.
2. Traffic-light close → the window is already gone, but the editor mirrored
its draft to bun (`editorState`, 400 ms debounce + flush on blur/dirty-flip)
→ native Save/Discard box from the held draft. A failed save (dead session)
parks the draft via `EditManager.rescueDraft` into the edits-leftovers dir,
offered back at next boot. Known limit: the last ≤400 ms of keystrokes can
be lost on an instant type-then-close.
3. ⌘Q → the app-level `before-quit` guard (cancellable) counts dirty editors
plus running transfers.
`editorSave` is a dedicated request (not generic `writeFile`) so one handler
atomically writes + cleans the bun record + retitles + pushes `pathRefresh`
to the main window (panels showing that directory refresh). Editor upgrades:
scroll-synced line gutter (hidden when wrap is on — wrapped line numbers would
need per-edit height measurement), Alt/⌥Z wrap toggle, custom find bar
(matches computed in TS, `setSelectionRange` + scrollTop math — deliberately
not `findInPage`, which is a shim on WebView2). Find next / previous (⌘G /
⇧⌘G, F3 / ⇧F3) step from the caret with the bar open or closed — the query
survives closing it. Focus after a state change goes through
`afterNextRender({ injector })`: under zoneless CD a microtask runs BEFORE the
render that creates the `@if` element.
### Connection-manager window (`src/bun/manager-window.ts`)
Single instance, centered over main, **modal to main only** (editors stay
usable). No native modality exists (re-checked on 2.0.2: no parent/owner/sheet
option or input lock in any SDK or the core's exports; `styleMask.DocModalWindow`
is only the cosmetic NSWindow bit), so three layers approximate it: bun
bounces main's `focus` event back to the manager; the main webview renders a
click-swallowing overlay (`managerState` message); main drops its keyboard
shortcuts and bun drops main-targeted menu actions while open. Connect /
open-stack execute in the MAIN window via the `connect:<id>` /
`open-stack:<id>` menuAction channel (the manager closes right after — a
failed connect surfaces in main). Store edits push `connectionsChanged` so
main's `ConnectionsService` reloads; a reload-retry in `App.findConnection`
covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it
would float over editor windows).
Keyboard (the `manager` keymap scope): ⌘N new connection (focus lands in
Name), ⇧⌘N new stack, ⌘S save, ⌘↩ connect — or, on a focused stack row, open
it in tabs — ⌘⌫ / Del delete the focused row (with a confirm), F2 rename the
focused stack, ⌘O import, ⌘W close. The list's rows are real buttons
(`data-row`; `data-stack` / `data-conn` say what they are): ↑ ↓ Home End move
FOCUS only — loading a connection would replace a half-edited form without
asking — Enter opens the focused connection in the form, ← / → collapse and
expand a stack (← on a connection climbs to its stack), and a stack's
hover-only actions also show while the keyboard is in its row. Enter in a form
field = Connect; Esc leaves a field first, then closes the window. Focus
returns where it was after a dialog (`focusin` memory + the FocusService
tick) and falls back to the edited connection's row whenever it drops to
`<body>`.
### Settings window (`src/bun/settings-window.ts` + `ui/src/app/skins/shadlike/settings-window/`)
Single instance, centered over main, **not modal**: every control is a plain
`SettingsService` setter, so each edit is an ordinary save that bun
rebroadcasts — the other windows follow live while it stays open. Opened by
the tab-bar gear and the open-settings key (⌘, / Ctrl+,, `openSettings` from
main) and, on macOS, the app menu's "Settings…". Its RPC serves only
`getSettings`/`saveSettings`/`pickEditorApp`/`showAbout`/`settingsClose`. Its
page claims its own close-window key (`settingsClose`); a ⌘W it doesn't claim
falls through to the app menu's Close Tab, and bun checks the Settings
window's focus FIRST and closes it (see gotchas). Sidebar sections (↑ / ↓ move
between them, focus starts on the current one, Tab is walked by `cycleFocus`):
Appearance, Transfers, Editor, File list (visible columns — the column-header
right-click menu toggles the same setting — and "Enter / double-click on a
file": nothing | open local files in their default app), Keyboard, About
(opens the native About box). The native title is `options.title`, retitled on
a locale switch.
**Keyboard** is the remap editor over the shared keymap: rows by group with a
search field (label or key text), a chip per combo (× removes), **+** records
(the next non-modifier keydown, taken before every other key in the window so
⌘W records instead of closing; Esc cancels; a click elsewhere or a blur ends
it), ↺ resets a row, Reset all (inline confirm). Reserved combos are refused
without `preventDefault` (⌘Q still quits); editor-scope combos must be chords
or F-keys (the editor's text area always has focus); a combo owned by an
overlapping-scope command offers **Replace** (moves it). Rows flag conflicts
and plain character keys on file-list rows (they no longer start a filter).
Fixed keys are listed read-only below. Every edit is `ShortcutsService.rebind`
→ `settings.keybindings` (only rows that differ from the defaults) → the
ordinary save + broadcast, so menus, tooltips and the macOS menu bar follow
live.
## Bun modules
- `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops +
path helpers + streams + **`fsKey`** (see self-copy below).
- `fs/local.ts` — node:fs, `lstat`-based, `fsKey="local"`. Owner/group via
cached `id -nu` / `dscacheutil|getent` spawns (win32 → undefined).
`listLocalDrives()` for the Windows drive picker (`fsutil`).
- `fs/sftp.ts` — ssh2 under Bun (pure JS; native accelerators are build
`external`s). POSIX paths always. `fsKey = resolvedIP:port` via `dns.lookup`
at connect. Owner/group parsed from the ls-style readdir `longname`.
Keepalive 15 s; `closedByUs` flag so `dispose()` doesn't fire the onClose
callback. mtime seconds→ms; mode masked `& 0o7777`.
- `fs/registry.ts` — sessionId → provider map; `"local"` is built-in;
`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
(`wake()` reserves slots synchronously — an async waiter increment was a
race). Per-job worker pool (`MAX_WORKERS_PER_JOB=8`) draws from the shared
slot budget, so in-folder parallelism and cross-job fairness share one knob
(`setConcurrency` from settings; `single` mode = 1). `activeStreams` set
enables hard cancel. `emit` throttled 100 ms. `failSession` fails that
session's jobs. Probe files: `.{appname}-probe-*`.
- `transfers/walk.ts` — recursive tree walk (segment arrays, skips symlinks)
+ `removeRecursive`.
- **Self-copy protection** (data-loss class): copying a file onto itself
truncates it (`createWriteStream` O_TRUNCs before the read). Guards compare
**fsKey, never sessionId** — two sessions to one server are distinct
sessions on one filesystem. When fsKeys differ but paths collide,
`probeSameFilesystem` writes a token via dst and reads it via src to unmask
hidden aliases (SFTP has no dev/inode). Fails open (permissions produce
their own error). Residual risk (documented in README): same filesystem
mounted at different paths.
- `edits.ts` — `readFileText`/`writeFileText` (2 MB cap) for the built-in
editor; `EditManager` for the external-editor flow: temp-download under
`appDataDir()/edits/<id>/` + `meta.json`, directory watch (survives atomic
renames), 400 ms debounce → engine upload. Watchers die with their session;
leftover dirs are offered for cleanup at boot. `launchEditor` is the
platform seam (`open` / `cmd /c start` / `xdg-open`).
- `store/paths.ts` — per-OS `appDataDir()` + `migrateLegacyDataDir()` (renames
a `BRANDING.legacyNames` dir into place on boot; list currently empty).
- `store/connections.ts` — plaintext-passwords JSON (deliberate v1 choice),
atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
spread-merge on load, so NEW SETTINGS KEYS need no backend change.
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
Connect submenu mirrors the store), language switch and keyboard remap.
**Accelerators come from the shared keymap** (`effectiveKeymap` +
`toAccelerator`, the first combo the native parser can express faithfully:
⌘/⌃ chords only — a bare key means ⌘+key to it — no ⌫ / ⌦ / Shift+punctuation);
⌘Q stays fixed. The keybindings are read through a getter AFTER the
connections load, so a remap saved meanwhile isn't lost. The page claims the
keys it handles first, so the menu only runs what falls through; a
`close-tab` that still arrives while a Settings or editor window is focused
closes THAT window, and other main-targeted actions bring the main window
forward 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;
Windows colored icon. No `setImage` calls ever (they lose the template
treatment) and no menu (clicks arrive directly).
- `index.ts` — composition root: wiring order matters (events assigned after
rpc; manager/editors constructed after win; quit guard; focus tracking for
notification suppression across all windows).
## UI layout
```
ui/src/app/
core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts,
metrics.ts and every *.service.ts — the non-visual layer
skins/shadlike/ the component tree: four window roots (SHAD_ROOTS barrel,
picked by main.ts), dialogs, context menu
```
Components reach `core/` through the **`@core/*`** tsconfig alias (alongside
`@shared/*` → `src/shared`), which is deliberate: components sit at varying
depths and an alias keeps their imports identical wherever a file moves. Do not
replace it with relative paths.
**Where a new rule goes**: behaviour → `core/`; markup and appearance → the
component; tokens → `ui/src/styles.css`.
## UI services (`ui/src/app/core/`)
- `rpc.service.ts` — wraps `Electroview.defineRPC`. **Push messages fan out
through plain callback registries, never signals**: a signal consumed from
an effect that also writes signals re-triggers itself (this froze the whole
app once), and signal coalescing drops progress snapshots. All requests are
gated on the socket-open promise (`electroview.hostSocket`) — on 1.18.1 calls
fired during boot were silently lost; 2.x Electroview queues them itself, so
the gate is belt-and-braces now. `request` and `send` are ready-gated Proxies.
- `panels.service.ts` — `PanelState` (per panel): sessionId, fsKey, cwd,
entries, sort, cursor, selection, type-to-filter. `sorted` computed:
filter → dirs-first sort (name/size/mtime numeric, kind/rights/owner/group
via display-string compare) → synthetic `'..'` row unshifted unless
`isRootPath(cwd)`. `touched` marks user navigation (connect flows only
auto-load `localDir` into an untouched local panel). `connectToken`
supersedes stale connects. `Workspace` = tab = left/right pair + activeSide.
Remote goes RIGHT on connect/open-stack (commander convention); per-panel
dropdown connects target their own panel. **Auto-refresh** (always on):
`refresh({silent})` re-lists without the loading/busy flags and touches no
DOM (scroll/cursor/selection/filter preserved), snapshotting sessionId+cwd
to discard results if the user navigated mid-flight. Only the active tab is
live: its local panels are fs.watched (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
(`SPEED_SMOOTHING=0.7`); `copyToOtherPanel` guards same-fs+same-dir before
enqueue and runs the collision flow (same-path auto-skip, type-mismatch
skip, overwrite dialog with all/none batch modes).
- `settings.service.ts` — one signal per setting; **imperative apply pattern**
(`applyTheme/DarkTone` set `data-*` attributes on `<html>`;
`applyUiScale` sets its inline `font-size`); `applyAll` also serves the
`settingsChanged` broadcast, so every window follows a save made in any
other. Every window RPC serves `saveSettings` (editor and manager windows
save too — the scale shortcuts — and the Settings window is nothing but
saves); bun's `settingsSaved()` in index.ts runs the
side effects once and rebroadcasts to every window **except the sender**, so
there is no echo to fight a held Cmd+. `applyAll` never persists. Saves are
whole-object, so **nothing persists until the first `getSettings` landed**
(`loaded`): a ⌘+ pressed while a window opens would otherwise write the
defaults over every remap. New setting = five touches here + types.ts
(backend merges automatically; sanitize hand-editable shapes in
`SettingsStore.load`).
- `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the
main window's panels listen — but a panel only takes focus while NO dialog or
menu is up (a run of overwrite questions reuses one dialog instance; focusing
the list behind it handed Enter/Delete to the panel). Exists so dialogs don't
inject PanelsService (whose constructor spawns a workspace — fatal in
secondary windows).
- `shortcuts.service.ts` — the keyboard layer over `src/shared/keymap.ts`; see
"Keyboard" below.
- `focus-cycle.ts` — `cycleFocus(root, event)`: Tab / ⇧Tab by hand (see
"Keyboard").
- `connections.service.ts` — store mirror + ssh-config hosts; reloads on the
`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 (call-stack
tracking, see gotchas.md) — 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` — **one file-command dispatcher**,
`run(id, { side, panel, entry })`, for every entry point: list keys (the
cursor row), row-menu picks (the right-clicked row, captured in the menu's
`onPick` closure), the macOS menu bar's File/View items and the panel
toolbar. Ids are keymap `FileCommand`s (`open`, `open-default`, `send`,
`edit`, `rename`, `new-folder`, `new-file`, `chmod`, `delete`, `trash`,
`copy-path`, `reveal`, `refresh`, `sort-*`). Selection if any, else the
target row; `..` stands for the folder. `open` enters folders and opens a
LOCAL file in its default app only when `openFilesOnEnter` is set (Settings
→ File list); `open-default` (the menu's Open on a local file) always does.
`trash` walks the selection one `moveToTrash` at a time. Main-window only
(it injects TransfersService).
- `context-menu.service.ts` + `skins/shadlike/context-menu.ts` — the panel and tab right-click
menus, in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was
retired on ALL platforms rather than kept as a second code path (see
platform-notes.md). The service builds the item list (row / empty-space menu,
the header column picker, and the panel **source picker** — Local, drives,
connections under per-stack `header` items, `~/.ssh/config` hosts — which
replaced a bespoke popover so it gets the same keys and look) and holds the
open request as a signal; the component renders it at a point
(`MenuPoint`: a MouseEvent, or a row rect for a keyboard-opened menu) with
edge-flip clamping, a scrolling clamped box, and close-on-blur/resize/scroll.
Items carry `shortcut` (a keymap id: its first key is the soft hint at 60%
of the row colour, and pressing it while the menu is open picks the item)
and `icon` (drawn in the check column; only the everyday verbs get one —
Send, Edit, Rename, Delete, New Folder, New/Close Tab, the toolbar's own
glyphs). Keys: ↑↓ Home End move the one highlight (mouse hover shares it),
Enter/Space pick, Esc closes, items are not Tab stops; every handled key
stops propagation so the window handler never runs it twice. A keyboard-opened
menu starts with its first item highlighted (`initialIndex`). A request's
`onPick` takes the pick (row menu → `FileActionsService.run`, source picker →
`FilePanel`); otherwise `col:*` / `tab:*` strings go through
`RpcService.emitMenuAction` to the SAME `App.handleMenuAction` dispatcher the
macOS app menubar uses. The three shell actions the webview cannot perform
(reveal, open-with-default, copy path) go back to bun via the
`shellItemAction` RPC, which is all that remains of the old native menu.
`tabItems()` builds the workspace-tab menu (`tab:new`, and
`tab:close|close-others|close-right|disconnect:<id>`), served by
`PanelsService.closeOtherWorkspaces` / `closeWorkspacesToRight` /
`disconnectWorkspace`. **The target id travels inside the action string**: the
right-clicked tab is not necessarily the active one, so nothing on that path
may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not
omitted — a menu whose items move between right-clicks is worse than one with
a dead row. The disabled styling (`pointer-events: none`) only stops the
pointer; the keyboard path never consults it — so `pick()` and the arrow-key
walker check the flag themselves.
- `file-icons.ts` — extension → category map driving glyphs, colors
(fixed `--icon-*` tokens from Oxide's hue scales) and the Kind column.
- `icons.ts` — the one icon registry, passed to `provideIcons` in `app.config.ts`.
A name is `<icon><px>`: the pixel grid its glyph is drawn for, which must match
the size token where it renders (`server14` at `--icon-size-sm`, `server16` at
`-md`, `server24` at `-xl`). The one exception is `tdesignLoading`, which spins.
Every glyph is TDesign's shape redrawn on its pixel grid in `pixel-icons.ts`,
as whole-pixel runs plus 1.25px slants (rules in gotchas). Only `closeCircle24`
(TDesign's own 24px grid) and the spinner still come from
`@ng-icons/tdesign-icons`. Templates can't type-check
icon names, and an unregistered one renders nothing (a console warning only), so
`icons.spec.ts` checks every `<ng-icon>` in the skins. TS code that picks an icon
takes the `Icon14` / `Icon16` types.
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
`EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`) +
`blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside text
fields — the app draws its own menu instead) + `blockReloadKeys` (swallows
unclaimed F5 / Ctrl+R / Ctrl+F5 / Ctrl+Shift+R on Windows, where WebView2
would reload the page and drop every session; each window root calls it
last).
- `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".
## Keyboard
**One keymap** — `src/shared/keymap.ts`, shared so the webviews and bun (the
macOS menu bar) read the same table. `BINDINGS` lists every command: `id`,
`group` (Settings sections), `scopes`, and default combos per platform
(`mac` / `other`, best first: the first is the hint menus and tooltips show).
Combos are platform-neutral strings (`Mod+Shift+N`: Mod = ⌘ on macOS, Ctrl
elsewhere; `Ctrl` a literal ⌃ only macOS tells apart). Letters match the
layout's character (AZERTY's A is A) and fall back to the physical key under
Alt (⌥Z types Ω) and on non-Latin layouts; digits match physically (AZERTY
types "&" on 1); punctuation by character first (AZERTY's comma is on the M
key), physical second (⇧] makes "}"). **Matching is exact**: ⌃F is not ⌘F,
Ctrl+Tab is not Tab, and neither the Windows key nor AltGr is ever part of a
binding. `effectiveKeymap(overrides)` applies `AppSettings.keybindings`
(rows that differ from the defaults; `[]` unbinds) and drops anything
malformed. `conflicts()` / `ownerOf()` work over overlapping scopes; the
spec (`keymap.spec.ts`) proves the defaults conflict-free, round-trippable and
unshadowed on both platforms.
**Scopes.** `panel` = the focused file list (navigation, file commands,
sorting); `main` = anywhere in the main window (tabs, window commands); `editor`
/ `manager` / `settings` per window; `any` = every window (interface scale).
`panel` sits inside `main`, so they conflict-check together.
**`ShortcutsService`** resolves keydowns against the effective keymap
(reactive: a remap applies live in every window via the settings broadcast) and
formats hints — `label(id)` / `labels(id)` / `tip(text, id)` /
`tipFixed(text, 'Shift+Enter')`, with **localized key names** off macOS
(`keys.names.*`: German says Strg+Entf). `claim()` = resolve + `preventDefault`
(so the macOS menu never runs it twice) + auto-repeat only for movement-like
commands (holding ⌘W closes one tab). IME composition is never a shortcut.
From a text field only `firesInTextField()` combos count: ⌘/Ctrl and Alt chords
and F-keys, but not plain keys, not text-editing chords (⌘⌫, ⌥←, ⌘A) and not
Ctrl+Alt off macOS (AltGr).
**Dispatch** (one place per window): an open context menu owns its keys; an
open dialog owns Enter / Esc / Tab while the main window swallows window
commands and the panel commands the macOS menu bar carries (an unclaimed ⌘T
would otherwise reach the menu and open a tab behind the dialog); the focused
list (`FilePanel.onKeydown`) runs, in order, the fixed filter-editing keys
while a query is typed, the `panel` keymap (Shift + a movement key extends the
selection over the rows passed — a fixed rule that follows remaps), lets
`main` matches bubble, then type-to-filter, then Esc's fixed chain (clear the
filter → cancel a connect → clear the selection); `App` runs `main`. Every root
ends with `blockReloadKeys`. Keys that land on `<body>` (a click dropped focus)
are forwarded to the active list (`PanelsService.forwardListKey`), and `App`'s
`(mousedown)` keeps focus on the list after chrome-button clicks.
**Remaps** live in `AppSettings.keybindings` (sanitized on load), edited in
Settings → Keyboard (see the Settings window). `ShortcutsService.rebind`
stores only rows that differ from their defaults.
**macOS key equivalents are page-first.** WKWebView hands ⌘-keys to the page
before the app menu (WebKit `WebViewImpl::performKeyEquivalent`); only keys the
page doesn't `preventDefault` reach the menu bar. So the page owns every app
shortcut, and a window that doesn't claim a key lets it fall through to the
menu (⌘W in a window whose close-window was remapped away still hits Close Tab,
which bun routes to the focused Settings / editor window first).
**Focus without Keyboard navigation.** WKWebView's Tab skips buttons,
checkboxes and radios unless the user turned on System Settings → Keyboard →
Keyboard navigation, so `cycleFocus(root, event)` walks Tab / ⇧Tab by hand
(DOM order, wrap, a radio group is one stop): every dialog (it is also their
focus trap), the transfer list (⌘J opens it focused), the editor's save prompt.
Dialogs carry `role="dialog" aria-modal`, take initial focus (the chmod dialog
on its octal field, Enter applies) and stop Esc from propagating — a
window-level Esc (the connection manager closes on it) must not see it too. A
base-layer `:focus-visible` ring in `--ring` marks the focused control.
**Opening a row's menu from the keyboard**: ⇧F10 / the Menu key / ⌃↩ (macOS)
open the cursor row's menu under the row, first item highlighted; the
selection is kept, its commands act on it like the F-keys do.
## Theming
`ui/src/styles.css`: OKLCH tokens adapted from **Oxide's design system**
(docs.oxide.computer: a cool neutral scale at hue 260 with almost no chroma,
one green accent, opaque strokes; the comments carry Oxide's step names
n0…n1300 / green-800). Dark values are duplicated in `:root[data-theme='dark']`
AND a `prefers-color-scheme` media block (system mode) — every dark token
change goes in BOTH; retone neutrals along the one scale, or warm and cool
surfaces clash. **There is no accent setting**: `--primary` is Oxide green per
theme (green-800 dark, green-500 light), so selection (`bg-primary/15`), the
cursor row (`--cursor-tint`, a `color-mix` of `--primary`), focus rings
(green-600) and `--success` all read green. **Surface layers**, each its own
shade: `--chrome` (frame — tab/transfer/status bars, the editor/manager/settings
header+footer strips and sidebars), the file panel's header strip **stepping
toward the list** — session strip `--toolbar`, path bar `--toolbar-2`, column
headers `--toolbar-3` (`color-mix` steps declared once in `:root`, so every
theme follows) — then `--background` (file lists, editor text, forms;
`--row-alt` stripes odd file rows, skipped on the cursor/selected row so two
`bg-*` utilities never fight) and `--card` (popovers, dialogs, toasts). The
right-click menu is the exception: the darkest surface (`bg-chrome`) behind a
faint `border-border/60`, and its highlighted item takes Oxide's selected style —
`--primary-surface` fill (Oxide surface-accent: green-200 dark, green-1200
light) with `text-primary`; mouse and keyboard share that one highlight
(`activeIndex`). The
ACTIVE panel is marked by a 0.125rem `--primary` line along its top edge (an
inset box-shadow on the session strip, so switching panels never shifts
layout). Dark: frame 0.162 < list 0.195 < header 0.275 → 0.248 → 0.221 < card
0.29; light steps greys out from near-white content: frame 0.925 < header 0.95
→ 0.965 → 0.98 < list = card 0.995. **`--accent` is translucent** (n0 at 5%
light, white 7% dark): hover/selected states sit on every layer, and an opaque
hover vanishes on a surface of its own lightness. Strokes are opaque
(`--border` n300 dark / n900 light, `--input` one step stronger). The
"darker" tone is true-black AMOLED: chrome, background, toolbar (+ its steps)
and row-alt are all `#000` (the layers collapse on purpose); elevation
survives only via `--card`/`--muted`/`--accent` (opaque there). **Square
design**: no `rounded-*` utilities and no radius tokens; an `@layer base`
block restyles native controls to match — checkboxes/radios (`appearance:
none`, a drawn check / square dot), selects (a `currentColor` caret; use
`pl-*`, never `px-*`, on a select), hidden number spinners, and square
`::-webkit-scrollbar`s (which make macOS scrollbars always visible).
`@theme inline` maps tokens to Tailwind utilities (`bg-primary`,
`text-icon-folder`, `bg-toolbar-2`, `bg-cursor-tint`). File-type icon colors
come from Oxide's hue scales (800 steps dark, 600 light). **Icon sizes** follow
TDesign's rule (an icon is 2px larger than its text, in whole pixels) through
four rem tokens, and every `<ng-icon>` takes one: `--icon-size-sm` 14px (beside
11–12px text, small icon buttons), `-md` 16px (13–14px text: file rows, window
titles; the path-bar buttons), `-lg` 20px and `-xl` 24px (lone spinners and
status marks). The glyphs are drawn on the sm, md and xl grids (`icons.ts`).
**Interface scale** (`settings.uiScale`,
0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets
`html { font-size: 16px * scale }`, and since EVERY size in the app is rem
(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes), the
whole UI scales, chrome included.
Driven by the Settings window's stepper, Ctrl/Cmd +/−/0, and preload-baked for a
flash-free first paint. It replaced the 3-tier `data-text` axis; the store
migrates old `textSize` values. TS that measures rem-sized CSS converts through
`@core/metrics` `remPx()` — never an assumed 16.
+190
View File
@@ -0,0 +1,190 @@
# Build & release
## Versioning — "Build n"
Sublime-style: the version IS the commit count. `scripts/gen-version.ts` runs
`git rev-list --count HEAD` + `git rev-parse --short HEAD` and writes the
**gitignored** `src/shared/version.gen.ts` (`BUILD = {number, commit, date,
electrobun}`). It is regenerated by `postinstall` and at the start of every
`dev`/`build`/`start`/`release` script; committing it would bump the count it
records, hence gitignored. Fresh checkouts fail `tsc` until `bun install` has
run once (postinstall creates the file). `BRANDING.version` stays
semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel`
("Build n") is what humans see (About box, Settings → About).
## Dev loop
- `bun run dev` — gen-version → `electrobun prepare` → scripts/dev.ts: starts
`ng serve` (HMR) and `electrobun dev --watch`, with `DEV_SERVER_URL` pointing
the webviews at the dev server (only honored on the dev channel and only if
the server answers).
- `bun run build` — gen-version → `electrobun prepare` → Angular build →
`electrobun build` (**dev channel** — fine for boot checks, NOT a release).
- `scripts/node-version-shim.ts` (preloaded via `bunfig.toml`, root AND ui/)
fakes a Node version the Angular CLI accepts under Bun. Both preloads are
load-bearing.
- **Electrobun 2.x is driven by Hutch.** The `electrobun` devDependency (exact
`2.0.2`) is only a bootstrap: its first command downloads the paired Hutch
(0.27.1) from the Electrobun GitHub release, checks its SHA-256 against
`hutch-artifacts.json`, caches it under `~/.hutch/npm/electrobun/<ver>/`, and
forwards every `electrobun …` to `hutch electrobun …`. Hutch then fetches the
Electrobun core, its build-time Cottontail and the pinned Bun 1.4.0 (the
runtime the app ships) into `~/.hutch` (~200 MB on a fresh machine) and
projects the SDK into the gitignored `.hutch/devkit/` (plus a gitignored
`.cottontail-tmp/` scratch dir). The devDependency version IS the toolchain
pin: moving Electrobun = bump it + `bun install`. No Node needed — the
bootstrap's `#!/usr/bin/env node` runs under `bunx`.
- `bunx electrobun prepare` (re)projects `.hutch/devkit`. It runs from
`postinstall` **and** at the start of `dev`/`start`/`build`/`release` (~0.5 s
when current — the slot the v1 CLI re-sign step held; that step is gone,
Hutch's binaries carry valid signatures). tsc (the root tsconfig extends
`.hutch/devkit/tsconfig.json`) and the Angular build / `ng serve` (the
`electrobun/view` path in `ui/tsconfig.json`) resolve the SDK there *before*
any electrobun command would project it; without it both fail on unresolved
`electrobun/*` imports. Bun stays the package manager: Hutch's own resolver
(`hutch.lock`, no lifecycle scripts) only runs on `hutch install`, which
nothing calls — hence no `hutch.config.ts`.
## Release builds
`bun scripts/release.ts [--target mac-arm64|win-x64|linux-x64]`
(default: host).
Steps: gen-version → electrobun prepare → gen-icons → Angular build → empty
`artifacts/` → `electrobun build --env=stable` → copy/rename the installers into
`release/<target>/miniSCP-Build<n>-<target>.*`.
Facts the script encodes:
- **`--env=stable` is mandatory for releases** — `electrobun build` defaults to
the dev channel (different app name `miniSCP-dev`, no distributable artifacts).
- **No cross-compile, and no Intel Mac.** Hutch builds for its host OS *and*
arch only; packaging needs host tools (codesign/hdiutil on macOS,
rcedit/PowerShell on Windows). Hence one script run per artifact, on the
matching machine; `win-x64` on a non-Windows host and `linux-x64` on a
non-Linux host both error out. **Electrobun 2.x publishes no macOS x64 core
(nor an x64 Hutch)**, so mac-x64 was dropped — the v1 trick of running the x64
CLI under Rosetta has nothing left to run. `--target mac-x64`, and any build
on an Intel Mac, fail with that message.
- **Runtime downloads**: on first use per Electrobun version Hutch fetches the
platform core from `github.com/blackboardsh/electrobun/releases`
(SHA-256-checked against `electrobun-artifacts.json`), plus Cottontail and the
pinned Bun, into `~/.hutch` — network is a build-time requirement on every
fresh machine/runner.
- **`artifacts/` is emptied before the build** — `site/deploy.sh` publishes it
verbatim, so it must hold exactly this build (stale files, e.g. v1-named
installers, would otherwise ship).
- **Artifact shapes** (stable channel, in `artifacts/`): 2.x installers carry
**no channel prefix**; only the updater feed keeps `stable-<os>-<arch>-` (the
names v1.18.1+ clients request). macOS → `macos-arm64-miniSCP.dmg` +
`stable-macos-arm64-miniSCP.app.tar.zst` + `stable-macos-arm64-update.json`
(verified); Windows → `win-x64-miniSCP-Setup.zip` (setup exe + hidden payload)
+ the `stable-win-x64-*` feed, plus the script's own **portable zip** of
`build/stable-win-x64/miniSCP` (warns instead of failing if that folder is
missing); Linux → `linux-x64-miniSCP-Setup.tar.gz` (a self-extracting
`installer` + README) + the `stable-linux-x64-*` feed. The official Linux
installer IS the release artifact — the v1 portable tarball and its
`miniSCP.sh` NVIDIA wrapper are gone, so an installed build on NVIDIA needs
`WEBKIT_DISABLE_DMABUF_RENDERER=1` from the user (see gotchas.md). The
Windows/Linux names come from the 2.x docs — confirm on the first real 2.x
build there. A build that can reach the previous release at
`release.baseUrl` also emits `stable-<os>-<arch>-<previous-hash>.patch`.
- **Linux builds need the AppIndicator dev libraries present to RUN** (not to
build) — see platform-notes.md; an installed build started on a machine
without them dies at `dlopen`.
- The script filters artifacts by app name, so stale artifacts from older
brandings don't get collected.
- **v1 → 2.x bridge**: Windows users on 0.1.78 (the only feed live on
miniscp.com/dl) update through their v1 updater because `app.name`,
`app.identifier` and `release.baseUrl` are unchanged, the build is
`--env=stable`, and `artifacts/` is published unrenamed. 2.x keeps the v1
top-level `update.json` fields (`version`/`hash`/`platform`/`arch`) and the
physical `stable` state root, so user data stays put. Unverified until the
first 2.x Windows release reaches a v1 install.
## CI (Gitea Actions)
`.gitea/workflows/release.yml` — tag-triggered (`build-*`), macos (mac-arm64) +
windows (win-x64) 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 the commit count); `bun install --frozen-lockfile` already
prepares the devkit (postinstall). 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/macos-arm64-miniSCP.dmg`, `/dl/win-x64-miniSCP-Setup.zip`, the portable
zip) so the page never changes per release. 2.x dropped the `stable-` prefix from installer
names (the feed kept it), so the v1 `stable-…-Setup.zip`/`.dmg` installers still on the server
are stale — delete them by hand. Logo/screenshot `<img>` slots are commented in, images live in
`site/assets/`.
- `site/Caddyfile` — one flat `/dl/*` folder with `file_server browse` + range requests, and
per-type `Cache-Control`: `*.patch` immutable (unique hashed names), `*update.json` `no-cache`
(updater must see new versions), installers (`*.dmg *.zip *.tar.zst *.tar.gz`) `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 updater `baseUrl`. Publishing = drop this machine's whole `artifacts/` into
`/dl/`, unrenamed; 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/*`
verbatim 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 need **build-time network to
`miniscp.com/dl`** so Hutch can fetch the currently-published version to diff a `.patch` (a
target with no published feed yet — currently mac and Linux — 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`;
`applyUpdate` goes through `events.applyUpdate` in index.ts); push message `updateStatus`.
- **`applyUpdate()` asks the before-quit guard first (2.x).** With transfers running or dirty
editors the guard vetoes and shows its Quit Anyway / Cancel box. `events.applyUpdate` marks the
quit as update-initiated, so Quit Anyway re-runs `Updater.applyUpdate()` (a plain `Utils.quit()`
would drop the downloaded update); Cancel leaves the pill at "Restart to apply".
- **UI**: `UpdaterService` ([ui/src/app/core/updater.service.ts](../ui/src/app/core/updater.service.ts)) holds signals
(`state`/`version`/`progress`); the tab-bar "update pill" ([tab-bar.ts](../ui/src/app/skins/shadlike/tab-bar/tab-bar.ts))
is one accent (`bg-primary`) control that announces → fills as a progress bar while downloading →
"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 was fine on 1.18.1:** `applyUpdate()` stripped the macOS quarantine xattr before
relaunch. 2.x hands the swap to a native post-exit helper (transactional replace, restores the
old app on failure) — re-check Gatekeeper on the first unsigned 2.x mac update.
## Icons
`scripts/gen-icons.ts` (resvg, dev-time): renders `assets/logo.svg` into the
macOS iconset + `icon.png`, a multi-size `assets/icon.ico` (Windows installer/
taskbar, wired via `electrobun.config.ts win.icon`), the macOS tray template
PNG from `assets/tray/tray-glyph.svg`, and a small colored Windows tray PNG.
Rerun after touching either SVG; outputs are committed (build inputs).
## electrobun config notes
`electrobun.config.ts`: app identity comes from `BRANDING`; **`build.mainProcess:
"bun"` is load-bearing** — 2.x defaults to Cottontail, which ignores the whole
`build.bun` block (verified: without the line, `bunx electrobun config
--env=stable` resolves `mainProcess: "cottontail"` with no externals);
`build.bun.external` must keep `["cpu-features", "*.node"]` (ssh2's optional
native accelerators — bundling them breaks the build; runtime falls back to pure
JS; a 2.x stable bundle keeps both as runtime `require`s); `build.copy` ships the
Angular dist and tray assets into `views://`; `bundleCEF: false` everywhere
(system WebKit / WebView2). `bunx electrobun config --env=stable` prints the
merged config Hutch will build with.
+435
View File
@@ -0,0 +1,435 @@
# Gotchas — landmines this codebase has already stepped on
Every one of these caused a real bug or debugging spiral. Read before
touching the adjacent code.
## Angular (v22, zoneless, signals, OnPush)
- **Never consume push streams through signal+effect.** An effect that reads
a signal and (directly or transitively) writes signals re-triggers itself —
this froze the entire app on the first transfer. Push messages
(transferUpdate etc.) go through plain callback registries in RpcService.
Signal coalescing would also silently drop intermediate progress snapshots.
- **No signal writes during template render** (NG0600). Anything that loads
panels from code paths reachable during render defers with
`queueMicrotask` (see `PanelsService.newWorkspace`).
- **`[value]` on `<select>` is applied before `@for` options render** and
silently selects nothing. Use per-option `[selected]` bindings (connection
manager stack picker).
- Template bindings with special characters: `[class.bg-primary/15]` works;
`[class.shadow-[...]]` (brackets) does NOT parse — use `[style.box-shadow]`.
Static class attributes may use arbitrary values freely.
- Functions called from templates ARE reactive: signals read inside them
register on the view's consumer (call-stack-based tracking). That's why a
template calling `i18n.t()` re-renders on a language switch — `t()` reads
the `locale` signal.
- Dedicated tools > shell: IDE diagnostics between paired template/class
edits are often stale mid-sequence — trust `bun run build:ui` (full AOT
type-check), not the first red squiggle.
- Tailwind v4 canonical class names: `wrap-break-word` not `break-words`,
`accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`.
Every color used in a template must resolve through `@theme inline`.
- Stylesheet `url()` must resolve at BUILD time: assets referenced from
`styles.css` live under `ui/src/` (esbuild emits them hashed into `media/`
and rewrites the url), NOT `ui/public/` — a public path fails the build with
"Could not resolve".
- **Two `class` attributes on one element: the second is silently dropped.**
No warning, no build error — the layout classes simply never apply. This
collapsed the tab strip and two window headers at once (`fe028a9`), because
a scripted edit appended `class="…"` to elements that already had one.
When removing a conditional binding, check what the branch *contained*: the
bindings there were genuinely dead, but the layout classes inside them were
not, and nothing replaced them.
- **A component `template:` is a TS template literal, so no backticks inside
it** — not even in an HTML comment. A backtick in a comment terminates the
literal and the errors point at the prose, not at the quoting.
- **Reach `core/` through the `@core/*` alias, never a relative path.**
Components sit at varying depths and get moved; the alias is what let 67
imports survive two relocations untouched. A `../../core/...` path is a
future merge conflict.
- **The build DOES type-check components nothing imports.** ngtsc compiles the
whole program, so an unreferenced component still fails `bun run build:ui` on
a type or template error (verified by injecting an error into an
unreferenced file).
- **`overflow-x: auto` also clips vertically.** CSS refuses to pair `visible`
with a non-visible axis, so the computed `overflow-y` becomes `auto` too:
anything poking out of a horizontally scrolling strip (a raised tab, an
underline at `-bottom-px`) is silently clipped. Reserve padding instead.
## Theming (`ui/src/styles.css`)
- **A new surface token needs an AMOLED line, or AMOLED stops being
all-black.** The two `[data-dark-tone='darker']` blocks re-declare every
surface (`--chrome`, `--toolbar`, `--background`, `--row-alt`, …) as `#000`;
a token added only to the dark blocks shows through in AMOLED. Tokens derived
in `:root` by `color-mix` (`--toolbar-2/-3`) follow on their own.
- **Hover/selected color is a translucent overlay (`--accent`).** An opaque
hover vanishes on a surface of its own lightness — light's old 0.967 hover
on the 0.965 header strip did exactly that. Keep `--accent` alpha-based when
retoning surfaces.
- **A native control ignores `border-radius` and background until
`appearance: none`.** WebKit draws checkboxes, radios and selects itself;
the square restyle in the `@layer base` block depends on it. Put `pl-*`,
never `px-*`, on a `<select>`: utilities outrank the base layer, so a `px`
utility would erase the padding-right the drawn caret lives in.
- **A custom `::-webkit-scrollbar` turns off macOS overlay scrollbars.** The
moment the pseudo-element is styled, WKWebView draws classic always-visible
scrollbars that take layout width. Deliberate here (square design); don't
reach for it to "tint" scrollbars without accepting that.
- **An icon size is one of the four `--icon-size-*` tokens, never a one-off
rem.** TDesign glyphs are 24-unit SVGs with 2-unit strokes, so a fractional
size (0.9rem = 14.4px) lands every stroke between device pixels and blurs
it. Per-template picks had drifted to 12 sizes, and neighbours stopped
matching: a 14.4px cog beside a 12.8px +, and a 12px + beside 9.6px × in
the shortcut chips.
- **On a 1x screen, TDesign's glyphs smear at 14 and 16px, so every icon is
redrawn on its pixel grid (`pixel-icons.ts`).** Their 24-unit strokes land
between pixels, and a ~1px line covers two pixels at half ink. This is
geometry, not Electrobun: headless Chrome renders the same. The drawing rules
each cost a debugging round:
- **Straight lines are runs of whole pixels** (`h`, `v`, `box`) that end on
pixel edges. Never rely on caps or joins:
- WebKit drops square caps on a `<path>` with several subpaths.
- Skia (WebView2, WebKitGTK) strokes 1px lines as hairlines at 1x, which
leave a joined corner pixel three-quarters inked.
- **Slants and curves (`slant`) are 1.25px**, so they read as heavy as the
crisp 1px runs. Put their ends, vertices and a circle's extremes on pixel
centres; a 45° line is cleanest where x−y or x+y is whole.
- **A glyph is exact only on its own grid**: a 14px glyph blurs at 16px, so
the name's suffix must match the size token (`icons.spec.ts`). It is also
exact only at UI scale 100/200/300%.
- **Check new glyphs at 1x in WebKit and Chrome, not resvg.** resvg skews a
shape's closing edge and leaves path corners partly inked, so its pixels
don't predict either browser.
## The interface scale (root font-size lever)
- **electrobun's native page zoom is a silent no-op on Linux — do not reach for
it.** `webviewSetPageZoom` in the shipped `libNativeWrapper.so` (v1.18.1, and
the `_cef.so`, and the copy in our own build output) is a compiled
empty-body stub: the disassembly has no `call` instruction, and
`webkit_web_view_set_zoom_level` is not even in the library's imports.
`webviewGetPageZoom` returns a hardcoded `1.0` from `.rodata`, so the failure
is unobservable through the API — set 2.0, read back "1.0 = success". The
JSDoc's "(WebKit only)" means macOS WKWebView. This is why the scale is CSS.
(Disassembled on 1.18.1; 2.0.2's zoom fixes are CEF-only — re-check the
native Linux wrapper before trusting it.)
- **Everything the UI sizes with must be rem.** The scale works by setting the
root font-size, so a px value freezes while everything around it grows. The
px reservoirs were the panel-chrome tokens (`--row-h` etc.) and 93
`text-[11px]` arbitraries. 1px hairlines and focus rects stay px deliberately
(crisp at any scale).
- **px constants in TS that mirror rem-sized CSS are latent scale bugs.** The
context menu's `MENU_W = 224` was a hardcoded mirror of `w-56`
(14rem): box grew, math didn't, flip fired at the wrong edge. Convert through
`@core/metrics` `remPx()` at use time — it reads the LIVE root font-size,
and per call, since the scale changes mid-session. The editor's canvas-based
`charWidth` is cached keyed on the computed font string for the same reason.
## Electrobun
- **The `electrobun` npm package (2.x) is a bootstrap whose every export is a
throwing stub** ("Electrobun 2.x APIs come from the Hutch devkit, not
node_modules"). Every `electrobun/*` import must resolve through the
projected `.hutch/devkit/`: the root tsconfig extends
`.hutch/devkit/tsconfig.json`, and `ui/tsconfig.json` maps `electrobun/view`
to `../.hutch/devkit/api/browser/index.ts` (the exact export-map target). Let
an import fall through to `node_modules/electrobun` and the webview throws at
load. tsc and the UI build fail on unresolved `electrobun/*` imports until
`bunx electrobun prepare` has run (postinstall and every dev/build script do
it). Never point anything into `~/.hutch` — that store is Hutch-owned.
- **`build.mainProcess` defaults to Cottontail in 2.x**, and Cottontail ignores
the whole `build.bun` block — the ssh2 externals included, silently (verified
with `bunx electrobun config --env=stable`). `mainProcess: "bun"` in
`electrobun.config.ts` is load-bearing.
- **The browser SDK is raw TS compiled inside the Angular program**, so the UI's
strictness flags apply to upstream code: 2.0.2's `createTransport().send`
returns on one path only, which is why `ui/tsconfig.json` has
`noImplicitReturns: false`. Its `import "./global.d.ts"` also prints "File
'…/global.d.ts' not found in TypeScript compilation" on every build — benign,
and adding the file to the program does not silence it.
- **Window `close` is not cancellable, but 2.x added `will-close`.** The `close`
event's response type is `{}`; `will-close` (fired by the titlebar/traffic-light
close and `win.requestClose()`) honors `event.response = {allow:false}`, and
`win.close()` skips it. The editor close safety predates this: all
unsaved-state guards still intercept app-controlled close paths (⌘W, buttons)
or survive the close (editor draft mirroring) — a `will-close` prompt is a
possible follow-up.
- **`Updater.applyUpdate()` asks the `before-quit` handlers first (2.x)**, so the
transfers/unsaved guard can veto an update restart. Its "Quit Anyway" must
re-run `Updater.applyUpdate()` (`events.applyUpdate` flags the quit as
update-initiated) — a plain `Utils.quit()` there quits without installing.
`Utils.quit()` runs the same approval, which is why the guard checks
`quitConfirmed` first.
- **macOS ⌘-keys go to the PAGE first; only unclaimed ones reach the app
menu.** WKWebView's `performKeyEquivalent` hands key equivalents to the page
while the webview is first responder (WebKit `WebViewImpl.mm`: "lets
webpages have a crack at intercepting key-modified keypresses"); a key the
page `preventDefault`s never reaches the menu. Old comments here claimed the
opposite ("the menu intercepts ⌘W first") — what was really happening is that
windows which didn't claim ⌘W let it fall through to the menu's Close Tab.
Two consequences: a handled shortcut MUST `preventDefault` (`claim()` does),
or the menu runs it a second time; and a ⌘W a secondary window doesn't claim
still arrives as the MAIN window's `close-tab` action — bun checks the
focused Settings window, then editors, first and closes THAT, so a new
window kind must either claim close-window or join that check.
- **One rpc instance per window** — BrowserView binding steals the rpc's
transport; a shared instance silently sends to the wrong webview.
- **RPC before socket open was silently lost on 1.18.1.** 2.x Electroview queues
sends while its `hostSocket` (v1: `bunSocket`) is CONNECTING and falls back to
the native bridge; RpcService still gates everything on the socket-open
promise (`ready`) as belt-and-braces.
- The emitter has no `off()`. Window event closures must not capture records
you want garbage collected.
- `electrobun build` defaults to the **dev** channel; release builds need
`--env=stable`. The stable app name differs from the dev one
(`miniSCP` vs `miniSCP-dev`), including the app-data dir implications when
testing.
- Tray: never call `setImage` after construction on macOS — the template
treatment is lost and the icon renders black on dark menu bars.
- `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are
single-character only.
- **`ContextMenu.showContextMenu` is a silent no-op on Linux** (electrobun's own
`.hutch/devkit/api/sdks/main/proc/linux.md` says so, still in 2.0.2; the app
also prints "Application menus are not supported on Linux" at boot). Because
the UI suppresses the webview's own right-click menu, that left EVERY panel
context menu dead on Linux — no error, nothing happens. The menu is plain
HTML now (`ui/src/app/skins/shadlike/context-menu.ts`) on every platform;
don't reintroduce the native one for "nativeness" on mac/win without keeping
a Linux path.
- **Window-open flash prevention is three-layer** — remove any one and the
white/inverted blink returns: (1) windows are created `hidden: true` and
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
fallback), (2) the bun preload bakes the persisted `data-theme`/tone
attributes and the interface scale before first paint (`buildPreload`),
(3) `ui/src/index.html` carries an inline `<style>` with the html background
per theme — its oklch values MUST stay in sync with the `--background` tokens
in styles.css. (The win7 branch once replaced (3) with a transparent rule and
the modern skin silently lost it — the other two layers do not cover it.)
- Dev vs prod URL: `getMainViewUrl()` resolves the ng-serve dev server only
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the
server answers a HEAD request; secondary windows must reuse the resolved
URL, never hardcode `views://`.
- Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`):
rare segfault under long sessions; upstream electrobun×Bun issue, restart
recovers, nothing app-side to fix. Seen on Bun 1.3.13 (Electrobun 1.18.1);
2.0.2 ships Bun 1.4.0 — unconfirmed whether that fixed it.
- **Preloads and frames are construction-time only.** The preload is a
persistent `WebKitUserScript` at DOCUMENT_START, frozen at webview
construction — `webview.loadURL()` re-runs the OLD one
(`updatePreloadScriptToWebView` is only reachable from electrobun's internal
`<electrobun-webview>` handler). `titleBarStyle`/`transparent` have no
setters. Anything `buildPreload` bakes is a first-paint snapshot the UI must
correct live over RPC. Recreating the main window is not an escape hatch:
`exitOnLastWindowClosed`, push closures and the tray capture it, and there
is no `off()`.
- **`trafficLightOffset` changed meaning in 2.x.** 1.18.1 nudged the buttons
by the offset but clamped them inside the stock titlebar, so `{12, 13}` really
meant "as low as the titlebar allows" (left 21 / top 18 on macOS 26). 2.x adds
the full offset to macOS's default position (9/9 in the 32pt Tahoe titlebar)
and grows the titlebar to fit — the same numbers dropped the lights 4pt.
`chromeOptions()` now passes `{12, 4}`: left 21 / top 13, the 14pt buttons
vertically centred in the 40px `h-10` header (1.18.1's clamped spot, top 18,
was 5pt low). The default shifts with macOS's titlebar metrics (28pt / 7
before macOS 26), so re-check after an OS release. `win.setWindowButtonPosition(x, top)` is the absolute
alternative (re-applied on resize/fullscreen) if per-version drift matters.
- **Drag regions ignore `preventDefault`.** The preload starts a window move
from a bubble-phase `mousedown` on `document` and never reads
`defaultPrevented`; a `pointerdown` handler doesn't stop it either (the
compat `mousedown` still fires). `isAppRegionDragTarget` lets a `no-drag`
region win — the `.electrobun-webkit-app-region-no-drag` class (or, since
2.x, `app-region: no-drag` CSS) on the element is the only reliable
suppressor (`.hutch/devkit/api/preload/dragRegions.ts`; re-checked on 2.0.2).
- **No resize, maximize-event or double-click-maximize primitives (1.18.1, still
absent from 2.0.2's `BrowserWindow`).**
`titleBarStyle: "hidden"` drops the whole frame (Cocoa `Titled` — the only
way to remove the traffic lights; `gtk_window_set_decorated(false)` on
Linux) and with it the WM resize borders; nothing restores them (no
`startWindowResize`, no `gtk_window_begin_resize_drag`, no
`setResizable`/min-size options — only `setFrame`/`getFrame`). No maximize
event exists (derive it from `resize` + `isMaximized()`), and drag regions
don't double-click-maximize. A custom-chrome window has to build all three.
## Linux
- **`ERR_DLOPEN_FAILED` on `bun run start`** means a missing system library, not
broken app code. `libNativeWrapper.so` links `libayatana-appindicator3.so.1`
*hard* (for the tray), so the app cannot start at all without it — even though
the tray is a minor feature. Diagnose with
`ldd build/dev-linux-x64/*/bin/libNativeWrapper.so | grep 'not found'`; the
`libasar.so => not found` line in that output is a red herring (it ships beside
the wrapper and resolves at load). Install list per distro is in
[platform-notes.md](platform-notes.md).
- **Black window on NVIDIA + `Failed to create GBM buffer of size WxH: Invalid
argument`** (usually with `X11 Error: GLXBadWindow (code 170)`). The app boots,
logs "miniSCP started", reports no error — and paints nothing. WebKitGTK's
DMABUF renderer can't allocate buffers on the NVIDIA proprietary driver;
`WEBKIT_DISABLE_DMABUF_RENDERER=1` is the upstream workaround.
- **The app cannot apply this to itself.** WebKit reads the variable when it
forks its web process, and **Bun's `process.env` writes do not propagate to
child processes** (verified: a spawned child sees it unset). Setting it in
`src/bun/index.ts` — even in a module imported before `electrobun/main` —
does nothing. It must be in the environment before the app process starts.
- Only the dev launch paths inject it: `scripts/linux-gpu.ts` (`launchEnv()`)
for `bun run dev` / `bun run start`. **A new dev launch path must call
`launchEnv()` or it will render black on NVIDIA.** The release is the
official 2.x Setup installer now (the v1 portable tarball and its
`miniSCP.sh` wrapper are gone), so an installed build on NVIDIA needs the
user to launch it with `WEBKIT_DISABLE_DMABUF_RENDERER=1` (e.g. in the
desktop entry's `Exec`).
- Gated on `/proc/driver/nvidia` so Intel/AMD keep hardware compositing, and
an explicit `WEBKIT_DISABLE_DMABUF_RENDERER` from the user always wins.
- A "does it boot?" log check does NOT catch this — grep for the GBM line, or
look at the window.
- **`bun run start` leaves the old instance running, and
`import -window miniSCP` grabs the OLDEST match.** Instances pile up across
rebuilds, so every screenshot silently comes from a stale build and the fix
you just made looks like it did nothing. Cost three wrong readings in one
session, including a "the CSS is in the bundle but does not apply" dead end.
Kill first (`pkill -f dev-linux-x64`), confirm with
`pgrep -cf 'dev-linux-x64/miniSCP-dev/bin/launcher'`, then launch and capture.
Note `pkill -f 'electrobun dev'` also matches the shell running it and kills
your own command (exit 144) — match on the build path instead. That is only
half the trap: `pkill -f dev-linux-x64` does the same thing, because the
pattern appears in the killing command's OWN argv. Break the literal with a
character class — `pkill -f 'dev-linux-x[6]4'` — the way `ps | grep [x]yz`
has always worked.
- **Launching the app steals focus — ask first.** `bun run start` opens a real
window that takes focus, which interrupts whatever the user is doing; there
is no Xvfb/xvfb-run on this machine and Wayland offers no
launch-without-focus. Batch every live check into a single launch.
- Benign startup noise on Linux — non-fatal, the app runs normally:
`libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib`
(upstream electrobun's choice, not ours).
## Keyboard (`src/shared/keymap.ts`, `ui/src/app/core/shortcuts.service.ts`)
- **WKWebView skips buttons, checkboxes and radios on Tab** unless the user
turned on System Settings → Keyboard → Keyboard navigation (or presses ⌥Tab).
Dialogs, Settings and the manager were mouse-only on a default Mac. Tab is
walked by hand: `cycleFocus(root, event)` (core/focus-cycle.ts) — use it in
any new dialog or window.
- **WebKit doesn't focus a clicked button — focus drops to `<body>`** (Windows
parks it on the button, where Space clicks it again). Every list key died
until the list was clicked. `App` `(mousedown)` keeps focus on chrome-button
clicks and forwards `<body>` keys to the active list; keep both.
- **Match modifiers exactly.** The old panel switch caught every Tab, so
Ctrl+Tab flipped the active panel AND switched tabs; `isMod = metaKey ||
ctrlKey` made ⌃F (Cocoa's forward-one-character) open the editor's find.
`matchesEvent` compares all four modifiers, platform-exact.
- **AltGr is Ctrl+Alt on Windows/Linux.** A Ctrl+Alt+letter binding steals
"@", "€", "ł"… — never a default there, never fired from a text field, and
an event with `getModifierState('AltGraph')` matches nothing.
- **Type-to-filter takes every printable key.** A plain-letter binding (vim
j/k) must resolve BEFORE it — and then that letter no longer starts a query
(Settings warns). While a query is being typed, printable keys (Space too)
always extend it.
- **In a master-detail list, selection must not follow focus** when the detail
is an unsaved form: the connection manager's `edit()` replaces the draft
without asking, so arrowing through the list would silently discard edits.
Arrows move focus; Enter loads.
- **A dialog's Esc must `stopPropagation`.** The connection manager closes on
a window-level Escape; the dialog cleared its own signal first, so the
window handler saw "no dialog open" and closed the whole window.
- **An unclaimed key behind a dialog reaches the macOS menu** — ⌘T opened a
tab behind the rename prompt. The main window swallows window commands and
the menu bar's panel commands while a dialog or menu is up.
- **WebView2 reloads the page on unclaimed F5 / Ctrl+R** (default browser
accelerators), dropping every session and the editor's unsaved text. Every
window root ends with `blockReloadKeys`; a blanket `stopPropagation` (the old
path bar) hides keys from it — don't add one.
- **Hold-to-repeat** fired ⌘W once per repeat — every tab closed. `claim()`
repeats only movement-like commands (`isRepeatable`).
- **Electrobun's native menu accelerator turns a combo with no modifier token
into ⌘+key** ("f7" shows and fires ⌘F7), its `backspace` / `delete` tokens
are characters AppKit matches unreliably, and AppKit matches Shift+punctuation
by the shifted character ("}" for ⇧]). `toAccelerator` returns null for all of
those; the menu item then shows no key and the page still handles it.
- **Recording a key must take the keydown before every window handler** — and
must NOT take reserved ones: preventDefault on ⌘Q while recording would make
the app unquittable from the keyboard.
- **Focus after a state change needs `afterNextRender({ injector })`.** Under
zoneless CD `queueMicrotask` runs before the render that creates an `@if`
element (the editor's find box and textarea), so `.focus()` hit nothing.
## Domain logic
- **sessionId ≠ filesystem identity.** Two panels on the same server are two
SSH sessions. Every same-file/nested-path guard compares `fsKey`
(`resolvedIP:port` / `"local"`); comparing sessionId caused a
truncate-to-zero data-loss bug. When fsKeys differ but paths collide, the
probe-file check unmasks aliased hosts. Never "simplify" this back.
- `createWriteStream` truncates BEFORE the source is read — that's why
self-copy is destructive, and why guards must run before any stream opens.
- The synthetic `'..'` row: type `'dir'`, must be excluded from selection,
targets(), filtering match-count, kind/rights/owner display, and drag
sources. Any new row-level feature must decide what `'..'` does.
- `Semaphore.wake()` must reserve slots synchronously; waking waiters that
increment asynchronously loses budget under concurrent release.
- Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so
new keys auto-default, but the UI SettingsService reads field-by-field —
a new setting silently stays at default until wired in all five spots.
- **Saves are whole-object: a window must not save before its first
`getSettings` landed.** A ⌘+ pressed while an editor opened wrote
`DEFAULT_SETTINGS` (no keyboard remaps) over the user's file.
`SettingsService.persist()` is a no-op until `applyAll` ran once.
- **Every window's RPC must serve `saveSettings`.** The scale shortcuts
shipped in editor and manager windows while only the main RPC served it:
the request went unanswered, the change applied locally, and main's next
broadcast snapped it back. All three factories now share
`saveSettingsHandler`, and `settingsSaved()` in index.ts rebroadcasts to
every window but the sender — an echo would drag a held Cmd+ backwards.
- The editor draft mirror sends full content per tick (2 MB cap upstream);
don't shrink the 400 ms debounce.
- `panel.touched` gates localDir auto-navigation; `connectToken` guards
against a slow connect resolving into a panel the user already reused.
## i18n
- **Translations live in `src/shared/i18n/*.ts` (TS, not JSON)** — deliberate:
it sidesteps `resolveJsonModule` differences between the Angular esbuild and
Bun builds and gives full typing. `en.ts` is the source of truth; other
locales are partial and fall back to English then the raw key.
- **`i18n.t()` is reactive because it reads the `locale` signal** — that's what
makes live language switching work with no pipe. If you cache a translated
string in a plain field it WON'T update on switch; call `t()` in the template
(or in a signal-reading getter) instead. Same for `i18n.date()/size()`.
- Dictionary values are TS string literals: **French/other apostrophes break
single-quoted entries** (`'…c'est…'`). Use double quotes for any value
containing an apostrophe. Plurals are `{ one, other }` maps (not ICU strings);
zh/ja supply only `other`.
- **Native menus can't use the Angular service** — `menu.ts` goes through backend
`i18n.ts`, and the app menu must be rebuilt when the locale changes (wired in
index.ts `settingsChanged`), or it keeps the old language. The context menu no
longer has this problem: it is Angular now and reads `I18nService` directly.
- `eu`/`zh`/`ja` are AI drafts pending native review (see the i18n README).
- **Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched
result.** A normal `refresh()` is masked by `busy` (input blocked), but the
silent variant (poll/watch) runs while the user can navigate — without the
guard, a poll that started in dir A resolves and stamps A's listing over the
dir B the user just opened. It also never calls `scrollCursorIntoView`
(scroll must stay put) and returns early if `busy()` or already refreshing.
## Build / tooling
- Angular CLI rejects Bun's Node-compat version — `scripts/node-version-shim.ts`
is preloaded via `bunfig.toml` at the root AND in `ui/`. Removing either
breaks `ng`.
- ssh2's optional native deps must stay `external: ["cpu-features", "*.node"]`
in `electrobun.config.ts` — bundling them breaks the build; ssh2 falls back
to pure JS at runtime. (Inert without `mainProcess: "bun"` — see Electrobun.)
- **2.x installers dropped the `stable-` prefix; the updater feed kept it.**
`macos-arm64-miniSCP.dmg` / `win-x64-miniSCP-Setup.zip` /
`linux-x64-miniSCP-Setup.tar.gz` next to `stable-<os>-<arch>-update.json`,
`.tar.zst` and `.patch`. Anything that globs `artifacts/stable-*` misses the
installers — release.ts collects by `<os>-<arch>-`, deploy.sh publishes
`artifacts/*` verbatim (release.ts empties `artifacts/` first), and the site
links the new names.
- `bun run --cwd ui build` (flag order matters; `bun --cwd ui run build` does
not do what you want).
- `version.gen.ts` is gitignored and regenerated by postinstall + every build
script; a fresh checkout fails `tsc` until `bun install` (or any script)
has run once. Never commit it — it would bump the commit count it records.
- Icon pipeline is `scripts/gen-icons.ts` (resvg) — regenerate after touching
`assets/logo.svg` or `assets/tray/tray-glyph.svg`; the .ico and tray PNGs
are build inputs, not build outputs.
+192
View File
@@ -0,0 +1,192 @@
# Platform notes — macOS / Windows / Linux behavior matrix
macOS (Apple Silicon) is the primary target; Windows 11+ x64 and Linux x64 are
**beta** targets (win-arm64 and linux-arm64 deferred). **Intel Macs were
dropped** with the move to Electrobun 2.x, which publishes no macOS x64 core.
Every platform seam carries a `PORTABILITY:` comment at the branch site.
## Seam inventory (where platform branches live)
| Concern | File | mac | win | linux |
|---|---|---|---|---|
| Window chrome | `src/bun/windows.ts chromeOptions` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
| Scrollbars | `ui/src/styles.css` (`::-webkit-scrollbar`) | custom square, always visible (styling them turns off overlay scrollbars) | custom square | custom square |
| External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` |
| 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` |
| Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty |
| chmod/Permissions | context-menu.service.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full |
| 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 | `src/shared/keymap.ts formatCombo` (+ `keys.names.*`) | ⌘⇧⌥⌃ glyphs: ⇧⌘N | Ctrl+Shift+N, localized key names (Strg+Entf) | as Windows |
| Command modifier | `keymap.ts` `Mod` | ⌘ only (⌃ stays Cocoa's text keys) | Ctrl | Ctrl |
| Key equivalents | `shortcuts.service.ts claim` | page first, unclaimed → app menu | page only (no app menu) | page only |
| Tab focus | `core/focus-cycle.ts cycleFocus` | WKWebView skips buttons/checkboxes without Keyboard navigation → walked by hand | native order (same walk) | native order (same walk) |
| Reload keys | `platform.ts blockReloadKeys` | — | WebView2 reloads on F5 / Ctrl+R → swallowed when unclaimed | — |
| 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; accelerators follow the keymap (remaps included), ⌘Q fixed | none (early return) — Settings opens from the tab-bar gear (Ctrl+,), About from its About section | none (unsupported upstream) |
| connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 |
| 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 |
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
The **context menu is HTML on every platform** (`ui/src/app/skins/shadlike/context-menu.ts` +
`context-menu.service.ts`), not a native electrobun menu — see the Linux section
below for why. Only the three shell actions it can't do itself (reveal, open with
the default app, copy path) cross to bun, via the `shellItemAction` RPC.
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
(`path.posix` throughout `fs/sftp.ts`).
## 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. (Hard link and path verified on
1.18.1 — re-check both on the first 2.x Linux build.)
**Tray visibility** is a separate matter from linking: GNOME 3.26+ hides the
legacy tray, so the icon needs the
[AppIndicator extension](https://extensions.gnome.org/extension/615/appindicator-support/).
KDE Plasma, XFCE, MATE and Cinnamon show it out of the box. The app runs fine
either way — the tray is a convenience, and every action it offers exists in-app.
## Linux known issues (upstream electrobun)
- **No native menus of any kind.** Application menus and `showContextMenu` are
both no-ops (electrobun's `.hutch/devkit/api/sdks/main/proc/linux.md`, still in
2.0.2; the app logs
"Application menus are not supported on Linux" at boot). The app menubar was
already macOS-only, and the panel context menus are now HTML on every platform,
so nothing is missing at the UI level — but do not add a feature that depends
on a native menu.
- **NVIDIA renders black without the DMABUF workaround.** WebKitGTK can't
allocate GBM buffers on the proprietary driver, so the window paints nothing
while the log looks perfectly healthy. The dev launches
(`scripts/linux-gpu.ts`) set `WEBKIT_DISABLE_DMABUF_RENDERER=1` when
`/proc/driver/nvidia` exists; an installed build (the official Setup
installer) does not — launch it with that variable set (e.g. in the desktop
entry's `Exec`). The app can't do it itself (Bun's `process.env` writes don't
reach child processes); see [gotchas.md](gotchas.md#linux).
- **Benign startup noise**: a deprecation warning pointing at
`libayatana-appindicator-glib`. Not fatal.
- **Unsigned; Electrobun's own installer**: the release artifact is
`linux-x64-miniSCP-Setup.tar.gz` — a self-extracting `installer` + README
(2.x does the desktop-entry packaging and ships an uninstaller); no AppImage
or `.deb`. Layout unverified until the first 2.x Linux build.
## First-Linux-boot smoke test
1. Install the AppIndicator package for your distro (table above), then
`bun run start`. The window opens **and actually renders the UI** (a black
window on NVIDIA means the DMABUF workaround didn't apply — check for
`Failed to create GBM buffer` in the log), and the tray icon appears (or
doesn't, on bare GNOME — that's the extension caveat, not a failure).
2. Right-click a file row: menu appears at the pointer, Send to Other Panel is
first, Permissions is present, Escape / click-outside / arrow keys + Enter all
work, and a right-click near the bottom-right flips the menu instead of
overflowing.
3. Right-click empty panel space (New Folder / New File / Refresh / Show in File
Manager) and a column header (checkmarks toggle columns live).
4. Copy Path puts the path on the clipboard; Show in File Manager opens the
distro's file manager; Open (local file) launches the default app via
`xdg-open`.
5. local↔remote transfers both directions; external editor round-trip
(temp download → save → auto-upload) with the inotify watcher.
6. Move to Trash lands the item in the desktop's trash; connections.json is 0600.
7. `bun scripts/release.ts --target linux-x64` produces
`release/linux-x64/miniSCP-Build<N>-linux-x64-Setup.tar.gz`; unpack it
elsewhere, run its `installer`, then launch the installed app (on NVIDIA with
`WEBKIT_DISABLE_DMABUF_RENDERER=1`).
## Windows known issues (upstream electrobun, documented not fixed)
- **HiDPI blur** — WebView2 renders at 1x on scaled displays
(blackboardsh/electrobun#324, seen on 1.18.1). Most user-visible defect; 2.x
lists per-monitor DPI setup among its Windows fixes — re-check on the first
2.x Windows build.
- **Notifications** are legacy balloon style (`Shell_NotifyIcon`), not
Windows toasts.
- **Unsigned binaries** — Setup.exe and the portable exe both trigger
SmartScreen / Mark-of-the-Web on first run. No Authenticode support (2.x:
"Windows release signing is not currently part of Hutch's packaging
pipeline"; a signtool post-hook is an option later). Installer unpacks to
`%LOCALAPPDATA%`, desktop shortcut only; 2.x installer builds add an
uninstaller (`%LOCALAPPDATA%\<identifier>\<install-root>\uninstall.exe`).
- `hiddenInset` degrades to `hidden` on Windows — irrelevant here because
non-mac windows use the native titlebar.
- `findInPage` is a `window.find()` shim on WebView2 — irrelevant here, the
editor find is custom.
## First-Windows-boot smoke test
Code paths written blind (no Windows box was available); verify in order:
1. Build: `bun scripts/release.ts --target win-x64` on the Windows machine —
Hutch, the core and the pinned Bun download into `~/.hutch` on first run;
Setup.zip AND the portable zip appear under `release/win-x64/` (the portable
step warns instead of failing if the assembled-app folder layout differs —
fix the `appDir` path in release.ts if so). A v1 0.1.78 install must pick up
the published 2.x build through its updater (the bridge release).
2. App boots, tray icon renders colored, window has a native titlebar.
3. Drives dropdown in each panel lists `C:\` (+ others); navigating to a
drive root hides the `'..'` row; the path bar shows consistent `\` paths.
4. local↔remote transfers both directions; self-copy guard still blocks
same-path copies.
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 →
auto-upload works (directory watcher on NTFS).
6. Context menus: Send to Other Panel is the first row item; Show in Explorer,
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+…".
8. connections.json ACL: `icacls connections.json` shows only the current
user after saving a connection.
9. Editor/manager/Settings windows open, modal bounce works, Ctrl+, opens
Settings and About shows from its About section.
10. Known-issue check: HiDPI blur on a scaled display (seen on 1.18.1 — 2.x's
per-monitor DPI setup may have fixed it).
+13 -1
View File
@@ -7,7 +7,18 @@ 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: {
// Load-bearing: 2.x defaults to Cottontail, which ignores the whole
// `bun` block below — the ssh2 externals included.
mainProcess: "bun",
bun: { bun: {
entrypoint: "src/bun/index.ts", entrypoint: "src/bun/index.ts",
// ssh2's optional native accelerators must never be bundled; its // ssh2's optional native accelerators must never be bundled; its
@@ -27,7 +38,8 @@ export default {
bundleCEF: false, bundleCEF: false,
}, },
win: { win: {
bundleCEF: false, bundleCEF: false, // system WebView2 keeps the bundle small
icon: "assets/icon.ico",
}, },
}, },
} satisfies ElectrobunConfig; } satisfies ElectrobunConfig;
+13 -6
View File
@@ -3,16 +3,19 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"workspaces": ["ui"], "workspaces": [
"ui"
],
"scripts": { "scripts": {
"dev": "bun scripts/dev.ts", "dev": "bun scripts/gen-version.ts && bunx electrobun prepare && bun scripts/dev.ts",
"dev:ui": "bun run --cwd ui start", "dev:ui": "bun run --cwd ui start",
"build:ui": "bun run --cwd ui build", "build:ui": "bun run --cwd ui build",
"build": "bun run build:ui && bunx electrobun build", "build": "bun scripts/gen-version.ts && bunx electrobun prepare && bun run build:ui && bunx electrobun build",
"start": "bun run build:ui && bunx electrobun dev" "start": "bun scripts/gen-version.ts && bunx electrobun prepare && bun run build:ui && bun scripts/start.ts",
"postinstall": "bun scripts/gen-version.ts && bunx electrobun prepare",
"release": "bun scripts/release.ts"
}, },
"dependencies": { "dependencies": {
"electrobun": "1.18.1",
"ssh-config": "^5.2.0", "ssh-config": "^5.2.0",
"ssh2": "^1.17.0" "ssh2": "^1.17.0"
}, },
@@ -20,7 +23,11 @@
"@resvg/resvg-js": "^2.6.2", "@resvg/resvg-js": "^2.6.2",
"@types/bun": "latest", "@types/bun": "latest",
"@types/ssh2": "^1.15.5", "@types/ssh2": "^1.15.5",
"electrobun": "2.0.2",
"png-to-ico": "^3.0.2",
"typescript": "~5.9.3" "typescript": "~5.9.3"
}, },
"trustedDependencies": ["@parcel/watcher"] "trustedDependencies": [
"@parcel/watcher"
]
} }
+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() {
+10 -3
View File
@@ -1,10 +1,12 @@
// Regenerate every raster icon asset from the SVG sources (dev-time only). // Regenerate every raster icon asset from the SVG sources (dev-time only).
// bun scripts/gen-icons.ts // bun scripts/gen-icons.ts
// Produces: assets/icon.png (1024), assets/icon.iconset/* (macOS app icon), // Produces: assets/icon.png (1024), assets/icon.iconset/* (macOS app icon),
// assets/tray/tray-44-template.png. // assets/icon.ico (Windows), assets/tray/tray-44-template.png (macOS tray),
// assets/tray/tray-32-color.png (Windows tray).
import { mkdir } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { Resvg } from "@resvg/resvg-js"; import { Resvg } from "@resvg/resvg-js";
import pngToIco from "png-to-ico";
const root = path.join(import.meta.dir, ".."); const root = path.join(import.meta.dir, "..");
const assets = path.join(root, "assets"); const assets = path.join(root, "assets");
@@ -27,8 +29,13 @@ for (const size of [16, 32, 128, 256, 512]) {
await writePng(path.join(assets, "icon.iconset", `icon_${size}x${size}@2x.png`), logo, size * 2); await writePng(path.join(assets, "icon.iconset", `icon_${size}x${size}@2x.png`), logo, size * 2);
} }
// ---- tray base (22pt status item @2x) ---- // ---- Windows app icon (.ico: 16/32/48/256, used by installer + taskbar) ----
const icoPngs = [16, 32, 48, 256].map((size) => Buffer.from(renderPng(logo, size)));
await Bun.write(path.join(assets, "icon.ico"), await pngToIco(icoPngs));
// ---- tray: macOS 22pt template @2x; Windows small colored icon ----
const trayGlyph = await Bun.file(path.join(assets, "tray", "tray-glyph.svg")).text(); const trayGlyph = await Bun.file(path.join(assets, "tray", "tray-glyph.svg")).text();
await writePng(path.join(assets, "tray", "tray-44-template.png"), trayGlyph, 44); await writePng(path.join(assets, "tray", "tray-44-template.png"), trayGlyph, 44);
await writePng(path.join(assets, "tray", "tray-32-color.png"), logo, 32);
console.log("icons regenerated: app iconset + tray template"); console.log("icons regenerated: app iconset + icon.ico + tray images");
+46
View File
@@ -0,0 +1,46 @@
// Writes src/shared/version.gen.ts (gitignored) with the "Build n" version:
// n = commit count since the repo root, Sublime Text style. Regenerated by
// postinstall and by every dev/build/release script — never committed, since
// committing it would bump the very count it records.
import path from "node:path";
function git(args: string[]): string | null {
try {
const proc = Bun.spawnSync(["git", ...args], {
cwd: import.meta.dir,
stdout: "pipe",
stderr: "ignore",
});
if (!proc.success) return null;
return proc.stdout.toString().trim() || null;
} catch {
return null;
}
}
const number = Number(git(["rev-list", "--count", "HEAD"]) ?? 0);
const commit = git(["rev-parse", "--short", "HEAD"]) ?? "dev";
const date = new Date().toISOString().slice(0, 10);
let electrobun = "unknown";
try {
const pkg = await Bun.file(
path.join(import.meta.dir, "..", "node_modules", "electrobun", "package.json"),
).json();
electrobun = pkg.version;
} catch {
// not installed yet (postinstall ordering) — fine
}
const out = `// GENERATED by scripts/gen-version.ts — do not edit, do not commit.
export const BUILD = {
number: ${number},
commit: ${JSON.stringify(commit)},
date: ${JSON.stringify(date)},
electrobun: ${JSON.stringify(electrobun)},
} as const;
`;
const target = path.join(import.meta.dir, "..", "src", "shared", "version.gen.ts");
await Bun.write(target, out);
console.log(`[version] Build ${number} (${commit}) -> ${path.relative(process.cwd(), target)}`);
+34
View File
@@ -0,0 +1,34 @@
// PORTABILITY: WebKitGTK's DMABUF renderer cannot allocate GBM buffers on the
// NVIDIA proprietary driver — the app window paints BLACK and stderr fills with
// "Failed to create GBM buffer of size WxH: Invalid argument" (usually with an
// "X11 Error: GLXBadWindow" alongside). Disabling that renderer path is the
// standard upstream workaround.
//
// It has to be in the environment BEFORE the app process starts: WebKit reads
// it when it forks its web process, and Bun's `process.env` writes do NOT
// propagate to children (verified — a child sees the variable unset), so the
// app cannot set this for itself from inside src/bun/. Only the dev launch
// paths (`bun run dev` / `bun run start`) inject it; an installed build (the
// official Linux Setup installer) needs the user to set
// WEBKIT_DISABLE_DMABUF_RENDERER=1 themselves on NVIDIA.
//
// Scoped to NVIDIA so Intel/AMD keep hardware compositing, and an explicit
// value from the user's environment always wins.
import { existsSync } from "node:fs";
export function needsDmabufWorkaround(): boolean {
return (
process.platform === "linux" &&
process.env.WEBKIT_DISABLE_DMABUF_RENDERER === undefined &&
existsSync("/proc/driver/nvidia")
);
}
/** `process.env` plus the workaround, ready to hand to Bun.spawn. */
export function launchEnv(extra: Record<string, string> = {}): Record<string, string | undefined> {
return {
...process.env,
...(needsDmabufWorkaround() ? { WEBKIT_DISABLE_DMABUF_RENDERER: "1" } : {}),
...extra,
};
}
+113
View File
@@ -0,0 +1,113 @@
// Build one release artifact for one target, on the matching host:
// bun scripts/release.ts [--target mac-arm64|win-x64|linux-x64]
// Default target = the host. electrobun cannot cross-compile (Hutch builds for
// its host OS and arch only; packaging needs host tools: codesign/hdiutil on
// mac, rcedit/PowerShell on Windows), so win-x64 must run on a Windows machine.
// There is no mac-x64: Electrobun 2.x publishes no macOS x64 core at all.
// Artifacts land in release/<target>/miniSCP-Build<N>-<target><ext>.
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import path from "node:path";
const ROOT = path.join(import.meta.dir, "..");
const TARGETS = ["mac-arm64", "win-x64", "linux-x64"] as const;
type Target = (typeof TARGETS)[number];
function hostTarget(): Target {
if (process.platform === "win32") return "win-x64";
if (process.platform === "linux") return "linux-x64";
return "mac-arm64";
}
function fail(message: string): never {
console.error(`\n[release] ERROR: ${message}`);
process.exit(1);
}
function run(cmd: string[], opts: { cwd?: string } = {}): void {
console.log(`[release] $ ${cmd.join(" ")}`);
const proc = Bun.spawnSync(cmd, {
cwd: opts.cwd ?? ROOT,
stdout: "inherit",
stderr: "inherit",
});
if (!proc.success) fail(`command failed: ${cmd.join(" ")}`);
}
// ---- target selection + host guards ----
const argIndex = process.argv.indexOf("--target");
const arg = argIndex >= 0 ? process.argv[argIndex + 1] : hostTarget();
if (arg === "mac-x64") fail("mac-x64 was dropped — Electrobun 2.x publishes no macOS x64 core.");
if (!TARGETS.includes(arg as Target)) fail(`unknown target "${arg}" (${TARGETS.join(", ")})`);
const requested = arg as Target;
if (requested === "win-x64" && process.platform !== "win32") {
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 === "mac-arm64" && (process.platform !== "darwin" || process.arch !== "arm64")) {
fail("mac-arm64 must be built on an Apple Silicon Mac (Electrobun 2.x has no macOS x64 build).");
}
// ---- prepare: version, devkit, icons, UI bundle ----
run(["bun", "scripts/gen-version.ts"]);
run(["bunx", "electrobun", "prepare"]);
run(["bun", "scripts/gen-icons.ts"]);
run(["bun", "run", "--cwd", "ui", "build"]);
const { BUILD } = await import("../src/shared/version.gen");
const { BRANDING } = await import("../src/shared/branding");
const buildName = `Build${BUILD.number}`;
// ---- electrobun build ----
// Start from an empty artifacts/: site/deploy.sh publishes it verbatim (the
// updater contract), so an earlier build's files must not ride along.
const artifactsDir = path.join(ROOT, "artifacts");
rmSync(artifactsDir, { recursive: true, force: true });
run(["bunx", "electrobun", "build", "--env=stable"]);
// ---- collect artifacts ----
const [osName, archName] =
requested === "win-x64" ? ["win", "x64"] : requested === "linux-x64" ? ["linux", "x64"] : ["macos", "arm64"];
// 2.x installers carry no channel prefix (macos-arm64-miniSCP.dmg); only the
// update feed (stable-<os>-<arch>-*) keeps it, for v1-compatible clients.
const prefix = `${osName}-${archName}-`;
const outDir = path.join(ROOT, "release", requested);
mkdirSync(outDir, { recursive: true });
const produced = readdirSync(artifactsDir).filter(
(f) => f.startsWith(prefix) && f.includes(BRANDING.appName),
);
if (!produced.length) fail(`no ${prefix}* installer found in artifacts/ — did the build run?`);
const out: string[] = [];
for (const file of produced) {
const ext = [".dmg", "-Setup.zip", "-Setup.tar.gz"].find((e) => file.endsWith(e));
if (!ext) continue;
const name = `${BRANDING.appName}-${buildName}-${requested}${ext}`;
cpSync(path.join(artifactsDir, file), path.join(outDir, name));
out.push(name);
}
// Windows: also zip the assembled app folder — the portable build (unzip
// anywhere and run) next to the Setup installer. Best-effort: the folder
// layout is confirmed by the first real 2.x Windows build.
if (requested === "win-x64") {
const appDir = path.join(ROOT, "build", "stable-win-x64", BRANDING.appName);
if (existsSync(appDir)) {
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]);
out.push(path.basename(portable));
} else {
console.warn(`[release] WARN: portable skipped — app folder not found at ${appDir}`);
}
}
if (!out.length) fail("artifacts were found but none matched a known package extension.");
console.log(`\n[release] ${BRANDING.appName} ${BRANDING.buildLabel} (${BUILD.commit}) — ${requested}`);
for (const name of out) console.log(`[release] release/${requested}/${name}`);
+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 *.tar.gz
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
}
}
+71
View File
@@ -0,0 +1,71 @@
# 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 win-x64 / linux-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
> 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 (`macos-arm64-miniSCP.dmg`, `win-x64-miniSCP-Setup.zip`,
…) so it stays static and always points at the latest build. Electrobun 2.x names installers
without the `stable-` prefix; only the updater feed (`stable-<os>-<arch>-update.json`, `.tar.zst`,
`.patch`) keeps it. Installers left over under the old v1 `stable-…` names are stale — delete them.
- Builds are currently unsigned — the FAQ on the page covers the macOS `xattr` step and the
Windows SmartScreen prompt.
- `/dl/` is also the electrobun auto-updater's `release.baseUrl`.
+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
+40
View File
@@ -0,0 +1,40 @@
#!/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 win-x64 / linux-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, verbatim — the updater contract, never rename them:
# the update feed (stable-<os>-<arch>-update.json, .tar.zst, .patch) plus the
# installers, which 2.x names without the channel prefix (macos-arm64-miniSCP.dmg,
# win-x64-miniSCP-Setup.zip). release.ts empties artifacts/ first, so it holds
# exactly this build.
if compgen -G 'artifacts/*' >/dev/null; then
cp -f artifacts/* "$dl"/
else
echo "deploy: WARN — artifacts/ is empty (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"
+252
View File
@@ -0,0 +1,252 @@
<!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/macos-arm64-miniSCP.dmg">macOS<small>Apple Silicon</small></a>
<a class="dl" href="/dl/win-x64-miniSCP-Setup.zip">Windows<small>Installer</small></a>
<a class="dl" href="/dl/stable-win-x64-miniSCP-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 Windows 11+ (x64). macOS is the primary target;
Windows is currently in beta. Intel Macs are not supported.</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>
-91
View File
@@ -1,91 +0,0 @@
import { ContextMenu, Utils, type ApplicationMenuItemConfig } from "electrobun/bun";
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;
const ctx = this.context;
if (!action || !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));
}
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 in Finder", 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" });
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 in Finder", action: "ctx-reveal" });
}
items.push({ type: "separator" });
if (ctx.isLocal) {
items.push({
label: "Move to Trash",
action: "ctx-trash",
tooltip: "Finder's Restore is not available for items trashed from here",
});
}
items.push({ label: "Delete", action: "ctx-delete" });
return items;
}
}
+189
View File
@@ -0,0 +1,189 @@
import { Utils, type BrowserWindow } from "electrobun/main";
import { BRANDING } from "../shared/branding";
import type { EditorOpenContext } from "../shared/rpc";
import type { AppSettings } from "../shared/types";
import { writeFileText, type EditManager } from "./edits";
import type { SessionRegistry } from "./fs/registry";
import { createEditorRPC } from "./rpc-handlers";
import { openSecondaryWindow, WindowRegistry } from "./windows";
import type { SettingsStore } from "./store/settings";
const EDITOR_WIDTH = 900;
const EDITOR_HEIGHT = 680;
interface EditorRecord {
win: BrowserWindow;
ctx: EditorOpenContext;
dirty: boolean;
/** Last mirrored content — what the native-close Save fallback writes. */
draft: string | null;
/** Set by editorClose: the webview already resolved its dirty prompt. */
suppressPrompt: boolean;
rpc: ReturnType<typeof createEditorRPC>;
}
export interface EditorWindowsDeps {
registry: SessionRegistry;
settings: SettingsStore;
edits: EditManager;
/** The once-resolved dev/prod view url. */
url: string;
/** Live settings snapshot — baked into new windows' preloads (theme flash). */
currentSettings: () => AppSettings;
mainFrame: () => { x: number; y: number };
/** Late-bound senders targeting the MAIN window's webview. */
notifyMain: { pathRefresh: (info: { sessionId: string; dir: string }) => void };
/** An editor saved settings; `key` identifies it so the rebroadcast skips it. */
onSettingsSaved: (settings: AppSettings, key: string) => void;
}
/**
* One native window per edited file (key: sessionId+path). The webview owns
* the styled dirty prompt for closes it can see (⌘W, Close button); the
* native traffic-light close is NOT cancellable in electrobun, so the draft
* mirrored via editorState lets us offer a native Save/Discard box after the
* window is already gone. A failed fallback save parks the draft in the
* edits leftovers dir (offered back at next boot).
*/
export class EditorWindows {
private windows: WindowRegistry<EditorRecord>;
constructor(private deps: EditorWindowsDeps) {
this.windows = new WindowRegistry(deps.mainFrame);
}
open(ctx: EditorOpenContext): void {
const key = `${ctx.sessionId}\0${ctx.path}`;
if (this.windows.activateExisting(key)) return;
const record: Partial<EditorRecord> = { ctx, dirty: false, draft: null, suppressPrompt: false };
const rpc = createEditorRPC(
{ registry: this.deps.registry, settings: this.deps.settings },
{
ctx,
onDraft: (state) => {
record.dirty = state.dirty;
record.draft = state.content;
this.setDirtyTitle(record as EditorRecord);
},
onSaved: () => {
record.dirty = false;
record.draft = null;
this.setDirtyTitle(record as EditorRecord);
this.deps.notifyMain.pathRefresh({
sessionId: ctx.sessionId,
dir: this.dirOf(ctx),
});
},
onCloseConfirmed: () => {
record.suppressPrompt = true;
record.win?.close();
},
onSettingsSaved: (next) => this.deps.onSettingsSaved(next, key),
},
);
const win = openSecondaryWindow({
title: this.baseTitle(ctx),
url: this.deps.url,
mode: "editor",
settings: this.deps.currentSettings(),
ctx,
frame: this.windows.nextFrame(EDITOR_WIDTH, EDITOR_HEIGHT),
rpc,
});
record.win = win;
record.rpc = rpc;
this.windows.set(key, record as EditorRecord);
this.windows.track(key, win, (k) => void this.handleNativeClose(k));
console.log(`[editor] opened window for ${ctx.sessionId}:${ctx.path}`);
}
/** The window is already gone; decide the draft's fate from what we hold. */
private async handleNativeClose(key: string): Promise<void> {
const record = this.windows.get(key);
this.windows.delete(key);
if (!record || !record.dirty || record.suppressPrompt || record.draft === null) return;
const { ctx, draft } = record;
const { response } = await Utils.showMessageBox({
type: "warning",
title: BRANDING.appName,
message: `Save changes to "${ctx.name}"?`,
detail: "The editor window was closed with unsaved changes.",
buttons: ["Save", "Discard"],
defaultId: 0,
cancelId: 1,
});
if (response !== 0) return;
try {
await writeFileText(this.deps.registry.get(ctx.sessionId), ctx.path, draft);
this.deps.notifyMain.pathRefresh({ sessionId: ctx.sessionId, dir: this.dirOf(ctx) });
} catch (error) {
await this.deps.edits.rescueDraft(ctx.name, draft);
await Utils.showMessageBox({
type: "error",
title: BRANDING.appName,
message: `Couldn't save "${ctx.name}".`,
detail: `${error instanceof Error ? error.message : error}\n\nA local copy was kept and will be offered at the next launch.`,
buttons: ["OK"],
defaultId: 0,
cancelId: 0,
});
}
}
dirtyCount(): number {
let count = 0;
this.windows.forEach((rec) => {
if (rec.dirty) count++;
});
return count;
}
anyFocused(): boolean {
return this.windows.anyFocused();
}
focusedKey(): string | null {
return this.windows.focusedKey();
}
/** Ask a window to run its styled close flow (⌘W routing, main close). */
requestClose(key: string): void {
this.windows.get(key)?.rpc.send.editorCloseRequest({});
}
/** Main window closed: clean editors just close, dirty ones get to ask. */
closeAllForAppExit(): void {
this.windows.forEach((rec, key) => {
if (rec.dirty) this.requestClose(key);
else rec.win.close();
});
}
notifySessionClosed(info: { sessionId: string; reason: string }): void {
this.windows.forEach((rec) => {
if (rec.ctx.sessionId === info.sessionId) rec.rpc.send.sessionClosed(info);
});
}
/** `exceptKey`: the editor that saved — it already shows the new values. */
broadcastSettings(settings: AppSettings, exceptKey?: string): void {
this.windows.forEach((rec, key) => {
if (key !== exceptKey) rec.rpc.send.settingsChanged({ settings });
});
}
private baseTitle(ctx: EditorOpenContext): string {
return `${ctx.name} — ${ctx.sessionTitle}`;
}
private setDirtyTitle(record: EditorRecord): void {
record.win.setTitle(`${record.dirty ? "• " : ""}${this.baseTitle(record.ctx)}`);
}
private dirOf(ctx: EditorOpenContext): string {
return this.deps.registry.get(ctx.sessionId).dirname(ctx.path);
}
}
+32 -6
View File
@@ -48,14 +48,25 @@ export function writeFileText(
* Launch a file in an external editor. * Launch a file in an external editor.
* *
* PORTABILITY: this is the only platform-specific piece of the edit feature. * PORTABILITY: this is the only platform-specific piece of the edit feature.
* macOS delivers the file to any GUI app uniformly via `open [-a app]`. * macOS delivers the file to any GUI app via `open [-a app]`; Windows via
* A Windows port should use `cmd /c start "" [app] file` and a Linux port * `cmd /c start` (empty "" arg = window title slot); Linux via the app
* `xdg-open file` (or `app file` when an editor is configured) — swap the * directly or `xdg-open`. Everything else (temp download, directory watch,
* spawn below and the .app picker in pickEditorApp; everything else * auto-upload) is platform-neutral. Windows path pending first smoke test.
* (temp download, directory watch, auto-upload) is platform-neutral.
*/ */
function launchEditor(filePath: string, editorApp: string): void { function launchEditor(filePath: string, editorApp: string): void {
const args = editorApp ? ["open", "-a", editorApp, filePath] : ["open", filePath]; let args: string[];
switch (process.platform) {
case "darwin":
args = editorApp ? ["open", "-a", editorApp, filePath] : ["open", filePath];
break;
case "win32":
args = editorApp
? ["cmd", "/c", "start", "", editorApp, filePath]
: ["cmd", "/c", "start", "", filePath];
break;
default:
args = editorApp ? [editorApp, filePath] : ["xdg-open", filePath];
}
Bun.spawn(args, { stdout: "ignore", stderr: "ignore" }); Bun.spawn(args, { stdout: "ignore", stderr: "ignore" });
} }
@@ -134,6 +145,21 @@ export class EditManager {
}); });
} }
/**
* Park unsaveable editor content (window closed, session dead) in the
* leftovers dir so the next-boot prompt offers it back to the user.
*/
async rescueDraft(name: string, content: string): Promise<void> {
const dir = path.join(this.editsDir, crypto.randomUUID().slice(0, 8));
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, "meta.json"),
JSON.stringify({ name, rescuedAt: new Date().toISOString() }),
);
await fs.writeFile(path.join(dir, name), content, "utf8");
console.log(`[edit] rescued unsaved draft of ${name} -> ${dir}`);
}
/** Session gone (disconnect, tab close, death): stop its watchers, drop temps. */ /** Session gone (disconnect, tab close, death): stop its watchers, drop temps. */
stopForSession(sessionId: string): void { stopForSession(sessionId: string): void {
const [mine, rest] = [ const [mine, rest] = [
+54
View File
@@ -11,6 +11,58 @@ function typeOf(st: Stats): EntryType {
return st.isDirectory() ? "dir" : "file"; return st.isDirectory() ? "dir" : "file";
} }
/**
* uid/gid -> names, cached per process (a listing usually has 1-3 owners).
*
* PORTABILITY: POSIX-only lookups (`id -nu` works on macOS and Linux; group
* names use dscacheutil on macOS, getent elsewhere). On Windows these return
* undefined and the Owner/Group columns show "—".
*/
const idNameCache = new Map<string, string | undefined>();
function resolveId(kind: "user" | "group", id: number): string | undefined {
if (process.platform === "win32") return undefined;
const cacheKey = `${kind}:${id}`;
if (idNameCache.has(cacheKey)) return idNameCache.get(cacheKey);
let name: string | undefined;
try {
const cmd =
kind === "user"
? ["id", "-nu", String(id)]
: process.platform === "darwin"
? ["dscacheutil", "-q", "group", "-a", "gid", String(id)]
: ["getent", "group", String(id)];
const proc = Bun.spawnSync(cmd, { stdout: "pipe", stderr: "ignore" });
if (proc.success) {
const out = proc.stdout.toString();
if (kind === "user") name = out.trim() || undefined;
else if (process.platform === "darwin") name = /^name:\s*(.+)$/m.exec(out)?.[1]?.trim();
else name = out.split(":")[0]?.trim() || undefined;
}
} catch {
// lookup tool missing — fall through to the numeric id
}
name ??= String(id);
idNameCache.set(cacheKey, name);
return name;
}
/**
* Drive roots for the Windows panel source picker ("C:\", "D:\", …).
* PORTABILITY: win32-only concept; empty elsewhere ("/" is always reachable).
*/
export function listLocalDrives(): string[] {
if (process.platform !== "win32") return [];
try {
const proc = Bun.spawnSync(["fsutil", "fsinfo", "drives"], { stdout: "pipe", stderr: "ignore" });
if (!proc.success) return [];
// output: "Drives: C:\ D:\ ..."
return [...proc.stdout.toString().matchAll(/[A-Za-z]:\\/g)].map((m) => m[0]);
} catch {
return [];
}
}
function toEntry(name: string, full: string, st: Stats): FileEntry { function toEntry(name: string, full: string, st: Stats): FileEntry {
return { return {
name, name,
@@ -19,6 +71,8 @@ function toEntry(name: string, full: string, st: Stats): FileEntry {
size: st.size, size: st.size,
mtime: st.mtimeMs, mtime: st.mtimeMs,
mode: st.mode & 0o7777, mode: st.mode & 0o7777,
owner: resolveId("user", st.uid),
group: resolveId("group", st.gid),
}; };
} }
+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);
+16 -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";
@@ -10,6 +11,18 @@ const S_IFMT = 0o170000;
const S_IFDIR = 0o040000; const S_IFDIR = 0o040000;
const S_IFLNK = 0o120000; const S_IFLNK = 0o120000;
/**
* Owner/group from the ls-style longname openssh sends with each entry
* ("-rw-r--r-- 1 root wheel 1024 Jan 1 00:00 name"). Servers aren't
* required to use this format — bail to undefined when it doesn't parse.
*/
function ownerFromLongname(longname: string | undefined): { owner?: string; group?: string } {
if (!longname || !/^[-dlbcps]/.test(longname)) return {};
const fields = longname.split(/\s+/);
if (fields.length < 5) return {};
return { owner: fields[2], group: fields[3] };
}
function typeFromMode(mode: number): EntryType { function typeFromMode(mode: number): EntryType {
const fmt = mode & S_IFMT; const fmt = mode & S_IFMT;
if (fmt === S_IFDIR) return "dir"; if (fmt === S_IFDIR) return "dir";
@@ -51,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": {
@@ -61,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;
@@ -132,6 +145,7 @@ export class SftpProvider implements FileSystemProvider {
size: e.attrs.size, size: e.attrs.size,
mtime: e.attrs.mtime * 1000, // sftp attrs are seconds mtime: e.attrs.mtime * 1000, // sftp attrs are seconds
mode: e.attrs.mode & 0o7777, mode: e.attrs.mode & 0o7777,
...ownerFromLongname(e.longname),
})), })),
); );
}); });
+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}}`,
);
}
+226 -28
View File
@@ -1,16 +1,22 @@
import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "electrobun/bun"; import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "electrobun/main";
import { BRANDING } from "../shared/branding"; import { BRANDING } from "../shared/branding";
import { effectiveConcurrency } from "../shared/types"; import type { EditorOpenContext } from "../shared/rpc";
import { PanelContextMenu } from "./context-menu"; import { effectiveConcurrency, type AppSettings } from "../shared/types";
import { EditManager } from "./edits"; import { EditManager } from "./edits";
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 { setLocale as setBackendLocale } from "./i18n";
import { applyApplicationMenu } from "./menu"; import { applyApplicationMenu } from "./menu";
import { createAppRPC } from "./rpc-handlers"; import { createAppRPC } from "./rpc-handlers";
import { SettingsWindow } from "./settings-window";
import { ConnectionStore } from "./store/connections"; import { ConnectionStore } from "./store/connections";
import { migrateLegacyDataDir } from "./store/paths"; import { migrateLegacyDataDir } from "./store/paths";
import { SettingsStore } from "./store/settings"; import { SettingsStore } from "./store/settings";
import { AppTray } from "./tray"; import { AppTray } from "./tray";
import { TransferEngine } from "./transfers/engine"; import { TransferEngine } from "./transfers/engine";
import { buildPreload, chromeOptions, showWhenReady } from "./windows";
const PROD_URL = "views://app/index.html"; const PROD_URL = "views://app/index.html";
@@ -40,15 +46,27 @@ 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: () => {},
openEditor: (_ctx: EditorOpenContext) => {},
settingsChanged: (_settings: AppSettings) => {},
openConnectionManager: () => {},
openSettings: () => {},
showAbout: () => {},
applyUpdate: async () => {},
}; };
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events }); const rpc = createAppRPC({ registry, engine, store, settings, edits, watcher, 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();
const isMac = process.platform === "darwin"; // 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
// fresh via the saveSettings flow below.
let currentSettings = await settings.load();
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
const win = new BrowserWindow({ const win = new BrowserWindow({
title: BRANDING.appName, title: BRANDING.appName,
@@ -59,23 +77,110 @@ const win = new BrowserWindow({
x: 120, x: 120,
y: 120, y: 120,
}, },
// macOS: fuse the tab bar with the titlebar — traffic lights overlay the // frame decision lives in chromeOptions (windows.ts); constructor-only
// UI's h-10 tab row (offset centers them vertically). Other platforms keep ...chromeOptions(),
// the native titlebar and the tab bar stays a separate row. // lets the UI pick the fused layout + theme before first paint
...(isMac preload: buildPreload("main", currentSettings),
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
: {}),
// lets the UI pick the fused layout before first paint
preload: `window.__APP_PLATFORM=${JSON.stringify(process.platform)};`,
rpc, rpc,
// revealed on first dom-ready — avoids the unthemed native flash
hidden: true,
});
showWhenReady(win);
const editors = new EditorWindows({
registry,
settings,
edits,
url,
currentSettings: () => currentSettings,
mainFrame: () => win.getFrame(),
notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) },
onSettingsSaved: (s, key) => settingsSaved(s, { editor: key }),
});
const manager = new ManagerWindow({
store,
settings,
url,
currentSettings: () => currentSettings,
mainFrame: () => win.getFrame(),
notifyMain: {
managerState: (info) => rpc.send.managerState(info),
connectionsChanged: () => rpc.send.connectionsChanged({}),
menuAction: (info) => rpc.send.menuAction(info),
},
rebuildMenu: () => void applyApplicationMenu(store, () => currentSettings.keybindings),
onSettingsSaved: (s) => settingsSaved(s, "manager"),
});
const settingsWin = new SettingsWindow({
settings,
url,
currentSettings: () => currentSettings,
mainFrame: () => win.getFrame(),
onSettingsSaved: (s) => settingsSaved(s, "settings"),
showAbout: () => showAbout(),
});
events.openEditor = (ctx) => editors.open(ctx);
/**
* Every settings save lands here, whichever window made it (editor and manager
* windows save too — the scale shortcuts — and the Settings window is nothing
* but saves): side effects run once, then every OTHER window follows. The
* sender is skipped — it already shows the new values, and an echo arriving
* behind a newer local change (a held Cmd+) would drag it backwards.
*/
function settingsSaved(
s: AppSettings,
sender: "main" | "manager" | "settings" | { editor: string },
): void {
engine.setConcurrency(effectiveConcurrency(s));
const localeChanged = s.locale !== currentSettings.locale;
const keysChanged = JSON.stringify(s.keybindings) !== JSON.stringify(currentSettings.keybindings);
currentSettings = s; // future windows' preloads bake the fresh theme
if (localeChanged) {
setBackendLocale(s.locale);
settingsWin.retitle();
}
// relabel the native menu in the new language / give it the remapped keys
if (localeChanged || keysChanged) void applyApplicationMenu(store, () => currentSettings.keybindings);
if (sender !== "main") rpc.send.settingsChanged({ settings: s });
if (sender !== "manager") manager.broadcastSettings(s);
if (sender !== "settings") settingsWin.broadcastSettings(s);
editors.broadcastSettings(s, typeof sender === "object" ? sender.editor : undefined);
}
events.settingsChanged = (s) => settingsSaved(s, "main");
events.openConnectionManager = () => manager.open();
events.openSettings = () => settingsWin.open();
function showAbout(): void {
const { build } = BRANDING;
void Utils.showMessageBox({
type: "info",
title: BRANDING.appName,
message: BRANDING.appName,
detail: `${BRANDING.buildLabel} (${build.commit}) · ${build.date}\n\nElectrobun ${build.electrobun} · Bun ${Bun.version}`,
buttons: ["OK"],
defaultId: 0,
cancelId: 0,
});
}
events.showAbout = showAbout;
// clean editors close with the main window; dirty ones get to ask first,
// and the modal manager has nothing to guard
win.on("close", () => {
manager.close();
settingsWin.close();
editors.closeAllForAppExit();
}); });
new AppTray(win); new AppTray(win);
contextMenu.onAction = (action) => rpc.send.menuAction({ action });
// notify on finished transfers while the window is in the background // notify on finished transfers while the window is in the background
let windowFocused = true; let windowFocused = true;
win.on("focus", () => (windowFocused = true)); win.on("focus", () => {
windowFocused = true;
// modal bounce: the main window can't take focus while the manager is open
if (manager.isOpen()) manager.activate();
});
win.on("blur", () => (windowFocused = false)); win.on("blur", () => (windowFocused = false));
const notifiedStates = new Map<string, string>(); const notifiedStates = new Map<string, string>();
@@ -85,7 +190,12 @@ engine.onUpdate = (snapshot) => {
const previous = notifiedStates.get(snapshot.id); const previous = notifiedStates.get(snapshot.id);
if (previous !== snapshot.state) { if (previous !== snapshot.state) {
notifiedStates.set(snapshot.id, snapshot.state); notifiedStates.set(snapshot.id, snapshot.state);
if (!windowFocused && (snapshot.state === "completed" || snapshot.state === "failed")) { if (
!windowFocused &&
!editors.anyFocused() &&
!settingsWin.isFocused() &&
(snapshot.state === "completed" || snapshot.state === "failed")
) {
Utils.showNotification({ Utils.showNotification({
title: snapshot.state === "completed" ? "Transfer complete" : "Transfer failed", title: snapshot.state === "completed" ? "Transfer complete" : "Transfer failed",
body: body:
@@ -102,36 +212,124 @@ engine.onUpdate = (snapshot) => {
} }
}; };
// don't quit out from under running transfers // don't quit out from under running transfers or unsaved editors
let quitConfirmed = false; let quitConfirmed = false;
// Updater.applyUpdate() asks this guard first (2.x), so remember the quit is
// update-initiated: "Quit Anyway" must re-run the apply, or a plain quit would
// drop the downloaded update.
let applyingUpdate = false;
events.applyUpdate = async () => {
applyingUpdate = true;
try {
await Updater.applyUpdate();
} finally {
applyingUpdate = false;
}
};
Electrobun.events.on("before-quit", (event) => { Electrobun.events.on("before-quit", (event) => {
if (quitConfirmed || !engine.hasActiveJobs()) return; const transfers = engine.activeJobCount();
const dirtyEditors = editors.dirtyCount();
if (quitConfirmed || (transfers === 0 && dirtyEditors === 0)) return;
(event as { response: { allow: boolean } }).response = { allow: false }; (event as { response: { allow: boolean } }).response = { allow: false };
const updating = applyingUpdate; // read now: it clears once the vetoed apply returns
void (async () => { void (async () => {
const count = engine.activeJobCount(); const parts = [
transfers > 0 && `${transfers} transfer${transfers === 1 ? " is" : "s are"} still running`,
dirtyEditors > 0 &&
`${dirtyEditors} file${dirtyEditors === 1 ? " has" : "s have"} unsaved changes`,
].filter(Boolean);
const { response } = await Utils.showMessageBox({ const { response } = await Utils.showMessageBox({
type: "warning", type: "warning",
title: BRANDING.appName, title: BRANDING.appName,
message: `${count} transfer${count === 1 ? " is" : "s are"} still running.`, message: `${parts.join(" and ")}.`,
detail: "Quitting now will abort them.", detail: "Quitting now will abort transfers and discard unsaved changes.",
buttons: ["Quit Anyway", "Cancel"], buttons: ["Quit Anyway", "Cancel"],
defaultId: 1, defaultId: 1,
cancelId: 1, cancelId: 1,
}); });
if (response === 0) { if (response === 0) {
quitConfirmed = true; quitConfirmed = true;
Utils.quit(); // the trailing quit is a no-op once the apply is quitting, and still
// quits if it bails (no prepared download)
if (updating) void Updater.applyUpdate().finally(() => Utils.quit());
else Utils.quit();
} }
})(); })();
}); });
events.sessionClosed = (info) => rpc.send.sessionClosed(info); events.sessionClosed = (info) => {
events.connectionsChanged = () => void applyApplicationMenu(store); rpc.send.sessionClosed(info);
void settings.load().then((s) => engine.setConcurrency(effectiveConcurrency(s))); editors.notifySessionClosed(info);
};
events.connectionsChanged = () => void applyApplicationMenu(store, () => currentSettings.keybindings);
engine.setConcurrency(effectiveConcurrency(currentSettings));
void applyApplicationMenu(store); void applyApplicationMenu(store, () => currentSettings.keybindings);
ApplicationMenu.on("application-menu-clicked", (event) => { ApplicationMenu.on("application-menu-clicked", (event) => {
const action = (event as { data?: { action?: string } }).data?.action; const action = (event as { data?: { action?: string } }).data?.action;
if (action) rpc.send.menuAction({ action }); if (!action) return;
// Their pages claim close-window (⌘W) themselves — macOS hands key
// equivalents to the page first — so a close-tab that still arrives while
// the Settings window or an editor is focused is a ⌘W the page let through
// (or the menu item clicked): close THAT window (an editor with its dirty
// prompt), never a main-window tab behind it. Other menu actions
// deliberately still target the main window.
if (action === "close-tab") {
if (settingsWin.isFocused()) {
settingsWin.close();
return;
}
const focusedEditor = editors.focusedKey();
if (focusedEditor) {
editors.requestClose(focusedEditor);
return;
}
}
if (action === "manage-connections") {
manager.open();
return;
}
if (action === "settings") {
settingsWin.open();
return;
}
if (action === "about") {
showAbout();
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
if (manager.isOpen()) return;
// everything below acts on the main window: from a focused editor or the
// Settings window (⌘T, ⌘N…), bring it forward so the effect is visible
if (settingsWin.isFocused() || editors.focusedKey()) win.activate();
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})`);
+109
View File
@@ -0,0 +1,109 @@
import type { BrowserWindow } from "electrobun/main";
import type { AppSettings } from "../shared/types";
import { createManagerRPC } from "./rpc-handlers";
import type { ConnectionStore } from "./store/connections";
import type { SettingsStore } from "./store/settings";
import { openSecondaryWindow } from "./windows";
const MANAGER_WIDTH = 760;
const MANAGER_HEIGHT = 520;
export interface ManagerWindowDeps {
store: ConnectionStore;
settings: SettingsStore;
/** The once-resolved dev/prod view url. */
url: string;
/** Live settings snapshot — baked into the window's preload (theme flash). */
currentSettings: () => AppSettings;
mainFrame: () => { x: number; y: number; width: number; height: number };
/** Late-bound senders targeting the MAIN window's webview. */
notifyMain: {
managerState: (info: { open: boolean }) => void;
connectionsChanged: () => void;
menuAction: (info: { action: string }) => void;
};
/** Rebuilds the native app menu after store changes. */
rebuildMenu: () => void;
/** The manager saved settings — rebroadcast them to every other window. */
onSettingsSaved: (settings: AppSettings) => void;
}
/**
* The single connection-manager window, modal to the main window. electrobun
* has no native modality, so it's approximated from two sides: index.ts
* bounces the main window's focus back here while open, and the main webview
* shows a click-swallowing overlay (driven by the managerState message).
* Editor windows are deliberately unaffected.
*/
export class ManagerWindow {
private win: BrowserWindow | null = null;
private rpc: ReturnType<typeof createManagerRPC> | null = null;
constructor(private deps: ManagerWindowDeps) {}
isOpen(): boolean {
return this.win !== null;
}
activate(): void {
this.win?.activate();
}
open(): void {
if (this.win) {
this.win.activate();
return;
}
this.rpc = createManagerRPC(
{ store: this.deps.store, settings: this.deps.settings },
{
onChanged: () => {
this.deps.rebuildMenu();
this.deps.notifyMain.connectionsChanged();
},
// connect/open-stack run in the main window; the manager's job is done
onConnect: (id) => {
this.deps.notifyMain.menuAction({ action: `connect:${id}` });
this.close();
},
onOpenStack: (id) => {
this.deps.notifyMain.menuAction({ action: `open-stack:${id}` });
this.close();
},
onClose: () => this.close(),
onSettingsSaved: (next) => this.deps.onSettingsSaved(next),
},
);
const main = this.deps.mainFrame();
this.win = openSecondaryWindow({
title: "Connections",
url: this.deps.url,
mode: "manager",
settings: this.deps.currentSettings(),
frame: {
x: Math.max(0, main.x + (main.width - MANAGER_WIDTH) / 2),
y: Math.max(0, main.y + (main.height - MANAGER_HEIGHT) / 2),
width: MANAGER_WIDTH,
height: MANAGER_HEIGHT,
},
rpc: this.rpc,
});
this.win.on("close", () => {
this.win = null;
this.rpc = null;
this.deps.notifyMain.managerState({ open: false });
});
this.deps.notifyMain.managerState({ open: true });
console.log("[manager] window opened");
}
close(): void {
this.win?.close(); // close event does the cleanup + notification
}
broadcastSettings(settings: AppSettings): void {
this.rpc?.send.settingsChanged({ settings });
}
}
+50 -17
View File
@@ -1,17 +1,37 @@
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/bun"; import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/main";
import { BRANDING } from "../shared/branding";
import { effectiveKeymap, type ShortcutId, toAccelerator } from "../shared/keymap";
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";
/** /**
* macOS application menu (electrobun menus are macOS-only). Standard roles plus * macOS application menu (electrobun menus are macOS-only). Standard roles plus
* app actions; custom actions are forwarded to the webview as menuAction * app actions; custom actions are forwarded to the webview as menuAction
* messages. Rebuilt whenever saved connections change so the Connect submenu * messages. Rebuilt whenever saved connections, the language or the keyboard
* stays current. * remaps change.
*
* Accelerators come from the shared keymap, so the menu bar shows — and
* fires — the user's keys. The page sees ⌘-keys first and claims the ones it
* handles; the menu only runs what falls through (see shortcuts.service.ts).
* ⌘Q stays fixed: quitting must always work.
*/ */
export async function applyApplicationMenu(store: ConnectionStore): Promise<void> { export async function applyApplicationMenu(
store: ConnectionStore,
keybindings: () => Record<string, string[]>,
): Promise<void> {
if (process.platform !== "darwin") return; if (process.platform !== "darwin") return;
const { stacks, connections } = await store.load(); const { stacks, connections } = await store.load();
// read after the await: a remap saved meanwhile must not be lost
const keymap = effectiveKeymap(keybindings(), true);
const accelerator = (id: ShortcutId): string | undefined => {
for (const combo of keymap.find((b) => b.id === id)?.combos ?? []) {
const native = toAccelerator(combo);
if (native) return native;
}
return undefined;
};
const item = (c: SavedConnection): ApplicationMenuItemConfig => ({ const item = (c: SavedConnection): ApplicationMenuItemConfig => ({
label: c.name, label: c.name,
action: `connect:${c.id}`, action: `connect:${c.id}`,
@@ -26,35 +46,48 @@ 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",
accelerator: accelerator("connection-manager"),
},
]; ];
ApplicationMenu.setApplicationMenu([ ApplicationMenu.setApplicationMenu([
{ {
submenu: [ submenu: [
{ label: t("menu.about", { app: BRANDING.appName }), action: "about" },
{ type: "separator" },
{ label: t("menu.settings"), action: "settings", accelerator: accelerator("open-settings") },
{ type: "separator" },
{ label: t("menu.checkForUpdates"), action: "check-for-updates" },
{ 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: accelerator("new-tab") },
{ label: "Close Tab", action: "close-tab", accelerator: "w" }, { label: t("menu.closeTab"), action: "close-tab", accelerator: accelerator("close-tab") },
{ type: "separator" }, { type: "separator" },
{ label: "New Folder", action: "new-folder" }, { label: t("menu.newFolder"), action: "new-folder", accelerator: accelerator("new-folder") },
{ label: "New File", action: "new-file" }, { label: t("menu.newFile"), action: "new-file", accelerator: accelerator("new-file") },
{ type: "separator" }, { type: "separator" },
{ label: "Edit File", action: "edit-file", accelerator: "e" }, { label: t("menu.editFile"), action: "edit-file", accelerator: accelerator("edit") },
{ 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" },
@@ -68,15 +101,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: accelerator("refresh") },
{ type: "separator" }, { type: "separator" },
{ role: "toggleFullScreen" }, { role: "toggleFullScreen" },
], ],
}, },
{ {
label: "Window", label: t("menu.window"),
submenu: [ submenu: [
{ role: "minimize" }, { role: "minimize" },
{ role: "zoom" }, { role: "zoom" },
+319 -90
View File
@@ -1,9 +1,10 @@
import { BrowserView, Utils } from "electrobun/bun"; import { BrowserView, Updater, Utils } from "electrobun/main";
import type { AppRPC } from "../shared/rpc"; import type { AppRPC, EditorOpenContext } from "../shared/rpc";
import { effectiveConcurrency, type Result } from "../shared/types"; import type { AppSettings, Result } from "../shared/types";
import type { PanelContextMenu } from "./context-menu";
import { readFileText, writeFileText, type EditManager } from "./edits"; import { readFileText, writeFileText, type EditManager } from "./edits";
import { listLocalDrives } from "./fs/local";
import 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";
@@ -22,11 +23,18 @@ export interface AppDeps {
store: ConnectionStore; store: ConnectionStore;
settings: SettingsStore; settings: SettingsStore;
edits: EditManager; edits: EditManager;
contextMenu: PanelContextMenu; watcher: LocalDirWatcher;
/** 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;
connectionsChanged: () => void; connectionsChanged: () => void;
openEditor: (ctx: EditorOpenContext) => void;
settingsChanged: (settings: AppSettings) => void;
openConnectionManager: () => void;
openSettings: () => void;
showAbout: () => void;
/** "Restart to apply" — lives next to the before-quit guard it must get past. */
applyUpdate: () => Promise<void>;
}; };
} }
@@ -38,13 +46,122 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
} }
} }
/** Connection-store requests, shared by the main and manager window rpcs. */
function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) {
return {
getConnections: () => wrap(async () => ({ store: await store.load() })),
upsertConnection: ({ connection }: { connection: Parameters<ConnectionStore["upsertConnection"]>[0] }) =>
wrap(async () => {
const id = await store.upsertConnection(connection);
onChanged();
return { id };
}),
deleteConnection: ({ id }: { id: string }) =>
wrap(async () => {
await store.deleteConnection(id);
onChanged();
return {};
}),
upsertStack: ({ stack }: { stack: Parameters<ConnectionStore["upsertStack"]>[0] }) =>
wrap(async () => {
const id = await store.upsertStack(stack);
onChanged();
return { id };
}),
deleteStack: ({ id }: { id: string }) =>
wrap(async () => {
await store.deleteStack(id);
onChanged();
return {};
}),
importWinScp: () =>
wrap(async () => {
const { homedir } = await import("node:os");
const picked = await Utils.openFileDialog({
startingFolder: homedir(),
allowedFileTypes: "*",
canChooseFiles: true,
canChooseDirectory: false,
allowsMultipleSelection: false,
});
const file = picked.find((p) => p.trim().length > 0);
// dialog cancelled — all-zero result, UI shows nothing
if (!file) return { connections: 0, stacks: 0, skipped: 0 };
const text = await (await import("node:fs/promises")).readFile(file, "utf8");
const result = await importWinScpIni(text, store);
onChanged();
return result;
}),
getSshConfigHosts: () => wrap(async () => ({ hosts: loadSshConfigHosts() })),
};
}
/**
* saveSettings for every window kind: persist, then hand the new object to the
* composition root (index.ts), which applies the side effects once and
* rebroadcasts to every OTHER window. Known ceiling: whole-object writes — two
* windows saving in the same instant, the later one wins. Upgrade path: send a
* patch and merge it bun-side.
*/
const saveSettingsHandler =
(store: SettingsStore, onSaved: (next: AppSettings) => void) =>
({ settings: next }: { settings: AppSettings }) =>
wrap(async () => {
await store.save(next);
onSaved(next);
return {};
});
const pickKeyFileHandler = () =>
wrap(async () => {
const { homedir } = await import("node:os");
const { join } = await import("node:path");
const picked = await Utils.openFileDialog({
startingFolder: join(homedir(), ".ssh"),
allowedFileTypes: "*",
canChooseFiles: true,
canChooseDirectory: false,
allowsMultipleSelection: false,
});
const path = picked.find((p) => p.trim().length > 0) ?? null;
return { path };
});
/** External-editor picker, shared by the main and settings window rpcs. */
const pickEditorAppHandler = () =>
wrap(async () => {
// PORTABILITY: apps live in different places per OS
const { homedir } = await import("node:os");
const [startingFolder, allowedFileTypes] =
process.platform === "darwin"
? ["/Applications", "app"]
: process.platform === "win32"
? [process.env.ProgramFiles ?? "C:\\Program Files", "exe"]
: [homedir(), "*"];
const picked = await Utils.openFileDialog({
startingFolder,
allowedFileTypes,
canChooseFiles: true,
canChooseDirectory: false,
allowsMultipleSelection: false,
});
const path = picked.find((p) => p.trim().length > 0) ?? null;
return { path };
});
export function createAppRPC({ export function createAppRPC({
registry, registry,
engine, engine,
store, store,
settings, settings,
edits, edits,
contextMenu, watcher,
events, events,
}: AppDeps) { }: AppDeps) {
return BrowserView.defineRPC<AppRPC>({ return BrowserView.defineRPC<AppRPC>({
@@ -84,6 +201,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);
@@ -137,66 +260,12 @@ export function createAppRPC({
}), }),
// ---- connection store ---- // ---- connection store ----
getConnections: () => wrap(async () => ({ store: await store.load() })), ...connectionStoreHandlers(store, () => events.connectionsChanged()),
upsertConnection: ({ connection }) =>
wrap(async () => {
const id = await store.upsertConnection(connection);
events.connectionsChanged();
return { id };
}),
deleteConnection: ({ id }) =>
wrap(async () => {
await store.deleteConnection(id);
events.connectionsChanged();
return {};
}),
upsertStack: ({ stack }) =>
wrap(async () => {
const id = await store.upsertStack(stack);
events.connectionsChanged();
return { id };
}),
deleteStack: ({ id }) =>
wrap(async () => {
await store.deleteStack(id);
events.connectionsChanged();
return {};
}),
importWinScp: () =>
wrap(async () => {
const { homedir } = await import("node:os");
const picked = await Utils.openFileDialog({
startingFolder: homedir(),
allowedFileTypes: "*",
canChooseFiles: true,
canChooseDirectory: false,
allowsMultipleSelection: false,
});
const file = picked.find((p) => p.trim().length > 0);
// dialog cancelled — all-zero result, UI shows nothing
if (!file) return { connections: 0, stacks: 0, skipped: 0 };
const text = await (await import("node:fs/promises")).readFile(file, "utf8");
const result = await importWinScpIni(text, store);
events.connectionsChanged();
return result;
}),
getSshConfigHosts: () => wrap(async () => ({ hosts: loadSshConfigHosts() })),
// ---- settings ---- // ---- settings ----
getSettings: () => wrap(async () => ({ settings: await settings.load() })), getSettings: () => wrap(async () => ({ settings: await settings.load() })),
saveSettings: ({ settings: next }) => saveSettings: saveSettingsHandler(settings, (next) => events.settingsChanged(next)),
wrap(async () => {
await settings.save(next);
engine.setConcurrency(effectiveConcurrency(next));
return {};
}),
// ---- file editing ---- // ---- file editing ----
readFile: ({ sessionId, path }) => readFile: ({ sessionId, path }) =>
@@ -213,32 +282,9 @@ export function createAppRPC({
editExternal: ({ sessionId, path }) => editExternal: ({ sessionId, path }) =>
wrap(() => edits.editExternal(sessionId, path)), wrap(() => edits.editExternal(sessionId, path)),
pickEditorApp: () => pickEditorApp: pickEditorAppHandler,
wrap(async () => {
const picked = await Utils.openFileDialog({
startingFolder: "/Applications",
allowedFileTypes: "app",
canChooseFiles: true,
canChooseDirectory: false,
allowsMultipleSelection: false,
});
const path = picked.find((p) => p.trim().length > 0) ?? null;
return { path };
}),
pickKeyFile: () => pickKeyFile: pickKeyFileHandler,
wrap(async () => {
const { homedir } = await import("node:os");
const picked = await Utils.openFileDialog({
startingFolder: `${homedir()}/.ssh`,
allowedFileTypes: "*",
canChooseFiles: true,
canChooseDirectory: false,
allowsMultipleSelection: false,
});
const path = picked.find((p) => p.trim().length > 0) ?? null;
return { path };
}),
getEditLeftovers: () => getEditLeftovers: () =>
wrap(async () => ({ names: await edits.listLeftovers() })), wrap(async () => ({ names: await edits.listLeftovers() })),
@@ -249,10 +295,12 @@ export function createAppRPC({
return {}; return {};
}), }),
// ---- context menu + local shell integration ---- // ---- local shell integration (menu itself is HTML, see ui/) ----
showRowMenu: (params) => shellItemAction: ({ action, path }) =>
wrap(async () => { wrap(async () => {
contextMenu.show(params); if (action === "reveal") Utils.showItemInFolder(path);
else if (action === "open") Utils.openPath(path);
else Utils.clipboardWriteText(path);
return {}; return {};
}), }),
@@ -261,6 +309,187 @@ export function createAppRPC({
Utils.moveToTrash(path); Utils.moveToTrash(path);
return {}; return {};
}), }),
// ---- editor windows ----
openEditor: (ctx) =>
wrap(async () => {
events.openEditor(ctx);
return {};
}),
// ---- connection-manager window ----
openConnectionManager: () =>
wrap(async () => {
events.openConnectionManager();
return {};
}),
// ---- settings window ----
openSettings: () =>
wrap(async () => {
events.openSettings();
return {};
}),
showAbout: () =>
wrap(async () => {
events.showAbout();
return {};
}),
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 events.applyUpdate(); // relaunches + quits; may never resolve
return {};
}),
},
},
});
}
export interface EditorRpcHooks {
ctx: EditorOpenContext;
/** Draft mirror tick: store dirty flag + content on the window record. */
onDraft: (state: { dirty: boolean; content: string }) => void;
/** editorSave succeeded: clean the record, retitle, refresh main panels. */
onSaved: () => void;
/** Webview resolved its dirty prompt: close without the native fallback. */
onCloseConfirmed: () => void;
/** This window saved settings (e.g. the scale shortcuts) — rebroadcast them. */
onSettingsSaved: (next: AppSettings) => void;
}
/**
* RPC for one editor window. Each window MUST get its own instance (binding a
* BrowserView steals the rpc's transport), and the hooks close over that
* window's record — no ids on the wire.
*/
export function createEditorRPC(
deps: { registry: SessionRegistry; settings: SettingsStore },
hooks: EditorRpcHooks,
) {
return BrowserView.defineRPC<AppRPC>({
maxRequestTime: MAX_REQUEST_TIME_MS,
handlers: {
requests: {
readFile: ({ sessionId, path }) =>
wrap(async () => ({
content: await readFileText(deps.registry.get(sessionId), path),
})),
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
editorSave: ({ content }) =>
wrap(async () => {
const { sessionId, path } = hooks.ctx;
await writeFileText(deps.registry.get(sessionId), path, content);
hooks.onSaved();
return {};
}),
editorClose: () =>
wrap(async () => {
hooks.onCloseConfirmed();
return {};
}),
},
messages: {
editorState: (state) => hooks.onDraft(state),
},
},
});
}
export interface ManagerRpcHooks {
/** Store changed: rebuild the app menu + tell the main window to reload. */
onChanged: () => void;
/** Connect / open-stack execute in the MAIN window's workspace. */
onConnect: (id: string) => void;
onOpenStack: (id: string) => void;
onClose: () => void;
/** This window saved settings (e.g. the scale shortcuts) — rebroadcast them. */
onSettingsSaved: (next: AppSettings) => void;
}
/** RPC for the single connection-manager window (per-window instance). */
export function createManagerRPC(
deps: { store: ConnectionStore; settings: SettingsStore },
hooks: ManagerRpcHooks,
) {
return BrowserView.defineRPC<AppRPC>({
maxRequestTime: MAX_REQUEST_TIME_MS,
handlers: {
requests: {
...connectionStoreHandlers(deps.store, hooks.onChanged),
pickKeyFile: pickKeyFileHandler,
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
managerConnect: ({ id }) =>
wrap(async () => {
hooks.onConnect(id);
return {};
}),
managerOpenStack: ({ id }) =>
wrap(async () => {
hooks.onOpenStack(id);
return {};
}),
managerClose: () =>
wrap(async () => {
hooks.onClose();
return {};
}),
},
},
});
}
export interface SettingsRpcHooks {
/** Every edit here is a settings save — rebroadcast it to the other windows. */
onSettingsSaved: (next: AppSettings) => void;
showAbout: () => void;
/** The window's own close-window key. */
close: () => void;
}
/** RPC for the single Settings window (per-window instance). */
export function createSettingsRPC(deps: { settings: SettingsStore }, hooks: SettingsRpcHooks) {
return BrowserView.defineRPC<AppRPC>({
maxRequestTime: MAX_REQUEST_TIME_MS,
handlers: {
requests: {
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
pickEditorApp: pickEditorAppHandler,
showAbout: () =>
wrap(async () => {
hooks.showAbout();
return {};
}),
settingsClose: () =>
wrap(async () => {
hooks.close();
return {};
}),
}, },
}, },
}); });
+92
View File
@@ -0,0 +1,92 @@
import type { BrowserWindow } from "electrobun/main";
import type { AppSettings } from "../shared/types";
import { t } from "./i18n";
import { createSettingsRPC } from "./rpc-handlers";
import type { SettingsStore } from "./store/settings";
import { openSecondaryWindow } from "./windows";
const SETTINGS_WIDTH = 680;
const SETTINGS_HEIGHT = 480;
export interface SettingsWindowDeps {
settings: SettingsStore;
/** The once-resolved dev/prod view url. */
url: string;
/** Live settings snapshot — baked into the window's preload (theme flash). */
currentSettings: () => AppSettings;
mainFrame: () => { x: number; y: number; width: number; height: number };
/** This window saved settings — rebroadcast them to every other window. */
onSettingsSaved: (settings: AppSettings) => void;
showAbout: () => void;
}
/**
* The single Settings window. Unlike the connection manager it is NOT modal:
* every edit is an ordinary settings save, so the other windows follow live
* while it stays open. Its page claims its own close-window key (settingsClose).
* A ⌘W it doesn't claim (remapped away) falls through to the app menu's Close
* Tab, which index.ts routes here while this window is focused.
*/
export class SettingsWindow {
private win: BrowserWindow | null = null;
private rpc: ReturnType<typeof createSettingsRPC> | null = null;
private focused = false;
constructor(private deps: SettingsWindowDeps) {}
isFocused(): boolean {
return this.focused;
}
open(): void {
if (this.win) {
this.win.activate();
return;
}
this.rpc = createSettingsRPC(
{ settings: this.deps.settings },
{
onSettingsSaved: (next) => this.deps.onSettingsSaved(next),
showAbout: () => this.deps.showAbout(),
close: () => this.close(),
},
);
const main = this.deps.mainFrame();
this.win = openSecondaryWindow({
title: t("options.title"),
url: this.deps.url,
mode: "settings",
settings: this.deps.currentSettings(),
frame: {
x: Math.max(0, main.x + (main.width - SETTINGS_WIDTH) / 2),
y: Math.max(0, main.y + (main.height - SETTINGS_HEIGHT) / 2),
width: SETTINGS_WIDTH,
height: SETTINGS_HEIGHT,
},
rpc: this.rpc,
});
this.win.on("focus", () => (this.focused = true));
this.win.on("blur", () => (this.focused = false));
this.win.on("close", () => {
this.win = null;
this.rpc = null;
this.focused = false;
});
console.log("[settings] window opened");
}
close(): void {
this.win?.close(); // the close event does the cleanup
}
/** The native title follows a language switch (the header row is the UI's own). */
retitle(): void {
this.win?.setTitle(t("options.title"));
}
broadcastSettings(settings: AppSettings): void {
this.rpc?.send.settingsChanged({ settings });
}
}
+19
View File
@@ -1,4 +1,5 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import type { ConnectionsFile, SavedConnection, Stack } from "../../shared/types"; import type { ConnectionsFile, SavedConnection, Stack } from "../../shared/types";
import { appDataDir } from "./paths"; import { appDataDir } from "./paths";
@@ -68,5 +69,23 @@ export class ConnectionStore {
await fs.writeFile(tmp, JSON.stringify(this.data, null, "\t"), { mode: 0o600 }); await fs.writeFile(tmp, JSON.stringify(this.data, null, "\t"), { mode: 0o600 });
await fs.rename(tmp, this.file); await fs.rename(tmp, this.file);
await fs.chmod(this.file, 0o600); await fs.chmod(this.file, 0o600);
this.restrictOnWindows();
}
/**
* PORTABILITY: 0o600 only toggles the read-only bit on Windows, so the
* plaintext passwords in this file would be world-readable there. Replace
* inheritance with a current-user-only ACL, best effort.
*/
private restrictOnWindows(): void {
if (process.platform !== "win32") return;
try {
Bun.spawn(
["icacls", this.file, "/inheritance:r", "/grant:r", `${os.userInfo().username}:F`],
{ stdout: "ignore", stderr: "ignore" },
);
} catch {
// ACL tooling unavailable — file still works, just unprotected
}
} }
} }
+20 -2
View File
@@ -1,8 +1,12 @@
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 { sanitizeKeybindings } from "../../shared/keymap";
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 +14,21 @@ 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));
// same reasoning: every window resolves keys through this map
settings.keybindings = sanitizeKeybindings(data.keybindings);
settings.openFilesOnEnter = data.openFilesOnEnter === true;
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"));
} }
/** /**
+14 -9
View File
@@ -1,21 +1,26 @@
import { Tray, type BrowserWindow } from "electrobun/bun"; import { Tray, type BrowserWindow } from "electrobun/main";
/** /**
* Menu-bar icon: the app glyph as a macOS template image (adapts to * Menu-bar / notification-area icon. Clicking it brings the app window to the
* light/dark). Clicking it brings the app window to the front. No menu is ever * front. No menu is ever set, so clicks arrive directly; no setImage swaps, so
* set, so clicks arrive directly; no setImage swaps, so the template * the image treatment from construction always applies.
* treatment from construction always applies. *
* PORTABILITY: macOS uses a monochrome template image (adapts to light/dark
* menu bars); Windows ignores `template` and wants a small colored icon.
*/ */
export class AppTray { export class AppTray {
private tray: Tray; private tray: Tray;
constructor(win: BrowserWindow) { constructor(win: BrowserWindow) {
const isMac = process.platform === "darwin";
this.tray = new Tray({ this.tray = new Tray({
title: "", title: "",
image: "views://assets/tray/tray-44-template.png", image: isMac
template: true, ? "views://assets/tray/tray-44-template.png"
width: 22, : "views://assets/tray/tray-32-color.png",
height: 22, template: isMac,
width: isMac ? 22 : 16,
height: isMac ? 22 : 16,
}); });
this.tray.on("tray-clicked", () => win.activate()); this.tray.on("tray-clicked", () => win.activate());
} }
+178
View File
@@ -0,0 +1,178 @@
import { BrowserWindow } from "electrobun/main";
import type { AppSettings } from "../shared/types";
/**
* The preload globals every window gets. Single composition point: the UI
* reads them in ui/src/app/core/platform.ts before first paint (window chrome and
* root-component selection both depend on them).
*
* The persisted theme attributes are baked in here — mirroring the
* delete/set logic of SettingsService.applyX — so the FIRST paint is already
* themed (the inline <style> in ui/src/index.html consumes them). Without
* this, forced themes only apply after the async getSettings RPC and new
* windows flash the wrong background.
*/
export function buildPreload(
mode: "main" | "editor" | "manager" | "settings",
settings: AppSettings,
ctx?: unknown,
): string {
let script =
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
`window.__APP_MODE=${JSON.stringify(mode)};`;
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
const dataset: Record<string, string> = {};
if (settings.theme !== "system") dataset["theme"] = settings.theme;
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
// 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)) {
script += `document.documentElement.dataset[${JSON.stringify(key)}]=${JSON.stringify(value)};`;
}
return script;
}
/**
* Native chrome for every window — the ONE place the frame is decided; the main
* window (src/bun/index.ts) and openSecondaryWindow both spread this. macOS
* fuses the titlebar with the app's own header row (hiddenInset: the traffic
* lights overlay it, which is what IS_MAC_CHROME reserves pl-24 for); other
* platforms keep the native titlebar. Constructor-only: there is no API to
* re-decorate a live window.
*
* electrobun 2.x adds trafficLightOffset to macOS's default button position
* (9/9 in the 32pt macOS 26 titlebar) and grows the titlebar to fit — 1.18.1
* clamped the shift inside the stock titlebar instead. {12, 4} puts the 14pt
* buttons at left 21 / top 13: vertically centred in the 40px h-10 header.
*/
export function chromeOptions() {
return process.platform === "darwin"
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 4 } }
: {};
}
/**
* Windows are created hidden and revealed on the webview's first dom-ready —
* showing them earlier paints the native (white) surface before the themed
* page exists. The fallback timer guarantees a missed event can never leave
* a window invisible.
*/
export function showWhenReady(win: BrowserWindow): void {
let shown = false;
const show = () => {
if (shown) return;
shown = true;
win.show();
};
const fallback = setTimeout(show, 800);
win.webview.on("dom-ready", () => {
clearTimeout(fallback);
show();
});
}
export interface SecondaryWindowOptions {
title: string;
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
url: string;
mode: "editor" | "manager" | "settings";
/** Current persisted settings — baked into the preload for flash-free paint. */
settings: AppSettings;
ctx?: unknown;
frame: { x: number; y: number; width: number; height: number };
/** Per-window rpc instance. NEVER share one across windows — each
* BrowserView steals the rpc's transport when it binds. */
rpc: NonNullable<ConstructorParameters<typeof BrowserWindow>[0]>["rpc"];
}
/** App-chromed secondary window; its frame comes from chromeOptions. */
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
const win = new BrowserWindow({
title: opts.title,
url: opts.url,
frame: opts.frame,
...chromeOptions(),
preload: buildPreload(opts.mode, opts.settings, opts.ctx),
rpc: opts.rpc,
hidden: true,
});
showWhenReady(win);
return win;
}
/**
* Keyed registry of secondary windows: dedupe/activate, cascade placement off
* an anchor window, focus tracking, close cleanup. A future "window follows
* window" grouping step slots in here — the registry already owns the window
* handles and the anchor it would need (anchor.on('move') + setPosition).
*/
export class WindowRegistry<Rec extends { win: BrowserWindow }> {
private records = new Map<string, Rec>();
private focused = new Set<string>();
private opened = 0;
constructor(private anchorFrame: () => { x: number; y: number }) {}
get(key: string): Rec | undefined {
return this.records.get(key);
}
/** True (and focuses the window) when the key already has a live window. */
activateExisting(key: string): boolean {
const rec = this.records.get(key);
if (!rec) return false;
rec.win.activate();
return true;
}
set(key: string, rec: Rec): void {
this.records.set(key, rec);
}
/** Cascade off the anchor so stacked windows never hide each other fully. */
nextFrame(width: number, height: number): { x: number; y: number; width: number; height: number } {
const anchor = this.anchorFrame();
const step = (this.opened++ % 8) * 28;
return { x: anchor.x + 90 + step, y: anchor.y + 70 + step, width, height };
}
/**
* Wire close/focus/blur for a window. electrobun's emitter has no off(),
* so these closures capture only (this, key) — never the record — letting
* closed-window content be garbage collected after delete().
*/
track(key: string, win: BrowserWindow, onClose: (key: string) => void): void {
win.on("focus", () => this.focused.add(key));
win.on("blur", () => this.focused.delete(key));
win.on("close", () => {
this.focused.delete(key);
onClose(key);
});
}
delete(key: string): void {
this.records.delete(key);
}
anyFocused(): boolean {
return this.focused.size > 0;
}
focusedKey(): string | null {
for (const key of this.focused) return key;
return null;
}
forEach(fn: (rec: Rec, key: string) => void): void {
for (const [key, rec] of this.records) fn(rec, key);
}
size(): number {
return this.records.size;
}
}
+9 -3
View File
@@ -1,12 +1,18 @@
// The single place to rebrand the app. Everything (window title, bundle name, // The single place to rebrand the app. Everything (window title, bundle name,
// app-data directory, UI header, icon) reads from here. // app-data directory, UI header, icon) reads from here.
import { BUILD } from "./version.gen";
export const BRANDING = { export const BRANDING = {
appName: "miniSCP", appName: "miniSCP",
identifier: "es.teknes.miniscp", identifier: "es.teknes.miniscp",
// Previous app names: their app-data folders are migrated on boot // Previous app names whose app-data folders migrate on boot after a rename
// (see migrateLegacyDataDir in src/bun/store/paths.ts). // (see migrateLegacyDataDir in src/bun/store/paths.ts).
legacyNames: ["BunSCP"], legacyNames: [] as string[],
version: "0.1.0", // Sublime-style versioning: n = commit count (scripts/gen-version.ts).
// `version` stays semver-shaped for electrobun's config/updater.
version: `0.1.${BUILD.number}`,
buildLabel: `Build ${BUILD.number}`,
build: BUILD,
// macOS build converts this .iconset folder into AppIcon.icns. // macOS build converts this .iconset folder into AppIcon.icns.
// Regenerate from assets/icon.png with scripts/gen-iconset.sh. // Regenerate from assets/icon.png with scripts/gen-iconset.sh.
icon: "assets/icon.iconset", icon: "assets/icon.iconset",
+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.
+431
View File
@@ -0,0 +1,431 @@
// 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",
closeTab: "Tab schließen",
options: "Einstellungen",
update: "Update verfügbar",
updating: "Aktualisiere",
restartToUpdate: "Zum Anwenden neu starten",
updateTooltip: "Update verfügbar: miniSCP {version}",
},
options: {
title: "Einstellungen",
sections: {
appearance: "Darstellung",
transfers: "Übertragungen",
editor: "Editor",
fileList: "Dateiliste",
keyboard: "Tastatur",
about: "Über",
},
columns: "Sichtbare Spalten",
language: "Sprache",
system: "System",
theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" },
tone: { label: "Ton", dark: "Dunkel", darker: "AMOLED" },
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" },
openFiles: { label: "Eingabe / Doppelklick auf eine Datei", nothing: "Nichts tun", open: "Öffnen", hint: "Lokale Dateien öffnen sich in ihrer Standard-App; Ordner öffnen sich immer." },
},
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",
copyToLeft: "An linkes Panel senden",
edit: "Bearbeiten",
editFile: "Datei bearbeiten",
rename: "Umbenennen",
newFolder: "Neuer Ordner",
newFile: "Neue Datei",
permissions: "Berechtigungen",
delete: "Löschen",
parentDir: "Übergeordneter Ordner",
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",
findHint: "Suchen",
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",
verbs: { save: "speichern", find: "suchen", close: "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',
movedManyToTrash: { one: "{count} Objekt in den Papierkorb verschoben", other: "{count} Objekte 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}",
settings: "Einstellungen…",
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",
},
keys: {
"cursor-up": "Nach oben",
"cursor-down": "Nach unten",
"page-up": "Seite nach oben",
"page-down": "Seite nach unten",
"cursor-first": "Erster Eintrag",
"cursor-last": "Letzter Eintrag",
"toggle-select": "Auswählen / abwählen",
"select-all": "Alles auswählen",
open: "Öffnen",
parent: "Übergeordneter Ordner",
"switch-panel": "Panel wechseln",
"row-menu": "Menü des Eintrags zeigen",
send: "An anderes Panel senden",
edit: "Bearbeiten",
rename: "Umbenennen",
"new-folder": "Neuer Ordner",
"new-file": "Neue Datei",
chmod: "Berechtigungen",
delete: "Löschen",
trash: "In den Papierkorb",
"copy-path": "Pfad kopieren",
reveal: "Im Dateimanager zeigen",
"open-default": "Mit Standard-App öffnen",
refresh: "Aktualisieren",
"focus-path": "Zum Pfad",
"source-picker": "Panelinhalt wählen",
"sort-name": "Nach Name sortieren",
"sort-size": "Nach Größe sortieren",
"sort-mtime": "Nach Änderungsdatum sortieren",
"sort-kind": "Nach Art sortieren",
"sort-rights": "Nach Rechten sortieren",
"sort-owner": "Nach Eigentümer sortieren",
"sort-group": "Nach Gruppe sortieren",
"new-tab": "Neuer Tab",
"close-tab": "Tab schließen",
"close-other-tabs": "Andere Tabs schließen",
"close-tabs-right": "Tabs rechts schließen",
"next-tab": "Nächster Tab",
"prev-tab": "Vorheriger Tab",
"go-to-tab": "Zu Tab {n}",
"go-to-last-tab": "Zum letzten Tab",
"move-tab-left": "Tab nach links",
"move-tab-right": "Tab nach rechts",
"disconnect-tab": "Tab trennen",
"open-settings": "Einstellungen",
"connection-manager": "Verbindungsverwaltung",
"toggle-transfers": "Übertragungen ein-/ausblenden",
"install-update": "Update installieren",
"scale-up": "Oberfläche größer",
"scale-down": "Oberfläche kleiner",
"scale-reset": "Oberfläche auf 100 %",
"close-window": "Fenster schließen",
save: "Speichern",
find: "Suchen",
"find-next": "Weitersuchen",
"find-prev": "Rückwärts suchen",
"toggle-wrap": "Zeilen umbrechen",
"conn-new": "Neue Verbindung",
"conn-new-stack": "Neue Gruppe",
"conn-save": "Verbindung speichern",
"conn-connect": "Verbinden / Gruppe öffnen",
"conn-delete": "Verbindung oder Gruppe löschen",
"conn-rename-stack": "Gruppe umbenennen",
"conn-import": "Aus WinSCP importieren",
groups: {
panels: "Dateiliste",
files: "Dateien",
sort: "Sortierung",
tabs: "Tabs",
window: "Fenster",
editor: "Editor",
connections: "Verbindungsverwaltung",
},
ui: {
filter: "Tastenkürzel suchen",
intro: "Auf + klicken und die Tasten drücken, um ein Kürzel hinzuzufügen. Das erste zeigen Menüs und Tooltips an.",
add: "Kürzel hinzufügen",
remove: "{key} entfernen",
reset: "Auf Standard zurücksetzen",
resetAll: "Alle zurücksetzen",
resetAllConfirm: "Alle Kürzel zurücksetzen",
press: "Tasten drücken… ({esc} bricht ab)",
unbound: "Kein Kürzel",
reserved: "{key} ist vom System reserviert",
needsModifier: "Im Editor würde {key} nur tippen – eine Sondertaste hinzufügen oder eine F-Taste nehmen",
usedBy: "{key} wird schon von „{action}“ benutzt",
replace: "Ersetzen",
conflict: "Gleiche Taste wie {action}",
plainLetter: "{key} in der Dateiliste tippen filtert sie nicht mehr",
noResults: "Kein Kürzel passt",
fixed: "Feste Tasten",
},
fixed: {
escape: "{esc} schließt Menüs, Dialoge und die Suchleiste",
escList: "{esc} in der Dateiliste leert den Filter, bricht dann eine Verbindung ab und hebt dann die Auswahl auf",
dialogs: "{enter} und {esc} bestätigen oder verwerfen einen Dialog; {tab} und {shiftTab} wechseln zwischen seinen Elementen",
menus: "↑ ↓ {home} {end} und {enter} funktionieren in jedem Menü",
filter: "Tippen in der Dateiliste filtert sie; {backspace} löscht das letzte Zeichen des Filters",
shift: "Umschalt mit einer Bewegungstaste wählt die überfahrenen Zeilen aus",
find: "{enter} und {shiftEnter} springen durch die Treffer der Suche",
manager: "↑ ↓ ← → und {enter} bewegen sich durch die Verbindungsliste",
sidebar: "↑ ↓ wechseln hier zwischen den Bereichen",
},
names: {
ctrl: "Strg",
alt: "Alt",
shift: "Umschalt",
enter: "Eingabe",
backspace: "Rücktaste",
del: "Entf",
esc: "Esc",
space: "Leertaste",
tab: "Tab",
pgup: "Bild↑",
pgdn: "Bild↓",
home: "Pos1",
end: "Ende",
ins: "Einfg",
menu: "Menü",
num: "Num",
},
},
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;
+440
View File
@@ -0,0 +1,440 @@
// 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",
closeTab: "Close tab",
options: "Settings",
update: "Update available",
updating: "Updating",
restartToUpdate: "Restart to apply",
updateTooltip: "Update available: miniSCP {version}",
},
options: {
title: "Settings",
sections: {
appearance: "Appearance",
transfers: "Transfers",
editor: "Editor",
fileList: "File list",
keyboard: "Keyboard",
about: "About",
},
columns: "Visible columns",
language: "Language",
system: "System",
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
tone: { label: "Tone", dark: "Dark", darker: "AMOLED" },
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" },
openFiles: { label: "Enter / double-click on a file", nothing: "Do nothing", open: "Open", hint: "Local files open in their default app; folders always open." },
},
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",
copyToLeft: "Send to left panel",
edit: "Edit",
editFile: "Edit file",
rename: "Rename",
newFolder: "New folder",
newFile: "New file",
permissions: "Permissions",
delete: "Delete",
parentDir: "Parent directory",
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",
findHint: "Find",
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",
// footer hint: "⌘S save · ⌘F find · ⌘W close", keys from the keymap
verbs: { save: "save", find: "find", close: "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',
movedManyToTrash: { one: "Moved {count} item to Trash", other: "Moved {count} items 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}",
settings: "Settings…",
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",
},
// Keyboard commands — one label per src/shared/keymap.ts BINDINGS id
keys: {
"cursor-up": "Move up",
"cursor-down": "Move down",
"page-up": "Page up",
"page-down": "Page down",
"cursor-first": "First item",
"cursor-last": "Last item",
"toggle-select": "Select / deselect",
"select-all": "Select all",
open: "Open",
parent: "Parent folder",
"switch-panel": "Switch panel",
"row-menu": "Show the item's menu",
send: "Send to the other panel",
edit: "Edit",
rename: "Rename",
"new-folder": "New folder",
"new-file": "New file",
chmod: "Permissions",
delete: "Delete",
trash: "Move to Trash",
"copy-path": "Copy path",
reveal: "Reveal in file manager",
"open-default": "Open in default app",
refresh: "Refresh",
"focus-path": "Go to path",
"source-picker": "Choose what the panel shows",
"sort-name": "Sort by name",
"sort-size": "Sort by size",
"sort-mtime": "Sort by modified",
"sort-kind": "Sort by kind",
"sort-rights": "Sort by rights",
"sort-owner": "Sort by owner",
"sort-group": "Sort by group",
"new-tab": "New tab",
"close-tab": "Close tab",
"close-other-tabs": "Close other tabs",
"close-tabs-right": "Close tabs to the right",
"next-tab": "Next tab",
"prev-tab": "Previous tab",
"go-to-tab": "Go to tab {n}",
"go-to-last-tab": "Go to last tab",
"move-tab-left": "Move tab left",
"move-tab-right": "Move tab right",
"disconnect-tab": "Disconnect tab",
"open-settings": "Settings",
"connection-manager": "Connection manager",
"toggle-transfers": "Show / hide transfers",
"install-update": "Install update",
"scale-up": "Bigger interface",
"scale-down": "Smaller interface",
"scale-reset": "Interface at 100%",
"close-window": "Close window",
save: "Save",
find: "Find",
"find-next": "Find next",
"find-prev": "Find previous",
"toggle-wrap": "Wrap lines",
"conn-new": "New connection",
"conn-new-stack": "New stack",
"conn-save": "Save connection",
"conn-connect": "Connect / open stack",
"conn-delete": "Delete connection or stack",
"conn-rename-stack": "Rename stack",
"conn-import": "Import from WinSCP",
groups: {
panels: "File list",
files: "Files",
sort: "Sorting",
tabs: "Tabs",
window: "Window",
editor: "Editor",
connections: "Connection manager",
},
// Settings → Keyboard
ui: {
filter: "Search shortcuts",
intro: "Press + and type the keys to add a shortcut. The first one is the hint menus and tooltips show.",
add: "Add a shortcut",
remove: "Remove {key}",
reset: "Reset to default",
resetAll: "Reset all",
resetAllConfirm: "Reset every shortcut",
press: "Press keys… ({esc} cancels)",
unbound: "No shortcut",
reserved: "{key} is reserved by the system",
needsModifier: "In the editor {key} would only type — add a modifier or use an F-key",
usedBy: "{key} is already used by “{action}”",
replace: "Replace",
conflict: "Same key as {action}",
plainLetter: "Typing {key} in the file list no longer filters it",
noResults: "No shortcut matches",
fixed: "Fixed keys",
},
// fixed keys, listed read-only there (key params come from the platform)
fixed: {
escape: "{esc} closes menus, dialogs and the find bar",
escList: "{esc} in the file list clears the filter, then cancels a connect, then clears the selection",
dialogs: "{enter} and {esc} confirm or cancel a dialog; {tab} and {shiftTab} move between its controls",
menus: "↑ ↓ {home} {end} and {enter} work in every menu",
filter: "Typing in the file list filters it; {backspace} trims the filter",
shift: "Shift with a movement key selects the rows the cursor passes",
find: "{enter} and {shiftEnter} step through the matches in the find box",
manager: "↑ ↓ ← → and {enter} move through the connection list",
sidebar: "↑ ↓ move between these sections",
},
// key names for "Ctrl+Shift+Del"-style hints (not used on macOS, which shows ⌘⇧⌫)
names: {
ctrl: "Ctrl",
alt: "Alt",
shift: "Shift",
enter: "Enter",
backspace: "Backspace",
del: "Del",
esc: "Esc",
space: "Space",
tab: "Tab",
pgup: "PgUp",
pgdn: "PgDn",
home: "Home",
end: "End",
ins: "Ins",
menu: "Menu",
num: "Num",
},
},
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;
+431
View File
@@ -0,0 +1,431 @@
// 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",
closeTab: "Cerrar pestaña",
options: "Ajustes",
update: "Actualización disponible",
updating: "Actualizando",
restartToUpdate: "Reiniciar para aplicar",
updateTooltip: "Actualización disponible: miniSCP {version}",
},
options: {
title: "Ajustes",
sections: {
appearance: "Apariencia",
transfers: "Transferencias",
editor: "Editor",
fileList: "Lista de archivos",
keyboard: "Teclado",
about: "Acerca de",
},
columns: "Columnas visibles",
language: "Idioma",
system: "Sistema",
theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
tone: { label: "Tono", dark: "Oscuro", darker: "AMOLED" },
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" },
openFiles: { label: "Intro / doble clic en un archivo", nothing: "No hacer nada", open: "Abrir", hint: "Los archivos locales se abren con su app predeterminada; las carpetas siempre se abren." },
},
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",
copyToLeft: "Enviar al panel izquierdo",
edit: "Editar",
editFile: "Editar archivo",
rename: "Renombrar",
newFolder: "Nueva carpeta",
newFile: "Nuevo archivo",
permissions: "Permisos",
delete: "Eliminar",
parentDir: "Carpeta superior",
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",
findHint: "Buscar",
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",
verbs: { save: "guardar", find: "buscar", close: "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',
movedManyToTrash: { one: "Se movió {count} elemento a la papelera", other: "Se movieron {count} elementos 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}",
settings: "Ajustes…",
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í",
},
keys: {
"cursor-up": "Subir",
"cursor-down": "Bajar",
"page-up": "Página arriba",
"page-down": "Página abajo",
"cursor-first": "Primer elemento",
"cursor-last": "Último elemento",
"toggle-select": "Seleccionar / deseleccionar",
"select-all": "Seleccionar todo",
open: "Abrir",
parent: "Carpeta superior",
"switch-panel": "Cambiar de panel",
"row-menu": "Mostrar el menú del elemento",
send: "Enviar al otro panel",
edit: "Editar",
rename: "Renombrar",
"new-folder": "Nueva carpeta",
"new-file": "Nuevo archivo",
chmod: "Permisos",
delete: "Eliminar",
trash: "Mover a la papelera",
"copy-path": "Copiar ruta",
reveal: "Mostrar en el gestor de archivos",
"open-default": "Abrir con la app predeterminada",
refresh: "Actualizar",
"focus-path": "Ir a la ruta",
"source-picker": "Elegir qué muestra el panel",
"sort-name": "Ordenar por nombre",
"sort-size": "Ordenar por tamaño",
"sort-mtime": "Ordenar por fecha de modificación",
"sort-kind": "Ordenar por tipo",
"sort-rights": "Ordenar por permisos",
"sort-owner": "Ordenar por propietario",
"sort-group": "Ordenar por grupo",
"new-tab": "Nueva pestaña",
"close-tab": "Cerrar pestaña",
"close-other-tabs": "Cerrar las demás pestañas",
"close-tabs-right": "Cerrar las pestañas de la derecha",
"next-tab": "Pestaña siguiente",
"prev-tab": "Pestaña anterior",
"go-to-tab": "Ir a la pestaña {n}",
"go-to-last-tab": "Ir a la última pestaña",
"move-tab-left": "Mover la pestaña a la izquierda",
"move-tab-right": "Mover la pestaña a la derecha",
"disconnect-tab": "Desconectar la pestaña",
"open-settings": "Ajustes",
"connection-manager": "Gestor de conexiones",
"toggle-transfers": "Mostrar / ocultar transferencias",
"install-update": "Instalar la actualización",
"scale-up": "Interfaz más grande",
"scale-down": "Interfaz más pequeña",
"scale-reset": "Interfaz al 100 %",
"close-window": "Cerrar la ventana",
save: "Guardar",
find: "Buscar",
"find-next": "Buscar siguiente",
"find-prev": "Buscar anterior",
"toggle-wrap": "Ajustar líneas",
"conn-new": "Nueva conexión",
"conn-new-stack": "Nuevo grupo",
"conn-save": "Guardar la conexión",
"conn-connect": "Conectar / abrir grupo",
"conn-delete": "Eliminar conexión o grupo",
"conn-rename-stack": "Renombrar grupo",
"conn-import": "Importar de WinSCP",
groups: {
panels: "Lista de archivos",
files: "Archivos",
sort: "Orden",
tabs: "Pestañas",
window: "Ventana",
editor: "Editor",
connections: "Gestor de conexiones",
},
ui: {
filter: "Buscar atajos",
intro: "Pulsa + y teclea las teclas para añadir un atajo. El primero es el que muestran los menús y las pistas.",
add: "Añadir un atajo",
remove: "Quitar {key}",
reset: "Restablecer el predeterminado",
resetAll: "Restablecer todo",
resetAllConfirm: "Restablecer todos los atajos",
press: "Pulsa las teclas… ({esc} cancela)",
unbound: "Sin atajo",
reserved: "{key} está reservado por el sistema",
needsModifier: "En el editor {key} solo escribiría: añade un modificador o usa una tecla F",
usedBy: "{key} ya lo usa «{action}»",
replace: "Reemplazar",
conflict: "Misma tecla que {action}",
plainLetter: "Teclear {key} en la lista de archivos ya no la filtra",
noResults: "Ningún atajo coincide",
fixed: "Teclas fijas",
},
fixed: {
escape: "{esc} cierra menús, diálogos y la barra de búsqueda",
escList: "{esc} en la lista de archivos borra el filtro, luego cancela una conexión y luego quita la selección",
dialogs: "{enter} y {esc} confirman o cancelan un diálogo; {tab} y {shiftTab} recorren sus controles",
menus: "↑ ↓ {home} {end} y {enter} funcionan en todos los menús",
filter: "Teclear en la lista de archivos la filtra; {backspace} borra el último carácter del filtro",
shift: "Mayús con una tecla de movimiento selecciona las filas por las que pasa el cursor",
find: "{enter} y {shiftEnter} recorren las coincidencias de la búsqueda",
manager: "↑ ↓ ← → y {enter} recorren la lista de conexiones",
sidebar: "↑ ↓ cambian de sección aquí",
},
names: {
ctrl: "Ctrl",
alt: "Alt",
shift: "Mayús",
enter: "Intro",
backspace: "Retroceso",
del: "Supr",
esc: "Esc",
space: "Espacio",
tab: "Tab",
pgup: "RePág",
pgdn: "AvPág",
home: "Inicio",
end: "Fin",
ins: "Insert",
menu: "Menú",
num: "Num",
},
},
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;
+433
View File
@@ -0,0 +1,433 @@
// 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",
closeTab: "Itxi fitxa",
options: "Ezarpenak",
update: "Eguneratzea eskuragarri",
updating: "Eguneratzen",
restartToUpdate: "Berrabiarazi aplikatzeko",
updateTooltip: "Eguneratzea eskuragarri: miniSCP {version}",
},
options: {
title: "Ezarpenak",
sections: {
appearance: "Itxura",
transfers: "Transferentziak",
editor: "Editorea",
fileList: "Fitxategi-zerrenda",
keyboard: "Teklatua",
about: "Honi buruz",
},
columns: "Zutabe ikusgaiak",
language: "Hizkuntza",
system: "Sistema",
theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" },
tone: { label: "Tonua", dark: "Iluna", darker: "AMOLED" },
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" },
openFiles: { label: "Sartu / klik bikoitza fitxategi batean", nothing: "Ezer ez", open: "Ireki", hint: "Fitxategi lokalak beren aplikazio lehenetsiarekin irekitzen dira; karpetak beti irekitzen dira." },
},
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",
copyToLeft: "Bidali ezkerreko panelera",
edit: "Editatu",
editFile: "Editatu fitxategia",
rename: "Berrizendatu",
newFolder: "Karpeta berria",
newFile: "Fitxategi berria",
permissions: "Baimenak",
delete: "Ezabatu",
parentDir: "Karpeta gurasoa",
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",
findHint: "Bilatu",
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",
verbs: { save: "gorde", find: "bilatu", close: "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',
movedManyToTrash: { one: "{count} elementu zakarrontzira eraman da", other: "{count} elementu zakarrontzira eraman dira" },
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",
settings: "Ezarpenak…",
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",
},
keys: {
"cursor-up": "Gora",
"cursor-down": "Behera",
"page-up": "Orrialde bat gora",
"page-down": "Orrialde bat behera",
"cursor-first": "Lehen elementua",
"cursor-last": "Azken elementua",
"toggle-select": "Hautatu / desautatu",
"select-all": "Hautatu dena",
open: "Ireki",
parent: "Karpeta gurasoa",
"switch-panel": "Aldatu panela",
"row-menu": "Erakutsi elementuaren menua",
send: "Bidali beste panelera",
edit: "Editatu",
rename: "Berrizendatu",
"new-folder": "Karpeta berria",
"new-file": "Fitxategi berria",
chmod: "Baimenak",
delete: "Ezabatu",
trash: "Eraman zakarrontzira",
"copy-path": "Kopiatu bidea",
reveal: "Erakutsi fitxategi-kudeatzailean",
"open-default": "Ireki aplikazio lehenetsiarekin",
refresh: "Freskatu",
"focus-path": "Joan bidera",
"source-picker": "Aukeratu panelak zer erakusten duen",
"sort-name": "Ordenatu izenaren arabera",
"sort-size": "Ordenatu tamainaren arabera",
"sort-mtime": "Ordenatu aldaketa-dataren arabera",
"sort-kind": "Ordenatu motaren arabera",
"sort-rights": "Ordenatu baimenen arabera",
"sort-owner": "Ordenatu jabearen arabera",
"sort-group": "Ordenatu taldearen arabera",
"new-tab": "Fitxa berria",
"close-tab": "Itxi fitxa",
"close-other-tabs": "Itxi beste fitxak",
"close-tabs-right": "Itxi eskuineko fitxak",
"next-tab": "Hurrengo fitxa",
"prev-tab": "Aurreko fitxa",
"go-to-tab": "Joan {n}. fitxara",
"go-to-last-tab": "Joan azken fitxara",
"move-tab-left": "Eraman fitxa ezkerrera",
"move-tab-right": "Eraman fitxa eskuinera",
"disconnect-tab": "Deskonektatu fitxa",
"open-settings": "Ezarpenak",
"connection-manager": "Konexio-kudeatzailea",
"toggle-transfers": "Erakutsi / ezkutatu transferentziak",
"install-update": "Instalatu eguneraketa",
"scale-up": "Interfaze handiagoa",
"scale-down": "Interfaze txikiagoa",
"scale-reset": "Interfazea % 100ean",
"close-window": "Itxi leihoa",
save: "Gorde",
find: "Bilatu",
"find-next": "Bilatu hurrengoa",
"find-prev": "Bilatu aurrekoa",
"toggle-wrap": "Doitu lerroak",
"conn-new": "Konexio berria",
"conn-new-stack": "Talde berria",
"conn-save": "Gorde konexioa",
"conn-connect": "Konektatu / ireki taldea",
"conn-delete": "Ezabatu konexioa edo taldea",
"conn-rename-stack": "Berrizendatu taldea",
"conn-import": "Inportatu WinSCPtik",
groups: {
panels: "Fitxategi-zerrenda",
files: "Fitxategiak",
sort: "Ordena",
tabs: "Fitxak",
window: "Leihoa",
editor: "Editorea",
connections: "Konexio-kudeatzailea",
},
ui: {
filter: "Bilatu lasterbideak",
intro: "Sakatu + eta idatzi teklak lasterbide bat gehitzeko. Lehenengoa da menuek eta argibideek erakusten dutena.",
add: "Gehitu lasterbide bat",
remove: "Kendu {key}",
reset: "Berrezarri lehenetsia",
resetAll: "Berrezarri dena",
resetAllConfirm: "Berrezarri lasterbide guztiak",
press: "Sakatu teklak… ({esc} utzi)",
unbound: "Lasterbiderik ez",
reserved: "{key} sistemak erreserbatuta dauka",
needsModifier: "Editorean {key}(e)k idatzi besterik ez luke egingo: gehitu aldatzaile bat edo erabili F tekla bat",
usedBy: "{key} «{action}»(e)k erabiltzen du dagoeneko",
replace: "Ordeztu",
conflict: "{action}(r)en tekla bera",
plainLetter: "Fitxategi-zerrendan {key} idazteak ez du jada iragazten",
noResults: "Ez dago bat datorren lasterbiderik",
fixed: "Tekla finkoak",
},
fixed: {
escape: "{esc}(e)k menuak, elkarrizketa-koadroak eta bilaketa-barra ixten ditu",
escList: "Fitxategi-zerrendan {esc}(e)k iragazkia garbitzen du, gero konexioa bertan behera uzten du eta gero hautapena kentzen du",
dialogs: "{enter} eta {esc} elkarrizketa-koadroa berresten edo uzten dute; {tab} eta {shiftTab} haren kontroletan mugitzen dira",
menus: "↑ ↓ {home} {end} eta {enter} menu guztietan dabiltza",
filter: "Fitxategi-zerrendan idazteak iragazi egiten du; {backspace}(e)k iragazkiaren azken karakterea ezabatzen du",
shift: "Maius mugimendu-tekla batekin kurtsoreak igarotako errenkadak hautatzen ditu",
find: "{enter} eta {shiftEnter} bilaketako bat-etortzeetan mugitzen dira",
manager: "↑ ↓ ← → eta {enter} konexio-zerrendan mugitzen dira",
sidebar: "↑ ↓ atal batetik bestera mugitzen dira hemen",
},
names: {
ctrl: "Ctrl",
alt: "Alt",
shift: "Maius",
enter: "Intro",
backspace: "Atzera",
del: "Supr",
esc: "Esc",
space: "Zuriunea",
tab: "Tab",
pgup: "RePág",
pgdn: "AvPág",
home: "Inicio",
end: "Fin",
ins: "Insert",
menu: "Menua",
num: "Num",
},
},
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;
+431
View File
@@ -0,0 +1,431 @@
// 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",
closeTab: "Fermer l'onglet",
options: "Réglages",
update: "Mise à jour disponible",
updating: "Mise à jour",
restartToUpdate: "Redémarrer pour appliquer",
updateTooltip: "Mise à jour disponible : miniSCP {version}",
},
options: {
title: "Réglages",
sections: {
appearance: "Apparence",
transfers: "Transferts",
editor: "Éditeur",
fileList: "Liste des fichiers",
keyboard: "Clavier",
about: "À propos",
},
columns: "Colonnes visibles",
language: "Langue",
system: "Système",
theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
tone: { label: "Ton", dark: "Sombre", darker: "AMOLED" },
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" },
openFiles: { label: "Entrée / double-clic sur un fichier", nothing: "Ne rien faire", open: "Ouvrir", hint: "Les fichiers locaux s'ouvrent avec leur application par défaut ; les dossiers s'ouvrent toujours." },
},
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",
copyToLeft: "Envoyer vers le panneau gauche",
edit: "Éditer",
editFile: "Éditer le fichier",
rename: "Renommer",
newFolder: "Nouveau dossier",
newFile: "Nouveau fichier",
permissions: "Autorisations",
delete: "Supprimer",
parentDir: "Dossier parent",
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",
findHint: "Rechercher",
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",
verbs: { save: "enregistrer", find: "rechercher", close: "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',
movedManyToTrash: { one: "{count} élément mis à la corbeille", other: "{count} éléments 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}",
settings: "Réglages…",
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",
},
keys: {
"cursor-up": "Monter",
"cursor-down": "Descendre",
"page-up": "Page précédente",
"page-down": "Page suivante",
"cursor-first": "Premier élément",
"cursor-last": "Dernier élément",
"toggle-select": "Sélectionner / désélectionner",
"select-all": "Tout sélectionner",
open: "Ouvrir",
parent: "Dossier parent",
"switch-panel": "Changer de panneau",
"row-menu": "Afficher le menu de l'élément",
send: "Envoyer vers l'autre panneau",
edit: "Éditer",
rename: "Renommer",
"new-folder": "Nouveau dossier",
"new-file": "Nouveau fichier",
chmod: "Permissions",
delete: "Supprimer",
trash: "Mettre à la corbeille",
"copy-path": "Copier le chemin",
reveal: "Afficher dans le gestionnaire de fichiers",
"open-default": "Ouvrir avec l'application par défaut",
refresh: "Actualiser",
"focus-path": "Aller au chemin",
"source-picker": "Choisir ce qu'affiche le panneau",
"sort-name": "Trier par nom",
"sort-size": "Trier par taille",
"sort-mtime": "Trier par date de modification",
"sort-kind": "Trier par type",
"sort-rights": "Trier par droits",
"sort-owner": "Trier par propriétaire",
"sort-group": "Trier par groupe",
"new-tab": "Nouvel onglet",
"close-tab": "Fermer l'onglet",
"close-other-tabs": "Fermer les autres onglets",
"close-tabs-right": "Fermer les onglets à droite",
"next-tab": "Onglet suivant",
"prev-tab": "Onglet précédent",
"go-to-tab": "Aller à l'onglet {n}",
"go-to-last-tab": "Aller au dernier onglet",
"move-tab-left": "Déplacer l'onglet à gauche",
"move-tab-right": "Déplacer l'onglet à droite",
"disconnect-tab": "Déconnecter l'onglet",
"open-settings": "Réglages",
"connection-manager": "Gestionnaire de connexions",
"toggle-transfers": "Afficher / masquer les transferts",
"install-update": "Installer la mise à jour",
"scale-up": "Interface plus grande",
"scale-down": "Interface plus petite",
"scale-reset": "Interface à 100 %",
"close-window": "Fermer la fenêtre",
save: "Enregistrer",
find: "Rechercher",
"find-next": "Résultat suivant",
"find-prev": "Résultat précédent",
"toggle-wrap": "Retour à la ligne",
"conn-new": "Nouvelle connexion",
"conn-new-stack": "Nouveau groupe",
"conn-save": "Enregistrer la connexion",
"conn-connect": "Se connecter / ouvrir le groupe",
"conn-delete": "Supprimer la connexion ou le groupe",
"conn-rename-stack": "Renommer le groupe",
"conn-import": "Importer depuis WinSCP",
groups: {
panels: "Liste des fichiers",
files: "Fichiers",
sort: "Tri",
tabs: "Onglets",
window: "Fenêtre",
editor: "Éditeur",
connections: "Gestionnaire de connexions",
},
ui: {
filter: "Rechercher un raccourci",
intro: "Cliquez sur + et tapez les touches pour ajouter un raccourci. Le premier est celui qu'affichent les menus et les infobulles.",
add: "Ajouter un raccourci",
remove: "Retirer {key}",
reset: "Rétablir la valeur par défaut",
resetAll: "Tout rétablir",
resetAllConfirm: "Rétablir tous les raccourcis",
press: "Tapez les touches… ({esc} annule)",
unbound: "Aucun raccourci",
reserved: "{key} est réservé par le système",
needsModifier: "Dans l'éditeur, {key} ne ferait que taper : ajoutez un modificateur ou utilisez une touche F",
usedBy: "{key} est déjà utilisé par « {action} »",
replace: "Remplacer",
conflict: "Même touche que {action}",
plainLetter: "Taper {key} dans la liste des fichiers ne la filtre plus",
noResults: "Aucun raccourci ne correspond",
fixed: "Touches fixes",
},
fixed: {
escape: "{esc} ferme les menus, les dialogues et la barre de recherche",
escList: "{esc} dans la liste des fichiers efface le filtre, puis annule une connexion, puis vide la sélection",
dialogs: "{enter} et {esc} valident ou annulent un dialogue ; {tab} et {shiftTab} passent d'un contrôle à l'autre",
menus: "↑ ↓ {home} {end} et {enter} fonctionnent dans tous les menus",
filter: "Taper dans la liste des fichiers la filtre ; {backspace} efface le dernier caractère du filtre",
shift: "Maj avec une touche de déplacement sélectionne les lignes parcourues",
find: "{enter} et {shiftEnter} parcourent les résultats de la recherche",
manager: "↑ ↓ ← → et {enter} parcourent la liste des connexions",
sidebar: "↑ ↓ changent de section ici",
},
names: {
ctrl: "Ctrl",
alt: "Alt",
shift: "Maj",
enter: "Entrée",
backspace: "Retour arrière",
del: "Suppr",
esc: "Échap",
space: "Espace",
tab: "Tab",
pgup: "Pg préc",
pgdn: "Pg suiv",
home: "Début",
end: "Fin",
ins: "Inser",
menu: "Menu",
num: "Num",
},
},
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";
}
+431
View File
@@ -0,0 +1,431 @@
// 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: "新しいタブ",
closeTab: "タブを閉じる",
options: "設定",
update: "アップデートあり",
updating: "更新中",
restartToUpdate: "再起動して適用",
updateTooltip: "アップデートがあります: miniSCP {version}",
},
options: {
title: "設定",
sections: {
appearance: "外観",
transfers: "転送",
editor: "エディタ",
fileList: "ファイル一覧",
keyboard: "キーボード",
about: "情報",
},
columns: "表示する列",
language: "言語",
system: "システム",
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
tone: { label: "トーン", dark: "ダーク", darker: "AMOLED" },
interface: { label: "インターフェース", smaller: "縮小", larger: "拡大", reset: "100%に戻す" },
speedUnit: { label: "速度の単位" },
transfers: { label: "転送", single: "1件ずつ", multi: "複数" },
atOnce: "同時数",
editor: { label: "エディタ", builtin: "内蔵", custom: "カスタム" },
systemDefault: "システムの既定",
resetDefault: "既定に戻す",
finishedTransfers: { label: "完了した転送", autoClear: "自動クリア", keep: "保持" },
openFiles: { label: "Enter / ファイルをダブルクリック", nothing: "何もしない", open: "開く", hint: "ローカルファイルは既定のアプリで開きます。フォルダは常に開きます。" },
},
kinds: {
folder: "フォルダ",
symlink: "シンボリックリンク",
file: "ファイル",
image: "画像",
audio: "オーディオ",
video: "動画",
archive: "アーカイブ",
code: "コード",
document: "ドキュメント",
},
columns: {
name: "名前",
size: "サイズ",
mtime: "更新日時",
kind: "種類",
rights: "権限",
owner: "所有者",
group: "グループ",
},
panel: {
choosePanel: "このパネルに表示する内容を選択",
copy: "送信",
copyToRight: "右のパネルへ送信",
copyToLeft: "左のパネルへ送信",
edit: "編集",
editFile: "ファイルを編集",
rename: "名前を変更",
newFolder: "新規フォルダ",
newFile: "新規ファイル",
permissions: "アクセス権",
delete: "削除",
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: "長い行を折り返す — 行番号を非表示",
findHint: "検索",
findPlaceholder: "ファイル内を検索",
prevMatch: "前の一致",
nextMatch: "次の一致",
closeHint: "閉じる (Esc)",
disconnected: "{name} から切断されました — 保存は失敗しますが、テキストはこのウィンドウに保持されます。",
cantOpen: "{name} を開けません",
closeWindow: "ウィンドウを閉じる",
verbs: { save: "保存", find: "検索", close: "閉じる" },
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}" をゴミ箱に移動しました',
movedManyToTrash: { other: "{count} 個の項目をゴミ箱に移動しました" },
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} について",
settings: "設定…",
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 の「戻す」を利用できません",
},
keys: {
"cursor-up": "上へ",
"cursor-down": "下へ",
"page-up": "前のページ",
"page-down": "次のページ",
"cursor-first": "先頭の項目",
"cursor-last": "最後の項目",
"toggle-select": "選択 / 選択解除",
"select-all": "すべて選択",
open: "開く",
parent: "親フォルダ",
"switch-panel": "パネルを切り替え",
"row-menu": "項目のメニューを表示",
send: "もう一方のパネルへ送信",
edit: "編集",
rename: "名前を変更",
"new-folder": "新規フォルダ",
"new-file": "新規ファイル",
chmod: "アクセス権",
delete: "削除",
trash: "ゴミ箱に移動",
"copy-path": "パスをコピー",
reveal: "ファイルマネージャーで表示",
"open-default": "既定のアプリで開く",
refresh: "更新",
"focus-path": "パスへ移動",
"source-picker": "パネルの表示内容を選択",
"sort-name": "名前で並べ替え",
"sort-size": "サイズで並べ替え",
"sort-mtime": "更新日時で並べ替え",
"sort-kind": "種類で並べ替え",
"sort-rights": "権限で並べ替え",
"sort-owner": "所有者で並べ替え",
"sort-group": "グループで並べ替え",
"new-tab": "新しいタブ",
"close-tab": "タブを閉じる",
"close-other-tabs": "他のタブを閉じる",
"close-tabs-right": "右側のタブを閉じる",
"next-tab": "次のタブ",
"prev-tab": "前のタブ",
"go-to-tab": "タブ {n} へ移動",
"go-to-last-tab": "最後のタブへ移動",
"move-tab-left": "タブを左へ移動",
"move-tab-right": "タブを右へ移動",
"disconnect-tab": "タブを切断",
"open-settings": "設定",
"connection-manager": "接続マネージャー",
"toggle-transfers": "転送の表示 / 非表示",
"install-update": "アップデートをインストール",
"scale-up": "インターフェースを拡大",
"scale-down": "インターフェースを縮小",
"scale-reset": "インターフェースを 100% に",
"close-window": "ウインドウを閉じる",
save: "保存",
find: "検索",
"find-next": "次を検索",
"find-prev": "前を検索",
"toggle-wrap": "行を折り返す",
"conn-new": "新規接続",
"conn-new-stack": "新規グループ",
"conn-save": "接続を保存",
"conn-connect": "接続 / グループを開く",
"conn-delete": "接続またはグループを削除",
"conn-rename-stack": "グループ名を変更",
"conn-import": "WinSCP からインポート",
groups: {
panels: "ファイル一覧",
files: "ファイル",
sort: "並べ替え",
tabs: "タブ",
window: "ウインドウ",
editor: "エディタ",
connections: "接続マネージャー",
},
ui: {
filter: "ショートカットを検索",
intro: "+ を押してキーを入力するとショートカットを追加できます。最初のものがメニューとツールチップに表示されます。",
add: "ショートカットを追加",
remove: "{key} を削除",
reset: "既定に戻す",
resetAll: "すべて戻す",
resetAllConfirm: "すべてのショートカットを戻す",
press: "キーを押してください…({esc} でキャンセル)",
unbound: "ショートカットなし",
reserved: "{key} はシステムで予約されています",
needsModifier: "エディタでは {key} は入力されるだけです。修飾キーを加えるか F キーを使ってください",
usedBy: "{key} はすでに「{action}」で使われています",
replace: "置き換える",
conflict: "{action} と同じキー",
plainLetter: "ファイル一覧で {key} を入力しても絞り込まれなくなります",
noResults: "一致するショートカットはありません",
fixed: "固定キー",
},
fixed: {
escape: "{esc} でメニュー、ダイアログ、検索バーを閉じます",
escList: "ファイル一覧の {esc} は、絞り込みの解除、接続のキャンセル、選択の解除の順に働きます",
dialogs: "{enter} と {esc} でダイアログを確定 / キャンセルし、{tab} と {shiftTab} でコントロール間を移動します",
menus: "↑ ↓ {home} {end} と {enter} はすべてのメニューで使えます",
filter: "ファイル一覧で入力すると絞り込まれます。{backspace} で最後の文字を消します",
shift: "Shift と移動キーで、カーソルが通過した行を選択します",
find: "{enter} と {shiftEnter} で検索結果を移動します",
manager: "↑ ↓ ← → と {enter} で接続一覧を移動します",
sidebar: "↑ ↓ でここのセクションを切り替えます",
},
names: {
ctrl: "Ctrl",
alt: "Alt",
shift: "Shift",
enter: "Enter",
backspace: "Backspace",
del: "Del",
esc: "Esc",
space: "スペース",
tab: "Tab",
pgup: "PgUp",
pgdn: "PgDn",
home: "Home",
end: "End",
ins: "Ins",
menu: "メニュー",
num: "Num",
},
},
errors: {
sshAgentUnavailable: "ssh-agent が利用できません(SSH_AUTH_SOCK が未設定)",
identityFileNotFound: "認証鍵ファイルが見つかりません: {path}",
sessionNotConnected: 'セッション "{id}" は接続されていません',
localCannotDisconnect: "ローカルセッションは切断できません",
notADirectory: '"{path}" は存在し、フォルダではありません',
sameFileTransfer: '"{name}": コピー元とコピー先が同じファイルです',
sameFileNoName: "コピー元とコピー先が同じファイルです",
folderOntoItself: "フォルダをそれ自身の上にコピーすることはできません",
folderIntoItself: "フォルダをそれ自身の中にコピーすることはできません",
},
} as const;
+431
View File
@@ -0,0 +1,431 @@
// 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: "新建标签页",
closeTab: "关闭标签页",
options: "设置",
update: "有可用更新",
updating: "正在更新",
restartToUpdate: "重启以应用",
updateTooltip: "有可用更新:miniSCP {version}",
},
options: {
title: "设置",
sections: {
appearance: "外观",
transfers: "传输",
editor: "编辑器",
fileList: "文件列表",
keyboard: "键盘",
about: "关于",
},
columns: "显示的列",
language: "语言",
system: "系统",
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
tone: { label: "色调", dark: "深色", darker: "AMOLED" },
interface: { label: "界面", smaller: "缩小", larger: "放大", reset: "重置为100%" },
speedUnit: { label: "速度单位" },
transfers: { label: "传输", single: "单个", multi: "多个" },
atOnce: "同时",
editor: { label: "编辑器", builtin: "内置", custom: "自定义" },
systemDefault: "系统默认",
resetDefault: "恢复为系统默认",
finishedTransfers: { label: "已完成传输", autoClear: "自动清除", keep: "保留" },
openFiles: { label: "回车 / 双击文件", nothing: "不执行操作", open: "打开", hint: "本地文件用默认应用打开;文件夹始终打开。" },
},
kinds: {
folder: "文件夹",
symlink: "符号链接",
file: "文件",
image: "图像",
audio: "音频",
video: "视频",
archive: "压缩包",
code: "代码",
document: "文档",
},
columns: {
name: "名称",
size: "大小",
mtime: "修改时间",
kind: "类型",
rights: "权限",
owner: "所有者",
group: "组",
},
panel: {
choosePanel: "选择此面板显示的内容",
copy: "发送",
copyToRight: "发送到右侧面板",
copyToLeft: "发送到左侧面板",
edit: "编辑",
editFile: "编辑文件",
rename: "重命名",
newFolder: "新建文件夹",
newFile: "新建文件",
permissions: "权限",
delete: "删除",
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: "长行自动换行 — 隐藏行号",
findHint: "查找",
findPlaceholder: "在文件中查找",
prevMatch: "上一个匹配",
nextMatch: "下一个匹配",
closeHint: "关闭 (Esc)",
disconnected: "已从 {name} 断开 — 保存将失败,但你的文本会保留在此窗口中。",
cantOpen: "无法打开 {name}",
closeWindow: "关闭窗口",
verbs: { save: "保存", find: "查找", close: "关闭" },
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}" 移到废纸篓',
movedManyToTrash: { other: "已将 {count} 个项目移到废纸篓" },
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}",
settings: "设置…",
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: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
},
keys: {
"cursor-up": "上移",
"cursor-down": "下移",
"page-up": "上一页",
"page-down": "下一页",
"cursor-first": "第一项",
"cursor-last": "最后一项",
"toggle-select": "选择 / 取消选择",
"select-all": "全选",
open: "打开",
parent: "上级目录",
"switch-panel": "切换面板",
"row-menu": "显示该项的菜单",
send: "发送到另一个面板",
edit: "编辑",
rename: "重命名",
"new-folder": "新建文件夹",
"new-file": "新建文件",
chmod: "权限",
delete: "删除",
trash: "移到废纸篓",
"copy-path": "复制路径",
reveal: "在文件管理器中显示",
"open-default": "用默认应用打开",
refresh: "刷新",
"focus-path": "转到路径",
"source-picker": "选择面板显示的内容",
"sort-name": "按名称排序",
"sort-size": "按大小排序",
"sort-mtime": "按修改时间排序",
"sort-kind": "按种类排序",
"sort-rights": "按权限排序",
"sort-owner": "按所有者排序",
"sort-group": "按组排序",
"new-tab": "新建标签页",
"close-tab": "关闭标签页",
"close-other-tabs": "关闭其他标签页",
"close-tabs-right": "关闭右侧标签页",
"next-tab": "下一个标签页",
"prev-tab": "上一个标签页",
"go-to-tab": "转到标签页 {n}",
"go-to-last-tab": "转到最后一个标签页",
"move-tab-left": "标签页左移",
"move-tab-right": "标签页右移",
"disconnect-tab": "断开标签页",
"open-settings": "设置",
"connection-manager": "连接管理器",
"toggle-transfers": "显示 / 隐藏传输",
"install-update": "安装更新",
"scale-up": "放大界面",
"scale-down": "缩小界面",
"scale-reset": "界面 100%",
"close-window": "关闭窗口",
save: "保存",
find: "查找",
"find-next": "查找下一个",
"find-prev": "查找上一个",
"toggle-wrap": "自动换行",
"conn-new": "新建连接",
"conn-new-stack": "新建分组",
"conn-save": "保存连接",
"conn-connect": "连接 / 打开分组",
"conn-delete": "删除连接或分组",
"conn-rename-stack": "重命名分组",
"conn-import": "从 WinSCP 导入",
groups: {
panels: "文件列表",
files: "文件",
sort: "排序",
tabs: "标签页",
window: "窗口",
editor: "编辑器",
connections: "连接管理器",
},
ui: {
filter: "搜索快捷键",
intro: "点击 + 并按下按键即可添加快捷键。第一个会显示在菜单和提示中。",
add: "添加快捷键",
remove: "移除 {key}",
reset: "恢复默认",
resetAll: "全部恢复",
resetAllConfirm: "恢复所有快捷键",
press: "请按键…({esc} 取消)",
unbound: "无快捷键",
reserved: "{key} 已被系统保留",
needsModifier: "在编辑器中 {key} 只会输入字符:请加上修饰键或使用 F 键",
usedBy: "{key} 已被“{action}”使用",
replace: "替换",
conflict: "与 {action} 的按键相同",
plainLetter: "在文件列表中输入 {key} 将不再进行筛选",
noResults: "没有匹配的快捷键",
fixed: "固定按键",
},
fixed: {
escape: "{esc} 关闭菜单、对话框和查找栏",
escList: "在文件列表中按 {esc} 依次清除筛选、取消连接、清除选择",
dialogs: "{enter} 和 {esc} 确认或取消对话框;{tab} 和 {shiftTab} 在其控件间移动",
menus: "↑ ↓ {home} {end} 和 {enter} 适用于所有菜单",
filter: "在文件列表中输入即可筛选;{backspace} 删除筛选的最后一个字符",
shift: "Shift 加移动键会选中光标经过的行",
find: "{enter} 和 {shiftEnter} 在查找结果间跳转",
manager: "↑ ↓ ← → 和 {enter} 在连接列表中移动",
sidebar: "↑ ↓ 在这些分区间切换",
},
names: {
ctrl: "Ctrl",
alt: "Alt",
shift: "Shift",
enter: "Enter",
backspace: "退格",
del: "Del",
esc: "Esc",
space: "空格",
tab: "Tab",
pgup: "PgUp",
pgdn: "PgDn",
home: "Home",
end: "End",
ins: "Ins",
menu: "菜单",
num: "Num",
},
},
errors: {
sshAgentUnavailable: "ssh-agent 不可用(未设置 SSH_AUTH_SOCK)",
identityFileNotFound: "找不到身份文件:{path}",
sessionNotConnected: '会话 "{id}" 未连接',
localCannotDisconnect: "无法断开本地会话",
notADirectory: '"{path}" 已存在且不是文件夹',
sameFileTransfer: '"{name}":源和目标是同一个文件',
sameFileNoName: "源和目标是同一个文件",
folderOntoItself: "无法将文件夹复制到其自身之上",
folderIntoItself: "无法将文件夹复制到其自身内部",
},
} as const;
+221
View File
@@ -0,0 +1,221 @@
import { expect, test } from "bun:test";
import { DICTS } from "./i18n";
import { en } from "./i18n/en";
import {
BINDINGS,
type Combo,
comboFromEvent,
conflicts,
EN_KEY_NAMES,
effectiveKeymap,
firesInTextField,
formatCombo,
GROUPS,
isReserved,
type KeyEventLike,
matchesEvent,
NATIVE_MENU_IDS,
parseCombo,
serializeCombo,
scopesOverlap,
toAccelerator,
} from "./keymap";
const US_CHARS: Record<string, string> = {
Comma: ",",
Period: ".",
Slash: "/",
Semicolon: ";",
Quote: "'",
BracketLeft: "[",
BracketRight: "]",
Backslash: "\\",
Backquote: "`",
Equal: "=",
Minus: "-",
NumpadAdd: "+",
};
/** The keydown a US keyboard produces for `c`. */
function eventFor(c: Combo, isMac: boolean): KeyEventLike {
let key = c.key;
let code = c.key;
if (/^[A-Z]$/.test(c.key)) {
key = c.shift ? c.key : c.key.toLowerCase();
code = `Key${c.key}`;
} else if (/^[0-9]$/.test(c.key)) {
code = `Digit${c.key}`;
} else if (c.key in US_CHARS) {
key = US_CHARS[c.key];
} else if (c.key === "Space") {
key = " ";
}
return {
key,
code,
metaKey: isMac && c.mod,
ctrlKey: isMac ? c.ctrl : c.mod,
altKey: c.alt,
shiftKey: c.shift,
};
}
const ev = (over: Partial<KeyEventLike>): KeyEventLike => ({
key: "",
code: "",
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: false,
...over,
});
const combo = (text: string, isMac: boolean) => {
const c = parseCombo(text, isMac);
if (!c) throw new Error(`unparsable ${text}`);
return c;
};
for (const isMac of [true, false]) {
const os = isMac ? "macOS" : "Windows/Linux";
const keymap = effectiveKeymap({}, isMac);
test(`${os}: the defaults have no conflicts`, () => {
expect([...conflicts(keymap).entries()]).toEqual([]);
});
test(`${os}: every default round-trips and resolves to its own command`, () => {
for (const binding of keymap) {
for (const c of binding.combos) {
expect(parseCombo(serializeCombo(c), isMac)).toEqual(c);
const event = eventFor(c, isMac);
// table order is resolution order: nothing earlier may shadow it
const hit = keymap.find(
(other) => scopesOverlap(other.scopes, binding.scopes) && other.combos.some((oc) => matchesEvent(oc, event, isMac)),
);
expect(`${serializeCombo(c)} → ${hit?.id}`).toBe(`${serializeCombo(c)} → ${binding.id}`);
expect(comboFromEvent(event, isMac)).toEqual(c);
}
}
});
test(`${os}: no default is a reserved combo`, () => {
for (const binding of keymap) for (const c of binding.combos) expect(isReserved(c, isMac)).toBe(false);
});
test(`${os}: every macOS menu command has an expressible accelerator`, () => {
if (!isMac) return;
for (const id of NATIVE_MENU_IDS) {
const binding = keymap.find((b) => b.id === id)!;
expect(binding.combos.some((c) => toAccelerator(c) !== null)).toBe(true);
}
});
}
test("every command and group is labelled — in every shipped language", () => {
for (const [locale, dict] of Object.entries(DICTS)) {
const keys = dict.keys as Record<string, unknown> & { groups: Record<string, unknown> };
for (const b of BINDINGS) {
expect(`${locale}:${b.id}:${typeof keys["n" in b ? "go-to-tab" : b.id]}`).toBe(`${locale}:${b.id}:string`);
}
for (const g of GROUPS) expect(`${locale}:${g}:${typeof keys.groups[g]}`).toBe(`${locale}:${g}:string`);
}
expect(typeof en.keys.ui.press).toBe("string");
});
test("matching is exact about modifiers", () => {
// ⌃F is Cocoa's forward-one-character, not ⌘F
expect(matchesEvent(combo("Mod+F", true), ev({ key: "f", code: "KeyF", ctrlKey: true }), true)).toBe(false);
expect(matchesEvent(combo("Mod+F", true), ev({ key: "f", code: "KeyF", metaKey: true }), true)).toBe(true);
// Ctrl+Tab is not Tab (the old panel switch caught both)
expect(matchesEvent(combo("Tab", false), ev({ key: "Tab", code: "Tab", ctrlKey: true }), false)).toBe(false);
// the Windows key is never part of a binding
expect(matchesEvent(combo("Mod+T", false), ev({ key: "t", code: "KeyT", ctrlKey: true, metaKey: true }), false)).toBe(false);
});
test("letters follow the layout, Alt chords and other scripts use the physical key", () => {
// AZERTY: the comma sits on the M key, the A on the Q key
expect(comboFromEvent(ev({ key: ",", code: "KeyM", metaKey: true }), true)).toEqual(combo("Mod+Comma", true));
expect(comboFromEvent(ev({ key: "a", code: "KeyQ", ctrlKey: true }), false)).toEqual(combo("Mod+A", false));
// macOS Option turns ⌥Z into Ω
expect(comboFromEvent(ev({ key: "Ω", code: "KeyZ", altKey: true }), true)).toEqual(combo("Alt+Z", true));
// Cyrillic ⌘T arrives as "е" on the T key
expect(matchesEvent(combo("Mod+T", true), ev({ key: "е", code: "KeyT", metaKey: true }), true)).toBe(true);
// US ⌘+ is ⇧⌘= — matched by the physical key
expect(matchesEvent(combo("Mod+Shift+Equal", true), ev({ key: "+", code: "Equal", metaKey: true, shiftKey: true }), true)).toBe(true);
// AZERTY ⌘1 types "&" on the 1 key
expect(comboFromEvent(ev({ key: "&", code: "Digit1", metaKey: true }), true)).toEqual(combo("Mod+1", true));
// a bare modifier press is not a combo
expect(comboFromEvent(ev({ key: "Shift", code: "ShiftLeft", shiftKey: true }), true)).toBeNull();
});
test("formatting per platform, with localized key names off macOS", () => {
expect(formatCombo(combo("Mod+Shift+N", true), true)).toBe("⇧⌘N");
expect(formatCombo(combo("Ctrl+Tab", true), true)).toBe("⌃⇥");
expect(formatCombo(combo("Mod+Alt+C", true), true)).toBe("⌥⌘C");
expect(formatCombo(combo("Mod+Comma", true), true)).toBe("⌘,");
expect(formatCombo(combo("Shift+F10", true), true)).toBe("⇧F10");
expect(formatCombo(combo("Mod+Backspace", true), true)).toBe("⌘⌫");
expect(formatCombo(combo("Mod+Shift+N", false), false)).toBe("Ctrl+Shift+N");
expect(formatCombo(combo("Alt+Shift+R", false), false)).toBe("Alt+Shift+R");
expect(formatCombo(combo("Delete", false), false)).toBe("Del");
const de = { ...EN_KEY_NAMES, ctrl: "Strg", del: "Entf" };
expect(formatCombo(combo("Mod+Delete", false), false, de)).toBe("Strg+Entf");
});
test("overrides replace a row; garbage is dropped", () => {
const keymap = effectiveKeymap(
{ rename: ["Mod+Shift+R", "nonsense", 5, "Mod+Shift+R"], save: "F9", bogus: ["F1"], delete: [] },
true,
);
const by = (id: string) => keymap.find((b) => b.id === id)!;
expect(by("rename").combos.map(serializeCombo)).toEqual(["Mod+Shift+R"]);
expect(by("rename").custom).toBe(true);
// not an array: ignored, the defaults stand
expect(by("save").custom).toBe(false);
expect(by("save").combos.map(serializeCombo)).toEqual(["Mod+S"]);
// [] unbinds
expect(by("delete").combos).toEqual([]);
expect(keymap.some((b) => (b.id as string) === "bogus")).toBe(false);
// not an object at all
expect(effectiveKeymap("junk", true).every((b) => !b.custom)).toBe(true);
});
test("off macOS a stored literal Ctrl folds into Mod", () => {
expect(parseCombo("Ctrl+Tab", false)).toEqual(parseCombo("Mod+Tab", false));
expect(parseCombo("Ctrl+Tab", true)).not.toEqual(parseCombo("Mod+Tab", true));
});
test("native menu accelerators", () => {
expect(toAccelerator(combo("Mod+Shift+N", true))).toBe("Command+Shift+n");
expect(toAccelerator(combo("Mod+Comma", true))).toBe("Command+,");
expect(toAccelerator(combo("Mod+Minus", true))).toBe("Command+minus");
expect(toAccelerator(combo("Ctrl+Shift+PageUp", true))).toBe("Control+Shift+pageup");
// a bare key would mean ⌘+key to the native parser
expect(toAccelerator(combo("F5", true))).toBeNull();
// ⌫ / ⌦, Shift+punctuation and modifier-less combos have no faithful form
expect(toAccelerator(combo("Mod+Delete", true))).toBeNull();
expect(toAccelerator(combo("Mod+Backspace", true))).toBeNull();
expect(toAccelerator(combo("Mod+Shift+BracketRight", true))).toBeNull();
expect(toAccelerator(combo("Shift+F4", true))).toBeNull();
});
test("text fields keep their own keys", () => {
// plain keys and text-editing chords type / edit
expect(firesInTextField(combo("Delete", true), true)).toBe(false);
expect(firesInTextField(combo("Mod+Backspace", true), true)).toBe(false);
expect(firesInTextField(combo("Mod+A", true), true)).toBe(false);
expect(firesInTextField(combo("Alt+ArrowLeft", true), true)).toBe(false);
// app chords and F-keys still fire (⌘S in the editor, ⌘↩ in the manager form)
expect(firesInTextField(combo("Mod+S", true), true)).toBe(true);
expect(firesInTextField(combo("Mod+Enter", false), false)).toBe(true);
expect(firesInTextField(combo("F3", false), false)).toBe(true);
// Ctrl+Alt off macOS is AltGr typing a character
expect(firesInTextField(combo("Mod+Alt+Q", false), false)).toBe(false);
});
test("AltGr is never a shortcut off macOS", () => {
const altGrQ = ev({ key: "@", code: "KeyQ", ctrlKey: true, altKey: true, getModifierState: (k) => k === "AltGraph" });
expect(matchesEvent(combo("Mod+Alt+Q", false), altGrQ, false)).toBe(false);
expect(comboFromEvent(altGrQ, false)).toBeNull();
});
+553
View File
@@ -0,0 +1,553 @@
// The app's one keymap: every keyboard command, where it fires and its default
// keys per platform, plus the pure helpers that parse, match, format and check
// key combos. Shared on purpose: the webviews resolve keys, show hints and edit
// remaps against this table, and bun builds the macOS menu-bar accelerators
// from it, so the two can never disagree. User remaps are stored as overrides
// in AppSettings.keybindings and applied by effectiveKeymap().
/** Where a binding fires. `panel` = the focused file list, `main` = anywhere
* in the main window, `any` = every window. `panel` sits inside `main`, so
* the two overlap for conflict purposes. */
export type Scope = "panel" | "main" | "editor" | "manager" | "settings" | "any";
/** Settings → Keyboard sections, in display order. */
export const GROUPS = ["panels", "files", "sort", "tabs", "window", "editor", "connections"] as const;
export type Group = (typeof GROUPS)[number];
interface BindingDef {
readonly id: string;
readonly group: Group;
readonly scopes: readonly Scope[];
/** Default combos, best first: the first is the one menus and tooltips show. */
readonly mac: readonly string[];
readonly other: readonly string[];
/** go-to-tab-N: the N its label interpolates. */
readonly n?: number;
}
const both = (...combos: string[]) => ({ mac: combos, other: combos });
/**
* Every command, in Settings display order within its group. Combo strings are
* platform-neutral (see parseCombo): `Mod` is ⌘ on macOS and Ctrl elsewhere,
* `Ctrl` a literal ⌃ that only macOS tells apart.
*/
export const BINDINGS = [
// ---- panels: the focused file list ----
{ id: "cursor-up", group: "panels", scopes: ["panel"], ...both("ArrowUp") },
{ id: "cursor-down", group: "panels", scopes: ["panel"], ...both("ArrowDown") },
{ id: "page-up", group: "panels", scopes: ["panel"], ...both("PageUp") },
{ id: "page-down", group: "panels", scopes: ["panel"], ...both("PageDown") },
{ id: "cursor-first", group: "panels", scopes: ["panel"], ...both("Home") },
{ id: "cursor-last", group: "panels", scopes: ["panel"], ...both("End") },
{ id: "toggle-select", group: "panels", scopes: ["panel"], mac: ["Space"], other: ["Space", "Insert"] },
{ id: "select-all", group: "panels", scopes: ["panel"], ...both("Mod+A") },
{ id: "open", group: "panels", scopes: ["panel"], mac: ["Enter", "Mod+ArrowDown"], other: ["Enter"] },
{ id: "parent", group: "panels", scopes: ["panel"], mac: ["Backspace", "Mod+ArrowUp"], other: ["Backspace", "Alt+ArrowUp"] },
{ id: "switch-panel", group: "panels", scopes: ["panel"], ...both("Tab", "Shift+Tab") },
{ id: "row-menu", group: "panels", scopes: ["panel"], mac: ["Shift+F10", "Ctrl+Enter"], other: ["Shift+F10", "ContextMenu"] },
// ---- files: act on the focused panel's selection, else its cursor row ----
{ id: "send", group: "files", scopes: ["panel"], ...both("F5") },
{ id: "edit", group: "files", scopes: ["panel"], mac: ["Mod+E", "F4"], other: ["F4", "Mod+E"] },
{ id: "rename", group: "files", scopes: ["panel"], ...both("F2") },
{ id: "new-folder", group: "files", scopes: ["panel"], mac: ["Mod+Shift+N", "F7"], other: ["F7", "Mod+Shift+N"] },
{ id: "new-file", group: "files", scopes: ["panel"], mac: ["Mod+N", "Shift+F4"], other: ["Shift+F4", "Mod+N"] },
{ id: "chmod", group: "files", scopes: ["panel"], ...both("Mod+I") },
{ id: "delete", group: "files", scopes: ["panel"], ...both("F8", "Delete") },
{ id: "trash", group: "files", scopes: ["panel"], mac: ["Mod+Backspace"], other: ["Mod+Delete"] },
{ id: "copy-path", group: "files", scopes: ["panel"], mac: ["Mod+Alt+C"], other: ["Mod+Shift+C"] },
{ id: "reveal", group: "files", scopes: ["panel"], mac: ["Mod+Alt+R"], other: ["Alt+Shift+R"] },
{ id: "open-default", group: "files", scopes: ["panel"], ...both("Mod+O") },
{ id: "refresh", group: "files", scopes: ["panel"], ...both("Mod+R") },
{ id: "focus-path", group: "files", scopes: ["panel"], ...both("Mod+L") },
{ id: "source-picker", group: "files", scopes: ["panel"], ...both("Mod+K") },
// ---- sort the focused panel (again = reverse) ----
{ id: "sort-name", group: "sort", scopes: ["panel"], ...both("Alt+1") },
{ id: "sort-size", group: "sort", scopes: ["panel"], ...both("Alt+2") },
{ id: "sort-mtime", group: "sort", scopes: ["panel"], ...both("Alt+3") },
{ id: "sort-kind", group: "sort", scopes: ["panel"], ...both("Alt+4") },
{ id: "sort-rights", group: "sort", scopes: ["panel"], ...both("Alt+5") },
{ id: "sort-owner", group: "sort", scopes: ["panel"], ...both("Alt+6") },
{ id: "sort-group", group: "sort", scopes: ["panel"], ...both("Alt+7") },
// ---- tabs (Ctrl+Tab is a literal ⌃ on macOS: ⌘Tab is the app switcher) ----
{ id: "new-tab", group: "tabs", scopes: ["main"], ...both("Mod+T") },
{ id: "close-tab", group: "tabs", scopes: ["main"], ...both("Mod+W") },
// Ctrl+Alt+letter is AltGr typing on many non-mac layouts — never a default there
{ id: "close-other-tabs", group: "tabs", scopes: ["main"], mac: ["Mod+Alt+W"], other: ["Mod+Shift+W"] },
{ id: "close-tabs-right", group: "tabs", scopes: ["main"], ...both() },
{ id: "next-tab", group: "tabs", scopes: ["main"], mac: ["Ctrl+Tab", "Mod+Shift+BracketRight"], other: ["Ctrl+Tab", "Mod+PageDown"] },
{ id: "prev-tab", group: "tabs", scopes: ["main"], mac: ["Ctrl+Shift+Tab", "Mod+Shift+BracketLeft"], other: ["Ctrl+Shift+Tab", "Mod+PageUp"] },
{ id: "go-to-tab-1", group: "tabs", scopes: ["main"], n: 1, ...both("Mod+1") },
{ id: "go-to-tab-2", group: "tabs", scopes: ["main"], n: 2, ...both("Mod+2") },
{ id: "go-to-tab-3", group: "tabs", scopes: ["main"], n: 3, ...both("Mod+3") },
{ id: "go-to-tab-4", group: "tabs", scopes: ["main"], n: 4, ...both("Mod+4") },
{ id: "go-to-tab-5", group: "tabs", scopes: ["main"], n: 5, ...both("Mod+5") },
{ id: "go-to-tab-6", group: "tabs", scopes: ["main"], n: 6, ...both("Mod+6") },
{ id: "go-to-tab-7", group: "tabs", scopes: ["main"], n: 7, ...both("Mod+7") },
{ id: "go-to-tab-8", group: "tabs", scopes: ["main"], n: 8, ...both("Mod+8") },
{ id: "go-to-last-tab", group: "tabs", scopes: ["main"], ...both("Mod+9") },
{ id: "move-tab-left", group: "tabs", scopes: ["main"], mac: ["Ctrl+Shift+PageUp"], other: ["Mod+Shift+PageUp"] },
{ id: "move-tab-right", group: "tabs", scopes: ["main"], mac: ["Ctrl+Shift+PageDown"], other: ["Mod+Shift+PageDown"] },
{ id: "disconnect-tab", group: "tabs", scopes: ["main"], ...both("Mod+D") },
// ---- window ----
{ id: "open-settings", group: "window", scopes: ["main"], ...both("Mod+Comma") },
{ id: "connection-manager", group: "window", scopes: ["main"], ...both("Mod+Shift+K") },
{ id: "toggle-transfers", group: "window", scopes: ["main"], ...both("Mod+J") },
{ id: "install-update", group: "window", scopes: ["main"], ...both() },
// ⌘+ is ⇧⌘= on US layouts — both spellings, plus the keypad
{ id: "scale-up", group: "window", scopes: ["any"], ...both("Mod+Equal", "Mod+Shift+Equal", "Mod+NumpadAdd") },
{ id: "scale-down", group: "window", scopes: ["any"], ...both("Mod+Minus") },
{ id: "scale-reset", group: "window", scopes: ["any"], ...both("Mod+0") },
{ id: "close-window", group: "window", scopes: ["editor", "manager", "settings"], ...both("Mod+W") },
// ---- editor window ----
{ id: "save", group: "editor", scopes: ["editor"], ...both("Mod+S") },
{ id: "find", group: "editor", scopes: ["editor"], ...both("Mod+F") },
{ id: "find-next", group: "editor", scopes: ["editor"], mac: ["Mod+G"], other: ["F3"] },
{ id: "find-prev", group: "editor", scopes: ["editor"], mac: ["Mod+Shift+G"], other: ["Shift+F3"] },
{ id: "toggle-wrap", group: "editor", scopes: ["editor"], ...both("Alt+Z") },
// ---- connection manager ----
{ id: "conn-new", group: "connections", scopes: ["manager"], ...both("Mod+N") },
{ id: "conn-new-stack", group: "connections", scopes: ["manager"], ...both("Mod+Shift+N") },
{ id: "conn-save", group: "connections", scopes: ["manager"], ...both("Mod+S") },
{ id: "conn-connect", group: "connections", scopes: ["manager"], ...both("Mod+Enter") },
{ id: "conn-delete", group: "connections", scopes: ["manager"], mac: ["Mod+Backspace"], other: ["Delete"] },
{ id: "conn-rename-stack", group: "connections", scopes: ["manager"], ...both("F2") },
{ id: "conn-import", group: "connections", scopes: ["manager"], ...both("Mod+O") },
] as const satisfies readonly BindingDef[];
export type ShortcutId = (typeof BINDINGS)[number]["id"];
/** Commands the macOS menu bar carries; their accelerators follow the keymap. */
export const NATIVE_MENU_IDS: readonly ShortcutId[] = [
"open-settings",
"new-tab",
"close-tab",
"new-folder",
"new-file",
"edit",
"connection-manager",
"refresh",
];
export interface Combo {
/** A–Z, 0–9, a punctuation event.code (Comma, Equal…) or a key name (Enter, F2…). */
key: string;
/** ⌘ on macOS, Ctrl elsewhere. */
mod: boolean;
/** A literal ⌃, macOS only — elsewhere Ctrl IS Mod. */
ctrl: boolean;
alt: boolean;
shift: boolean;
}
/** The KeyboardEvent fields matching needs (tests build these by hand). */
export interface KeyEventLike {
key: string;
code: string;
metaKey: boolean;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
getModifierState?(key: string): boolean;
}
export interface ResolvedBinding {
id: ShortcutId;
group: Group;
scopes: readonly Scope[];
n?: number;
/** What fires: the user's override when there is one, else the defaults. */
combos: Combo[];
defaults: Combo[];
/** The user replaced this row's defaults (possibly with nothing). */
custom: boolean;
}
const LETTER = /^[A-Z]$/;
const DIGIT = /^[0-9]$/;
const FKEY = /^F([1-9]|1[0-2])$/;
/** Punctuation tokens and the character each stands for. Matched by character
* first (AZERTY's comma is on the M key) and by physical key second (⇧] makes
* "}", the US ⌘+ is ⇧⌘=). */
const CHAR_OF: Record<string, string> = {
Comma: ",",
Period: ".",
Slash: "/",
Semicolon: ";",
Quote: "'",
BracketLeft: "[",
BracketRight: "]",
Backslash: "\\",
Backquote: "`",
Equal: "=",
Minus: "-",
};
const TOKEN_OF_CHAR: Record<string, string> = Object.fromEntries(
Object.entries(CHAR_OF).map(([token, char]) => [char, token]),
);
/** Physical-only keys: the keypad's + (its − already types "-", which is Minus). */
const KEYPAD = new Set(["NumpadAdd"]);
/** KeyboardEvent.key names, the same on every layout. */
const NAMED = new Set([
"Enter",
"Escape",
"Backspace",
"Delete",
"Tab",
"Space",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"Home",
"End",
"PageUp",
"PageDown",
"Insert",
"ContextMenu",
]);
function isToken(key: string): boolean {
return (
LETTER.test(key) || DIGIT.test(key) || FKEY.test(key) || Object.hasOwn(CHAR_OF, key) || KEYPAD.has(key) || NAMED.has(key)
);
}
/**
* An event's key token, or null for modifier-only presses and keys the keymap
* doesn't cover. Letters follow the layout (AZERTY's A is A); under Alt
* (macOS Option turns ⌥Z into Ω) and on non-Latin layouts they fall back to the
* physical key. Digits are physical too: AZERTY types "&" on the 1 key.
*/
export function keyToken(e: Pick<KeyEventLike, "key" | "code" | "altKey">): string | null {
const { key, code } = e;
const upper = key.length === 1 ? key.toUpperCase() : "";
if (!e.altKey && LETTER.test(upper)) return upper;
if (/^Key[A-Z]$/.test(code) && (e.altKey || /\p{L}/u.test(key))) return code.slice(3);
if (/^Digit[0-9]$/.test(code)) return code.slice(5);
if (/^Numpad[0-9]$/.test(code)) return DIGIT.test(key) ? key : NAMED.has(key) ? key : null;
if (Object.hasOwn(TOKEN_OF_CHAR, key)) return TOKEN_OF_CHAR[key];
if (Object.hasOwn(CHAR_OF, code) || KEYPAD.has(code)) return code;
if (key === " " || code === "Space") return "Space";
if (FKEY.test(key) || NAMED.has(key)) return key;
return null;
}
function eventMods(e: KeyEventLike, isMac: boolean) {
return isMac
? { mod: e.metaKey, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, other: false }
: // never part of a binding: the Windows / Super key, and AltGr — which
// arrives as Ctrl+Alt while it types "@", "€", "{"…
{
mod: e.ctrlKey,
ctrl: false,
alt: e.altKey,
shift: e.shiftKey,
other: e.metaKey || !!e.getModifierState?.("AltGraph"),
};
}
/** Exact match: the same key AND exactly the same modifiers. ⌃ is not ⌘ on
* macOS (⌃F is Cocoa's forward-one-character), and Ctrl+Tab is not Tab. */
export function matchesEvent(combo: Combo, e: KeyEventLike, isMac: boolean): boolean {
const m = eventMods(e, isMac);
if (m.other || m.mod !== combo.mod || m.ctrl !== combo.ctrl || m.alt !== combo.alt || m.shift !== combo.shift) {
return false;
}
return keyToken(e) === combo.key || ((Object.hasOwn(CHAR_OF, combo.key) || KEYPAD.has(combo.key)) && e.code === combo.key);
}
/** The combo a keydown spells (Settings → Keyboard recording), or null. */
export function comboFromEvent(e: KeyEventLike, isMac: boolean): Combo | null {
const key = keyToken(e);
const m = eventMods(e, isMac);
if (!key || m.other) return null;
return { key, mod: m.mod, ctrl: m.ctrl, alt: m.alt, shift: m.shift };
}
/** "Mod+Shift+N" → Combo; null for anything malformed. Off macOS a stored
* literal Ctrl folds into Mod — on those keyboards they are the same key. */
export function parseCombo(text: unknown, isMac: boolean): Combo | null {
if (typeof text !== "string" || !text) return null;
const parts = text.split("+");
let key = parts.pop() ?? "";
if (key.length === 1) key = key.toUpperCase();
if (!isToken(key)) return null;
const combo: Combo = { key, mod: false, ctrl: false, alt: false, shift: false };
for (const part of parts) {
const name = part.toLowerCase();
if (name === "mod") combo.mod = true;
else if (name === "ctrl") combo.ctrl = true;
else if (name === "alt") combo.alt = true;
else if (name === "shift") combo.shift = true;
else return null;
}
if (!isMac && combo.ctrl) {
combo.ctrl = false;
combo.mod = true;
}
return combo;
}
export function serializeCombo(c: Combo): string {
return [c.mod && "Mod", c.ctrl && "Ctrl", c.alt && "Alt", c.shift && "Shift", c.key].filter(Boolean).join("+");
}
export function sameCombo(a: Combo, b: Combo): boolean {
return serializeCombo(a) === serializeCombo(b);
}
function parseList(list: readonly unknown[], isMac: boolean): Combo[] {
const out: Combo[] = [];
for (const text of list) {
const combo = parseCombo(text, isMac);
if (combo && !out.some((c) => sameCombo(c, combo))) out.push(combo);
}
return out;
}
/**
* The table with the user's overrides applied — the single place a hand-edited
* settings.json is sanitized: unknown ids, non-arrays and malformed combos are
* dropped. An override replaces a row's whole list; [] unbinds it.
*/
export function effectiveKeymap(overrides: unknown, isMac: boolean): ResolvedBinding[] {
const table: Record<string, unknown> =
overrides && typeof overrides === "object" && !Array.isArray(overrides)
? (overrides as Record<string, unknown>)
: {};
return BINDINGS.map((b): ResolvedBinding => {
const defaults = parseList(isMac ? b.mac : b.other, isMac);
const raw = Object.hasOwn(table, b.id) ? table[b.id] : undefined;
const custom = Array.isArray(raw);
return {
id: b.id,
group: b.group,
scopes: b.scopes,
n: "n" in b ? b.n : undefined,
defaults,
custom,
combos: custom ? parseList(raw, isMac) : defaults,
};
});
}
/** The stored shape, coerced to arrays of combo strings, for
* SettingsStore.load: a hand-edited settings.json can't break boot. Unknown
* ids are kept (a newer build's commands survive a downgrade round trip);
* effectiveKeymap ignores them. */
export function sanitizeKeybindings(raw: unknown): Record<string, string[]> {
const out: Record<string, string[]> = {};
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
for (const [id, list] of Object.entries(raw)) {
if (Array.isArray(list)) out[id] = list.filter((t): t is string => parseCombo(t, true) !== null);
}
return out;
}
/** True when a binding in `a` and one in `b` can fire on the same keystroke. */
export function scopesOverlap(a: readonly Scope[], b: readonly Scope[]): boolean {
return a.some((x) =>
b.some(
(y) =>
x === y ||
x === "any" ||
y === "any" ||
(x === "panel" && y === "main") ||
(x === "main" && y === "panel"),
),
);
}
/** The binding that already owns `combo` where `scopes` fire, if any. */
export function ownerOf(
keymap: readonly ResolvedBinding[],
combo: Combo,
scopes: readonly Scope[],
except?: ShortcutId,
): ResolvedBinding | undefined {
return keymap.find(
(b) => b.id !== except && scopesOverlap(b.scopes, scopes) && b.combos.some((c) => sameCombo(c, combo)),
);
}
/** Every binding that shares a combo with another one it can collide with. */
export function conflicts(keymap: readonly ResolvedBinding[]): Map<ShortcutId, ShortcutId[]> {
const out = new Map<ShortcutId, ShortcutId[]>();
const link = (from: ShortcutId, to: ShortcutId) => out.set(from, [...(out.get(from) ?? []), to]);
for (let i = 0; i < keymap.length; i++) {
for (let j = i + 1; j < keymap.length; j++) {
const a = keymap[i];
const b = keymap[j];
if (!scopesOverlap(a.scopes, b.scopes)) continue;
if (a.combos.some((ca) => b.combos.some((cb) => sameCombo(ca, cb)))) {
link(a.id, b.id);
link(b.id, a.id);
}
}
}
return out;
}
/** Keys whose modified forms edit text: ⌘⌫ deletes to line start, ⌥← jumps a word… */
const TEXT_EDITING = new Set(["Backspace", "Delete", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End"]);
/** Whether a combo may fire while focus is in a text field. Plain and
* Shift-only keys type there, and so do the text-editing chords (⌘⌫, ⌥←, ⌘A);
* other ⌘/Ctrl and Alt chords and F-keys fire — except Ctrl+Alt off macOS,
* which is AltGr. */
export function firesInTextField(c: Combo, isMac: boolean): boolean {
if (FKEY.test(c.key)) return true;
if (TEXT_EDITING.has(c.key) || (c.mod && c.key === "A")) return false;
if (!isMac && c.mod && c.alt) return false;
return c.mod || c.alt;
}
/** Whether a plain press of the combo types a character — bound in the file
* list, that key then no longer starts a type-to-filter query. */
export function typesCharacter(c: Combo): boolean {
return !c.mod && !c.ctrl && !c.alt && (LETTER.test(c.key) || DIGIT.test(c.key) || Object.hasOwn(CHAR_OF, c.key));
}
/** Commands a held key may repeat; any other held shortcut fires once
* (holding ⌘W must not close every tab). */
const REPEATABLE = new Set<ShortcutId>([
"cursor-up",
"cursor-down",
"page-up",
"page-down",
"toggle-select",
"next-tab",
"prev-tab",
"move-tab-left",
"move-tab-right",
"scale-up",
"scale-down",
"find-next",
"find-prev",
]);
export function isRepeatable(id: ShortcutId): boolean {
return REPEATABLE.has(id);
}
const RESERVED = {
mac: ["Mod+Q", "Mod+X", "Mod+C", "Mod+V", "Mod+Z", "Mod+Shift+Z", "Mod+H", "Mod+Alt+H", "Mod+M", "Mod+Backquote"],
other: ["Mod+X", "Mod+C", "Mod+V", "Mod+Z", "Mod+Y"],
};
/** Quit, the clipboard / undo keys and macOS's window keys (⌘H ⌘M ⌘`) can't be rebound. */
export function isReserved(c: Combo, isMac: boolean): boolean {
return parseList(isMac ? RESERVED.mac : RESERVED.other, isMac).some((r) => sameCombo(r, c));
}
/** Key names for the non-macOS spelling ("Ctrl+Shift+Del"); i18n supplies
* localized ones (German keyboards say Strg and Entf). */
export interface KeyNames {
ctrl: string;
alt: string;
shift: string;
enter: string;
backspace: string;
del: string;
esc: string;
space: string;
tab: string;
pgup: string;
pgdn: string;
home: string;
end: string;
ins: string;
menu: string;
num: string;
}
export const EN_KEY_NAMES: KeyNames = {
ctrl: "Ctrl",
alt: "Alt",
shift: "Shift",
enter: "Enter",
backspace: "Backspace",
del: "Del",
esc: "Esc",
space: "Space",
tab: "Tab",
pgup: "PgUp",
pgdn: "PgDn",
home: "Home",
end: "End",
ins: "Ins",
menu: "Menu",
num: "Num",
};
const ARROWS: Record<string, string> = { ArrowUp: "↑", ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→" };
const MAC_GLYPHS: Record<string, string> = { Enter: "↩", Escape: "⎋", Backspace: "⌫", Delete: "⌦", Tab: "⇥" };
function keyLabel(key: string, isMac: boolean, names: KeyNames): string {
if (Object.hasOwn(CHAR_OF, key)) return CHAR_OF[key];
if (Object.hasOwn(ARROWS, key)) return ARROWS[key];
if (isMac && Object.hasOwn(MAC_GLYPHS, key)) return MAC_GLYPHS[key];
const named: Record<string, string> = {
Enter: names.enter,
Escape: names.esc,
Backspace: names.backspace,
Delete: names.del,
Tab: names.tab,
Space: names.space,
Home: names.home,
End: names.end,
PageUp: names.pgup,
PageDown: names.pgdn,
Insert: names.ins,
ContextMenu: names.menu,
NumpadAdd: `${names.num} +`,
};
return Object.hasOwn(named, key) ? named[key] : key;
}
/** "⇧⌘N" on macOS (Apple's ⌃⌥⇧⌘ order), "Ctrl+Shift+N" elsewhere. */
export function formatCombo(c: Combo, isMac: boolean, names: KeyNames = EN_KEY_NAMES): string {
const key = keyLabel(c.key, isMac, names);
if (isMac) return `${c.ctrl ? "⌃" : ""}${c.alt ? "⌥" : ""}${c.shift ? "⇧" : ""}${c.mod ? "⌘" : ""}${key}`;
return [c.mod && names.ctrl, c.alt && names.alt, c.shift && names.shift, key].filter(Boolean).join("+");
}
const ACCELERATOR_KEYS: Record<string, string> = {
Enter: "return",
Escape: "escape",
Tab: "tab",
Space: "space",
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
Home: "home",
End: "end",
PageUp: "pageup",
PageDown: "pagedown",
};
/**
* Electrobun's native menu accelerator ("Command+Shift+n"), or null when the
* menu can't express the combo faithfully:
* - only ⌘ / ⌃ chords — the native parser turns any combo without a modifier
* token into ⌘+key;
* - no ⌫ / ⌦ (its "backspace" and "delete" tokens map to characters AppKit
* matches unreliably), Insert, the Menu key or the keypad;
* - no Shift+punctuation — AppKit matches the shifted character ("}"), not "]".
*/
export function toAccelerator(c: Combo): string | null {
if (!c.mod && !c.ctrl) return null;
if (c.shift && Object.hasOwn(CHAR_OF, c.key)) return null;
let key: string | undefined;
if (LETTER.test(c.key)) key = c.key.toLowerCase();
else if (DIGIT.test(c.key)) key = c.key;
else if (FKEY.test(c.key)) key = c.key.toLowerCase();
else if (Object.hasOwn(CHAR_OF, c.key)) key = c.key === "Minus" ? "minus" : CHAR_OF[c.key];
else if (Object.hasOwn(ACCELERATOR_KEYS, c.key)) key = ACCELERATOR_KEYS[c.key];
if (!key) return null;
return [c.mod && "Command", c.ctrl && "Control", c.alt && "Alt", c.shift && "Shift", key]
.filter(Boolean)
.join("+");
}
+80 -10
View File
@@ -11,12 +11,24 @@ import type {
TransferSnapshot, TransferSnapshot,
} from "./types"; } from "./types";
/** Everything an editor window needs to know about the file it hosts. */
export interface EditorOpenContext {
sessionId: string;
path: string;
name: string;
sessionTitle: string;
}
// Structurally equivalent to electrobun's ElectrobunRPCSchema, written in // Structurally equivalent to electrobun's ElectrobunRPCSchema, written in
// resolved form so this file stays importable from the Angular bundle without // resolved form so this file stays importable from the Angular bundle without
// pulling in bun-only type modules. // pulling in bun-only type modules.
// //
// bun.requests = calls the webview makes, handled in src/bun/rpc-handlers.ts // bun.requests = calls the webview makes, handled in src/bun/rpc-handlers.ts
// webview.messages = push events from bun, handled in ui rpc.service.ts // webview.messages = push events from bun, handled in ui rpc.service.ts
//
// ONE schema serves every window kind (main, editor, manager, settings):
// electrobun allows partial handler sets, and messages nobody listens to are
// silently dropped, so each side only implements/hears what applies to it.
export type AppRPC = { export type AppRPC = {
bun: { bun: {
requests: { requests: {
@@ -31,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: {
@@ -110,20 +125,55 @@ export type AppRPC = {
response: Result<{ names: string[] }>; response: Result<{ names: string[] }>;
}; };
clearEditLeftovers: { params: Record<string, never>; response: Result }; clearEditLeftovers: { params: Record<string, never>; response: Result };
// context menu + local shell integration // local shell integration
showRowMenu: { /**
params: { * Shell actions the context menu triggers on a LOCAL path. The menu
path: string; * itself is HTML (ui/src/app/core/context-menu.service.ts) — electrobun's native
name: string; * context menu is a no-op on Linux — but these three still need the
type: EntryType; * bun side's Utils.
isLocal: boolean; */
hasEntry: boolean; shellItemAction: {
}; params: { action: "reveal" | "open" | "copyPath"; path: string };
response: Result; response: Result;
}; };
moveToTrash: { params: { path: string }; response: Result }; moveToTrash: { params: { path: string }; response: Result };
/** Native About box (name, Build n, commit, engine versions). */
showAbout: { params: Record<string, never>; response: Result };
/** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */
listDrives: { params: Record<string, never>; response: Result<{ drives: string[] }> };
// editor windows
/** Main window only: open (or focus) a native editor window. */
openEditor: { params: EditorOpenContext; response: Result };
/** Editor windows only — handled by that window's own rpc closure. */
editorSave: { params: { content: string }; response: Result };
editorClose: { params: Record<string, never>; response: Result };
// connection-manager window
/** Main window: open (or focus) the single connection-manager window. */
openConnectionManager: { params: Record<string, never>; response: Result };
/** Manager window only: connect/open-stack run in the MAIN window. */
managerConnect: { params: { id: string }; response: Result };
managerOpenStack: { params: { id: string }; response: Result };
managerClose: { params: Record<string, never>; response: Result };
// settings window
/** Main window: open (or focus) the single Settings window. */
openSettings: { params: Record<string, never>; response: Result };
/** Settings window only: its close-window key (Ctrl+W off macOS, ⌘W). */
settingsClose: { params: Record<string, never>; 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: {
/** Editor → bun draft mirror; safety net for the non-cancellable native close. */
editorState: { dirty: boolean; content: string };
}; };
messages: Record<never, never>;
}; };
webview: { webview: {
requests: Record<never, never>; requests: Record<never, never>;
@@ -131,6 +181,26 @@ export type AppRPC = {
transferUpdate: TransferSnapshot; transferUpdate: TransferSnapshot;
sessionClosed: { sessionId: string; reason: string }; sessionClosed: { sessionId: string; reason: string };
menuAction: { action: string }; menuAction: { action: string };
/** bun → editor windows when the main window saves settings. */
settingsChanged: { settings: AppSettings };
/** bun → main after an editor save, so matching panels refresh. */
pathRefresh: { sessionId: string; dir: string };
/** bun → one editor: run the in-window close flow (dirty prompt). */
editorCloseRequest: Record<string, never>;
/** bun → main: the modal connection manager opened/closed. */
managerState: { open: boolean };
/** bun → main: the manager window changed the connection store. */
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;
};
}; };
}; };
}; };
+53 -18
View File
@@ -7,8 +7,13 @@ export interface FileEntry {
size: number; // bytes size: number; // bytes
mtime: number; // epoch milliseconds mtime: number; // epoch milliseconds
mode: number; // permission bits only (masked & 0o7777) mode: number; // permission bits only (masked & 0o7777)
owner?: string; // resolved name when the provider knows it
group?: string;
} }
/** Optional file-list columns (Name is always shown). */
export type ColumnKey = "size" | "mtime" | "kind" | "rights" | "owner" | "group";
/** Every RPC response uses this envelope; errors never cross the bridge as exceptions. */ /** Every RPC response uses this envelope; errors never cross the bridge as exceptions. */
export type Result<T = object> = ({ ok: true } & T) | { ok: false; error: string }; export type Result<T = object> = ({ ok: true } & T) | { ok: false; error: string };
@@ -57,27 +62,25 @@ export interface ConnectionsFile {
connections: SavedConnection[]; connections: SavedConnection[];
} }
export type AccentTone = /** UI languages the app ships translations for. */
| "graphite" export type SupportedLocale = "en" | "es" | "eu" | "fr" | "de" | "zh" | "ja";
| "blue"
| "indigo"
| "purple"
| "rose"
| "orange"
| "green"
| "teal";
export interface AppSettings { export interface AppSettings {
/** UI language; "system" resolves to the OS locale (fallback English). */
locale: "system" | SupportedLocale;
theme: "system" | "light" | "dark"; theme: "system" | "light" | "dark";
/** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */ /** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */
darkTone: "dark" | "darker"; darkTone: "dark" | "darker";
/** Accent tone for chrome and selection tint; graphite = neutral zinc. */ /** Visible optional file-list columns (both panels; Name always shows). */
accent: AccentTone; columns: ColumnKey[];
/** File-list icons: plain neutral glyphs, or per-category glyphs and colors. */
iconColors: "plain" | "multi";
speedUnit: "MBps" | "Mbps"; speedUnit: "MBps" | "Mbps";
/** Size of the panel interface: file list, path bar, header controls. */ /**
textSize: "compact" | "default" | "large"; * Interface scale factor, 0.5–4 in 0.1 steps (shown as 50%–400%). Works by
* setting the root font-size — every size in the UI is rem, so one lever
* scales the whole UI. Replaced the 3-tier `textSize`
* (compact|default|large); SettingsStore.load migrates old files.
*/
uiScale: number;
/** Run transfer jobs one at a time, or several in parallel. */ /** Run transfer jobs one at a time, or several in parallel. */
transferMode: "single" | "multi"; transferMode: "single" | "multi";
/** How many jobs run at once when transferMode is "multi". */ /** How many jobs run at once when transferMode is "multi". */
@@ -88,22 +91,54 @@ 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;
/**
* Keyboard remaps from Settings → Keyboard: command id → combo strings, only
* for rows that differ from the defaults ([] unbinds). Format and sanitizing
* live in keymap.ts — a hand-edited entry it can't parse is simply ignored.
*/
keybindings: Record<string, string[]>;
/** Enter / double-click on a LOCAL file opens it in its default app. */
openFilesOnEnter: boolean;
// 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",
theme: "system", theme: "system",
darkTone: "dark", darkTone: "dark",
accent: "blue", columns: ["size", "mtime"],
iconColors: "multi",
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: "",
keybindings: {},
openFilesOnEnter: false,
// autoRefresh: true,
}; };
export const UI_SCALE_MIN = 0.5;
export const UI_SCALE_MAX = 4;
export const UI_SCALE_STEP = 0.1;
/**
* Clamp + snap a scale factor to the legal range and step grid. Shared by the
* store (a hand-edited settings.json must not be able to render the UI
* unusable), the settings service and the stepper — one definition of "valid",
* same as effectiveConcurrency below.
*/
export function clampUiScale(value: number): number {
if (!Number.isFinite(value)) return 1;
const clamped = Math.min(UI_SCALE_MAX, Math.max(UI_SCALE_MIN, value));
// snap to the step grid; toFixed strips float noise (0.7000000000000001 -> 0.7)
return Number((Math.round(clamped / UI_SCALE_STEP) * UI_SCALE_STEP).toFixed(2));
}
export function effectiveConcurrency(settings: AppSettings): number { export function effectiveConcurrency(settings: AppSettings): number {
if (settings.transferMode === "single") return 1; if (settings.transferMode === "single") return 1;
return Math.max(1, Math.min(8, Math.round(settings.transferConcurrency) || 1)); return Math.max(1, Math.min(8, Math.round(settings.transferConcurrency) || 1));
-3
View File
@@ -1,3 +0,0 @@
// electrobun ships raw .ts sources whose WGPU adapter imports "three" without
// bundled types; we never use that path, so give tsc an any-typed stand-in.
declare module "three";
+2 -1
View File
@@ -1,4 +1,5 @@
{ {
"extends": "./.hutch/devkit/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"useDefineForClassFields": false, "useDefineForClassFields": false,
@@ -16,6 +17,6 @@
"emitDecoratorMetadata": false, "emitDecoratorMetadata": false,
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["src"], "include": ["src", "electrobun.config.ts"],
"exclude": ["node_modules", "dist", "build", "../../package/dist"] "exclude": ["node_modules", "dist", "build", "../../package/dist"]
} }
+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": {
+1 -1
View File
@@ -14,7 +14,7 @@
"@angular/core": "^22.0.0", "@angular/core": "^22.0.0",
"@angular/platform-browser": "^22.0.0", "@angular/platform-browser": "^22.0.0",
"@ng-icons/core": "^34.0.0", "@ng-icons/core": "^34.0.0",
"@ng-icons/mynaui": "^34.0.0", "@ng-icons/tdesign-icons": "^34.0.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
-75
View File
@@ -1,75 +0,0 @@
<div class="flex h-screen flex-col bg-background text-foreground">
@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">Not connected to the app</p>
<p class="mt-1 text-xs text-muted-foreground">
This page is running in a plain browser. Launch the desktop app to use it.
</p>
</div>
</div>
} @else {
<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 />
}
<!-- dialogs -->
@if (dialogs.manager()) {
<app-connection-manager />
}
@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" />
}
@if (dialogs.editor(); as req) {
<app-editor-dialog [request]="req" />
}
<!-- toasts -->
<div class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
@for (t of toast.toasts(); track t.id) {
<div
(mouseenter)="toast.pause(t.id)"
(mouseleave)="toast.resume(t.id)"
class="group pointer-events-auto relative flex items-start gap-2 overflow-hidden rounded-lg border border-border bg-card p-3 text-xs text-card-foreground shadow-lg"
>
<ng-icon
[name]="toastIcon(t.kind)"
size="1rem"
[class]="t.kind === 'error' ? 'shrink-0 text-destructive' : 'shrink-0 text-success'"
/>
<span class="min-w-0 flex-1 wrap-break-word">{{ t.text }}</span>
<button
(click)="toast.dismiss(t.id)"
class="shrink-0 text-muted-foreground hover:text-foreground"
>
<ng-icon name="mynaX" size="0.9rem" />
</button>
<!-- auto-dismiss countdown; freezes with the timer while hovered -->
<div
class="toast-countdown absolute inset-x-0 bottom-0 h-0.5 origin-left"
[class.bg-destructive]="t.kind === 'error'"
[class.bg-success]="t.kind === 'success'"
[class.bg-primary]="t.kind === 'info'"
[style.animation-duration.ms]="t.ttlMs"
></div>
</div>
}
</div>
</div>
-182
View File
@@ -1,182 +0,0 @@
import { Component, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { BRANDING } from '@shared/branding';
import { ConnectionManager } from './connection-manager/connection-manager';
import { ConnectionsService } from './connections.service';
import { ChmodDialog } from './dialogs/chmod-dialog';
import { ConfirmDialog } from './dialogs/confirm-dialog';
import { EditorDialog } from './dialogs/editor-dialog';
import { OverwriteDialog } from './dialogs/overwrite-dialog';
import { PromptDialog } from './dialogs/prompt-dialog';
import { DialogsService } from './dialogs.service';
import { FileActionsService } from './file-actions.service';
import { FilePanel } from './file-panel/file-panel';
import { PanelsService } from './panels.service';
import { RpcService } from './rpc.service';
import { StatusBar } from './status-bar/status-bar';
import { TabBar } from './tab-bar/tab-bar';
import { ToastService } from './toast.service';
import { TransferBar } from './transfer-bar/transfer-bar';
import { TransfersService } from './transfers.service';
@Component({
selector: 'app-root',
imports: [
NgIcon,
FilePanel,
TabBar,
StatusBar,
PromptDialog,
ConfirmDialog,
ChmodDialog,
OverwriteDialog,
EditorDialog,
TransferBar,
ConnectionManager,
],
templateUrl: './app.html',
host: { '(window:keydown)': 'onWindowKeydown($event)' },
})
export class App {
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);
constructor() {
document.title = BRANDING.appName;
if (this.rpc.available) {
this.rpc.onMenuAction((action) => this.handleMenuAction(action));
void this.promptForEditLeftovers();
}
}
/** 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 ? `, +${res.names.length - 5} more` : '';
this.dialogs.confirm.set({
title: 'Leftover edited files',
body: `A previous session left ${res.names.length} temporary edited file(s) behind (${shown}${more}). Clear them?`,
confirmLabel: 'Clear',
destructive: true,
onConfirm: () => void this.rpc.request.clearEditLeftovers({}),
});
}
/** Native application-menu actions forwarded from the bun process. */
private handleMenuAction(action: string): void {
if (action.startsWith('ctx-')) {
this.handleContextAction(action);
return;
}
if (action.startsWith('connect:')) {
const id = action.slice('connect:'.length);
const connection = this.connections.connections().find((c) => c.id === id);
if (connection) {
void this.panels.connectPanel(
this.panels.activeSide(),
connection,
connection.name,
connection.localDir,
);
}
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 'manage-connections':
this.dialogs.manager.set(true);
break;
}
}
/** Tab (workspace) shortcuts — panel-level keys live on the panels themselves. */
onWindowKeydown(event: KeyboardEvent): void {
const mod = event.metaKey || event.ctrlKey;
if (mod && event.key.toLowerCase() === 't') {
event.preventDefault();
this.panels.newWorkspace();
} else if (mod && event.key.toLowerCase() === 'w') {
event.preventDefault();
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
} else if (event.ctrlKey && event.key === 'Tab') {
event.preventDefault();
this.panels.cycleWorkspace(event.shiftKey ? -1 : 1);
} else if (mod && !event.shiftKey && !event.altKey) {
// ⌘1–⌘8 jump to that tab, ⌘9 to the last (Ctrl+digit elsewhere), like
// Firefox. Matched on event.code so the digit row works on any layout.
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1];
if (!digit) return;
event.preventDefault();
this.panels.goToWorkspace(digit === '9' ? -1 : Number(digit) - 1);
}
}
/** Context-menu actions apply to the row/panel remembered at right-click time. */
private handleContextAction(action: string): void {
const target = this.actions.menuTarget;
if (!target) return;
const { panel, side, entry } = target;
switch (action) {
case 'ctx-open':
if (entry) panel.open(entry);
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;
}
}
toastIcon(kind: 'error' | 'info' | 'success'): string {
if (kind === 'error') return 'mynaXCircle';
if (kind === 'success') return 'mynaCheckCircle';
return 'mynaCheck';
}
}
@@ -1,364 +0,0 @@
<div class="fixed inset-0 z-30 grid place-items-center bg-black/40" (mousedown)="close()">
<div
class="flex h-[440px] w-[680px] flex-col rounded-lg border border-border bg-card text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
>
<!-- title bar -->
<div class="flex items-center justify-between border-b border-border px-4 py-2.5">
<h2 class="text-sm font-semibold">Connections</h2>
<button (click)="close()" class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
<ng-icon name="mynaX" size="1rem" />
</button>
</div>
<div class="flex min-h-0 flex-1">
<!-- stacks + connections list -->
<div class="flex w-60 shrink-0 flex-col border-r border-border">
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
<button
(click)="newConnection()"
title="New connection"
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" /> Connection
</button>
<button
(click)="newStack()"
title="New stack"
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" /> Stack
</button>
<div class="flex-1"></div>
<button
(click)="importWinScp()"
[disabled]="importing()"
title="Import sessions from a WinSCP.ini export"
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground 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" />
}
Import
</button>
</div>
<div class="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 ? 'Expand' : 'Collapse'"
>
<ng-icon
[name]="group.stack.collapsed ? 'mynaChevronRight' : 'mynaChevronDown'"
size="0.8rem"
/>
</button>
<span class="truncate text-[11px] font-medium text-muted-foreground uppercase">
{{ group.stack.name }}
</span>
<span class="shrink-0 text-[10px] text-muted-foreground/70 tabular-nums">
{{ group.connections.length }}
</span>
<div class="min-w-0 flex-1"></div>
<button
(click)="renameStack(group.stack)"
title="Rename stack"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground"
>
<ng-icon name="mynaEdit" size="0.75rem" />
</button>
<button
(click)="deleteStack(group.stack)"
title="Delete stack"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-destructive"
>
<ng-icon name="mynaTrash" size="0.75rem" />
</button>
@if (group.connections.length) {
<button
(click)="openStack(group.connections)"
title="Open stack in tabs"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-primary"
>
<ng-icon name="mynaArrowRight" size="0.8rem" />
</button>
}
</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-[11px] text-muted-foreground italic">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-[11px] font-medium text-muted-foreground uppercase">
Connections
</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-[11px] text-muted-foreground">
No saved connections yet. Create one, or import your WinSCP sessions.
</p>
}
</div>
</div>
<!-- edit form -->
<div class="flex min-w-0 flex-1 flex-col">
@if (draft(); as d) {
<div class="grid flex-1 auto-rows-min grid-cols-2 gap-x-3 gap-y-2.5 overflow-y-auto p-4">
<label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">Name</span>
<input
[value]="d.name"
(input)="patch('name', $any($event.target).value)"
placeholder="my server"
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">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">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">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">(none)</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">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">Remote path (optional)</span>
<input
[value]="d.initialPath"
(input)="patch('initialPath', $any($event.target).value)"
placeholder="home directory"
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">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"
/>
Password
</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"
/>
Private key
</label>
</div>
@if (d.authKind === 'password') {
<label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">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">Private key file</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-[11px] hover:bg-accent"
>
Choose…
</button>
</div>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">Passphrase (optional)</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">Local directory (optional)</span>
<input
[value]="d.localDir"
(input)="patch('localDir', $any($event.target).value)"
placeholder="~/projects/my-server"
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-[10px] text-muted-foreground/80">
The other panel opens here when it's on Local and hasn't been navigated.
</span>
</label>
}
</div>
<div class="flex items-center gap-2 border-t border-border 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" /> 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() ? 'Less…' : '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"
>
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" />
}
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">
Select a connection or create a new one.
</p>
</div>
</div>
}
</div>
</div>
</div>
</div>
@@ -1,251 +0,0 @@
import { Component, computed, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import type { SavedConnection, Stack } from '@shared/types';
import { ConnectionsService } from '../connections.service';
import { DialogsService } from '../dialogs.service';
import { PanelsService } from '../panels.service';
import { RpcService } from '../rpc.service';
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,
};
}
@Component({
selector: 'app-connection-manager',
imports: [NgIcon],
templateUrl: './connection-manager.html',
})
export class ConnectionManager {
readonly store = inject(ConnectionsService);
readonly dialogs = inject(DialogsService);
readonly panels = inject(PanelsService);
private readonly rpc = inject(RpcService);
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 {
this.dialogs.manager.set(false);
this.panels.refocus();
}
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> {
const d = this.draft();
if (!d) return;
this.busy.set(true);
try {
const id = await this.save();
if (!id) return;
const connection = fromDraft({ ...d, id });
const ok = await this.panels.connectPanel(
this.panels.activeSide(),
connection,
connection.name,
connection.localDir,
);
if (ok) this.close();
} finally {
this.busy.set(false);
}
}
deleteCurrent(): void {
const d = this.draft();
if (!d?.id) {
this.draft.set(null);
return;
}
this.dialogs.confirm.set({
title: 'Delete connection',
body: `Delete "${d.name || d.host}"?`,
confirmLabel: '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(connections: SavedConnection[]): void {
this.panels.openStack(connections);
this.close();
}
toggleStack(stack: Stack): void {
void this.store.upsertStack({ ...stack, collapsed: !stack.collapsed });
}
newStack(): void {
this.dialogs.prompt.set({
title: 'New stack',
placeholder: 'stack name',
confirmLabel: 'Create',
onConfirm: (name) => void this.store.upsertStack({ id: '', name }),
});
}
renameStack(stack: Stack): void {
this.dialogs.prompt.set({
title: 'Rename stack',
initial: stack.name,
confirmLabel: 'Rename',
onConfirm: (name) => void this.store.upsertStack({ ...stack, name }),
});
}
deleteStack(stack: Stack): void {
this.dialogs.confirm.set({
title: 'Delete stack',
body: `Delete stack "${stack.name}"? Its connections stay, ungrouped.`,
confirmLabel: '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);
}
}
@@ -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);
@@ -29,6 +31,8 @@ export class ConnectionsService {
if (this.rpc.available) { if (this.rpc.available) {
void this.reload(); void this.reload();
void this.reloadSshHosts(); void this.reloadSshHosts();
// the connection-manager window edited the store — pick it up
this.rpc.onConnectionsChanged(() => void this.reload());
} }
} }
@@ -84,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();
+223
View File
@@ -0,0 +1,223 @@
import { Injectable, inject, signal } from '@angular/core';
import type { ColumnKey, EntryType } from '@shared/types';
import type { FileCommand } from './file-actions.service';
import type { Icon14 } from './icons';
import { I18nService } from './i18n.service';
import { stepIndex } from './menu-nav';
import type { Side } from './panels.service';
import { IS_MAC, IS_WINDOWS } from './platform';
import { RpcService } from './rpc.service';
import type { ShortcutId } from './shortcuts.service';
/** Optional list columns, in display order (Name is fixed). */
export const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group'];
export interface MenuItem {
/** separator: a rule; header: a non-pickable section title (the source picker). */
type?: 'separator' | 'header';
label?: string;
/** Handed to the request's onPick, else emitted to the action dispatcher
* (`col:*` / `tab:*`). */
action?: string;
checked?: boolean;
tooltip?: string;
/** The keymap command the item runs: its first key shows as the soft hint,
* and pressing that key while the menu is open picks the item. */
shortcut?: ShortcutId;
/** A 14px icon (core/icons.ts), drawn in the check column. */
icon?: Icon14;
/** Greyed and unclickable. Menus disable rather than hide, so the set of
* commands stays in the same place from one right-click to the next. */
disabled?: boolean;
}
/** Anything with a viewport point: a MouseEvent, or a row's rect for a
* keyboard-opened menu. */
export interface MenuPoint {
clientX: number;
clientY: number;
}
export interface MenuRequest {
x: number;
y: number;
items: MenuItem[];
/** Highlighted on open: the first item when the keyboard opened the menu. */
initialIndex: number;
/** Where a pick goes instead of the shared dispatcher (App.handleMenuAction). */
onPick?: (action: string) => void;
}
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 {
side: Side;
name: string;
type: EntryType;
isLocal: boolean;
hasEntry: boolean; // false = right-click on empty panel space
}
/** Items the highlight can land on. */
export const isSelectable = (item: MenuItem): boolean => !item.type && !item.disabled;
// 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 HTML context menus. This used to be a native
* electrobun ContextMenu on the bun side, but `showContextMenu` is a documented
* no-op on Linux (electrobun's own .hutch/devkit/api/sdks/main/proc/linux.md),
* which left the whole right-click surface dead there. The menu is plain HTML
* now — one code path on every platform.
*/
@Injectable({ providedIn: 'root' })
export class ContextMenuService {
private readonly i18n = inject(I18nService);
private readonly rpc = inject(RpcService);
readonly request = signal<MenuRequest | null>(null);
open(
point: MenuPoint,
items: MenuItem[],
opts: { keyboard?: boolean; onPick?: (action: string) => void } = {},
): void {
const initialIndex = opts.keyboard ? stepIndex(items.map(isSelectable), -1, 1) : -1;
this.request.set({ x: point.clientX, y: point.clientY, items, initialIndex, onPick: opts.onPick });
}
close(): void {
this.request.set(null);
}
/** A pick without its own handler goes through the dispatcher the app
* menubar uses. */
emit(action: string): void {
this.rpc.emitMenuAction(action);
}
/**
* Row / empty-space menu for a file panel. Actions are FileCommand ids,
* picked through FileActionsService.run. Icons only on the everyday verbs —
* the same glyphs as their toolbar buttons.
*/
rowItems(ctx: RowMenuContext): MenuItem[] {
const t = (key: string) => this.i18n.t(key);
const cmd = (id: FileCommand, label: string, extra: Partial<MenuItem> = {}): MenuItem => ({
label,
action: id,
shortcut: id,
...extra,
});
const items: MenuItem[] = [];
if (!ctx.hasEntry || ctx.name === '..') {
// empty space (or the parent row): folder-level actions
items.push(cmd('new-folder', t('ctx.newFolder'), { icon: 'folderAdd14' }));
items.push(cmd('new-file', t('ctx.newFile')));
items.push({ type: 'separator' });
items.push(cmd('refresh', t('ctx.refresh')));
if (ctx.isLocal) {
items.push({ type: 'separator' });
items.push(cmd('reveal', t(revealKey())));
}
return items;
}
// the arrow points at the other panel, as on the toolbar's Send button
const sendIcon = ctx.side === 'left' ? 'arrowRight14' : 'arrowLeft14';
items.push(cmd('send', t('ctx.copyToOther'), { icon: sendIcon }));
if (ctx.type === 'dir') {
items.push(cmd('open', t('ctx.open')));
} else {
if (ctx.isLocal) {
items.push(cmd('open-default', t('ctx.open'), { tooltip: t('ctx.openDefault') }));
}
items.push(cmd('edit', t('ctx.edit'), { icon: 'fileText14' }));
}
items.push(cmd('rename', t('ctx.rename'), { icon: 'edit14' }));
// chmod on Windows local files only flips the read-only bit — hide the lie
if (!(ctx.isLocal && IS_WINDOWS)) {
items.push(cmd('chmod', t('ctx.permissions')));
}
items.push({ type: 'separator' });
items.push(cmd('copy-path', t('ctx.copyPath')));
if (ctx.isLocal) {
items.push(cmd('reveal', t(revealKey())));
}
items.push({ type: 'separator' });
if (ctx.isLocal) {
items.push(cmd('trash', t(trashKey()), IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}));
}
items.push(cmd('delete', t('ctx.delete'), { icon: 'delete14' }));
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`. (The hints name the keys, which act on the
* ACTIVE tab; picking by key while this menu is open acts on THIS one.)
*
* Everything illegal is DISABLED, not omitted: a menu whose items move
* between right-clicks is worse than a dead row, and "Close Tab" greyed out
* explains why the last tab cannot be closed better than its absence does.
*
* Duplicate/Rename/Save-session are deliberately absent until tabs have an
* identity to duplicate (a stored connection id) — the sessions round.
*/
tabItems(ctx: TabMenuContext): MenuItem[] {
const t = (key: string) => this.i18n.t(key);
const last = ctx.tabCount <= 1;
return [
{ label: t('ctx.tabNew'), action: 'tab:new', shortcut: 'new-tab', icon: 'add14' },
{ type: 'separator' },
{
label: t('ctx.tabClose'),
action: `tab:close:${ctx.id}`,
shortcut: 'close-tab',
icon: 'close14',
disabled: last,
},
{
label: t('ctx.tabCloseOthers'),
action: `tab:close-others:${ctx.id}`,
shortcut: 'close-other-tabs',
disabled: last,
},
{
label: t('ctx.tabCloseRight'),
action: `tab:close-right:${ctx.id}`,
shortcut: 'close-tabs-right',
disabled: ctx.index >= ctx.tabCount - 1,
},
{ type: 'separator' },
{
label: t('ctx.tabDisconnect'),
action: `tab:disconnect:${ctx.id}`,
shortcut: 'disconnect-tab',
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,6 +1,5 @@
import { Injectable, signal } from '@angular/core'; import { Injectable, signal } from '@angular/core';
import type { FileEntry } from '@shared/types'; import type { FileEntry } from '@shared/types';
import type { PanelState } from './panels.service';
export type OverwriteChoice = 'overwrite' | 'skip' | 'overwriteAll' | 'skipAll' | 'cancel'; export type OverwriteChoice = 'overwrite' | 'skip' | 'overwriteAll' | 'skipAll' | 'cancel';
@@ -34,23 +33,12 @@ export interface ChmodRequest {
onConfirm: (mode: number) => void; onConfirm: (mode: number) => void;
} }
export interface EditorRequest {
sessionId: string;
path: string;
name: string;
sessionTitle: string;
content: string;
panel: PanelState; // refreshed after save
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class DialogsService { export class DialogsService {
readonly prompt = signal<PromptRequest | null>(null); readonly prompt = signal<PromptRequest | null>(null);
readonly confirm = signal<ConfirmRequest | null>(null); readonly confirm = signal<ConfirmRequest | null>(null);
readonly chmod = signal<ChmodRequest | null>(null); readonly chmod = signal<ChmodRequest | null>(null);
readonly overwrite = signal<OverwriteRequest | null>(null); readonly overwrite = signal<OverwriteRequest | null>(null);
readonly editor = signal<EditorRequest | null>(null);
readonly manager = signal(false);
/** Ask what to do about one name collision; resolves with the user's choice. */ /** Ask what to do about one name collision; resolves with the user's choice. */
askOverwrite(request: Omit<OverwriteRequest, 'resolve'>): Promise<OverwriteChoice> { askOverwrite(request: Omit<OverwriteRequest, 'resolve'>): Promise<OverwriteChoice> {
@@ -59,8 +47,10 @@ export class DialogsService {
}); });
} }
/** Any dialog up — the overwrite question included. Reads signals, so an
* effect or computed that asks re-runs when a dialog opens or closes. */
get anyOpen(): boolean { get anyOpen(): boolean {
return !!(this.prompt() || this.confirm() || this.chmod()); return !!(this.prompt() || this.confirm() || this.chmod() || this.overwrite());
} }
closeAll(): void { closeAll(): void {
+285
View File
@@ -0,0 +1,285 @@
import { Injectable, inject } from '@angular/core';
import type { FileEntry } from '@shared/types';
import { ContextMenuService, type MenuPoint } from './context-menu.service';
import { DialogsService } from './dialogs.service';
import { joinPath } from './format';
import { type PanelState, type Side, type SortKey } from './panels.service';
import { IS_WINDOWS } from './platform';
import { RpcService } from './rpc.service';
import { SettingsService } from './settings.service';
import type { ShortcutId } from './shortcuts.service';
import { I18nService } from './i18n.service';
import { ToastService } from './toast.service';
import { TransfersService } from './transfers.service';
/** What a file command acts on: a panel, and the row it was invoked on (the
* cursor row from the keyboard, the right-clicked row from a menu; null =
* the folder itself). */
export interface FileTarget {
side: Side;
panel: PanelState;
entry: FileEntry | null;
}
/** The keymap commands that act on files — everything the keyboard, the
* right-click menu, the macOS menu bar and the panel toolbar run, all through
* run() so the four entry points can't drift apart. */
export type FileCommand = Extract<
ShortcutId,
| 'open'
| 'open-default'
| 'send'
| 'edit'
| 'rename'
| 'new-folder'
| 'new-file'
| 'chmod'
| 'delete'
| 'trash'
| 'copy-path'
| 'reveal'
| 'refresh'
| `sort-${string}`
>;
/**
* File operations, always against an explicit panel: on its selection if there
* is one, else on the target row.
*/
@Injectable({ providedIn: 'root' })
export class FileActionsService {
private readonly i18n = inject(I18nService);
private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService);
private readonly dialogs = inject(DialogsService);
private readonly menu = inject(ContextMenuService);
private readonly settings = inject(SettingsService);
private readonly transfers = inject(TransfersService);
/** Run a file command. One switch for every entry point. */
run(id: FileCommand, target: FileTarget): void {
const { side, panel } = target;
const local = panel.sessionId() === 'local';
// the ".." row stands for the folder itself, as on the empty-space menu
const entry = target.entry && target.entry.name !== '..' ? target.entry : null;
switch (id) {
case 'open':
case 'open-default':
// folders, symlinks and ".." navigate; a LOCAL file opens in its default
// app — from the menu's Open always, from Enter / double-click only
// when Settings → File list says so
if (!target.entry) return;
if (target.entry.type !== 'file') panel.open(target.entry);
else if (local && (id === 'open-default' || this.settings.openFilesOnEnter())) {
this.shell('open', target.entry.path);
}
return;
case 'send':
void this.transfers.copyToOtherPanel(side);
return;
case 'edit':
void this.edit(panel);
return;
case 'rename':
this.rename(panel);
return;
case 'new-folder':
this.mkdir(panel);
return;
case 'new-file':
this.createFile(panel);
return;
case 'chmod':
this.chmod(panel);
return;
case 'delete':
this.delete(panel);
return;
case 'trash':
if (local) void this.trash(panel);
return;
case 'copy-path':
this.shell('copyPath', entry?.path ?? panel.cwd());
return;
case 'reveal':
if (local) this.shell('reveal', entry?.path ?? panel.cwd());
return;
case 'refresh':
void panel.refresh();
return;
default:
// sort-name, sort-size…: again = reverse
if (id.startsWith('sort-')) panel.toggleSort(id.slice('sort-'.length) as SortKey);
}
}
/** Open the row / empty-space menu on `target`; its picks run here. */
openContextMenu(point: MenuPoint, target: FileTarget, keyboard = false): void {
const { side, panel, entry } = target;
this.menu.open(
point,
this.menu.rowItems({
side,
name: entry?.name ?? '',
type: entry?.type ?? 'dir',
isLocal: panel.sessionId() === 'local',
hasEntry: !!entry,
}),
{ keyboard, onPick: (action) => this.run(action as FileCommand, target) },
);
}
/** Local shell integration — only bun can reach the OS shell / clipboard. */
private shell(action: 'open' | 'reveal' | 'copyPath', path: string): void {
void this.rpc.request.shellItemAction({ action, path });
}
/** Move the selection (else the target row) to the Trash, one item at a time. */
async trash(panel: PanelState): Promise<void> {
const targets = this.targets(panel);
if (!targets.length) return;
let moved = 0;
for (const entry of targets) {
const res = await this.rpc.request.moveToTrash({ path: entry.path });
if (!res.ok) {
this.toast.error(res.error);
break;
}
moved++;
}
if (moved === 1) this.toast.info(this.i18n.t('toasts.movedToTrash', { name: targets[0].name }));
else if (moved > 1) this.toast.info(this.i18n.t('toasts.movedManyToTrash', { count: moved }));
await panel.refresh();
}
/** Selection if non-empty, else the cursor entry. Never the ".." parent row. */
targets(panel: PanelState): FileEntry[] {
const selected = panel.selectedEntries();
if (selected.length) return selected;
const atCursor = panel.sorted()[panel.cursor()];
return atCursor && atCursor.name !== '..' ? [atCursor] : [];
}
mkdir(panel: PanelState): void {
this.dialogs.prompt.set({
title: this.i18n.t('fileActions.newFolderTitle'),
placeholder: this.i18n.t('fileActions.newFolderPlaceholder'),
confirmLabel: this.i18n.t('fileActions.createConfirm'),
onConfirm: async (name) => {
const res = await this.rpc.request.mkdir({
sessionId: panel.sessionId(),
path: joinPath(panel.cwd(), name),
});
if (!res.ok) this.toast.error(res.error);
await panel.refresh();
},
});
}
createFile(panel: PanelState): void {
this.dialogs.prompt.set({
title: this.i18n.t('fileActions.newFileTitle'),
placeholder: this.i18n.t('fileActions.newFilePlaceholder'),
confirmLabel: this.i18n.t('fileActions.createConfirm'),
onConfirm: async (name) => {
const res = await this.rpc.request.createFile({
sessionId: panel.sessionId(),
path: joinPath(panel.cwd(), name),
});
if (!res.ok) this.toast.error(res.error);
await panel.refresh();
},
});
}
/** Open the cursor file in the built-in editor or the configured external app. */
async edit(panel: PanelState): Promise<void> {
const entry = panel.sorted()[panel.cursor()];
if (!entry || entry.type !== 'file' || entry.name === '..') return;
if (this.settings.editor() === 'custom') {
const res = await this.rpc.request.editExternal({
sessionId: panel.sessionId(),
path: entry.path,
});
if (!res.ok) {
this.toast.error(res.error);
} else if (res.watching) {
this.toast.info(this.i18n.t('toasts.editingExternal', { name: entry.name }));
}
return;
}
// built-in editor: its own native window (reads the file itself)
const res = await this.rpc.request.openEditor({
sessionId: panel.sessionId(),
path: entry.path,
name: entry.name,
sessionTitle: panel.title(),
});
if (!res.ok) this.toast.error(res.error);
}
rename(panel: PanelState): void {
const entry = panel.sorted()[panel.cursor()];
if (!entry || entry.name === '..') return;
this.dialogs.prompt.set({
title: this.i18n.t('fileActions.renameTitle', { name: entry.name }),
initial: entry.name,
confirmLabel: this.i18n.t('fileActions.renameConfirm'),
onConfirm: async (name) => {
if (name === entry.name) return;
const res = await this.rpc.request.rename({
sessionId: panel.sessionId(),
oldPath: entry.path,
newPath: joinPath(panel.cwd(), name),
});
if (!res.ok) this.toast.error(res.error);
await panel.refresh();
},
});
}
delete(panel: PanelState): void {
const targets = this.targets(panel);
if (!targets.length) return;
const what =
targets.length === 1
? `"${targets[0].name}"`
: this.i18n.t('fileActions.itemsCount', { count: targets.length });
this.dialogs.confirm.set({
title: this.i18n.t('fileActions.deleteTitle'),
body: this.i18n.t('fileActions.deleteBody', { what }),
confirmLabel: this.i18n.t('fileActions.deleteConfirm'),
destructive: true,
onConfirm: async () => {
const res = await this.rpc.request.deleteEntries({
sessionId: panel.sessionId(),
entries: targets.map(({ path, type }) => ({ path, type })),
});
if (!res.ok) this.toast.error(res.error);
await panel.refresh();
},
});
}
chmod(panel: PanelState): void {
// Windows local chmod only flips the read-only bit — don't pretend
if (IS_WINDOWS && panel.sessionId() === 'local') return;
const targets = this.targets(panel);
if (!targets.length) return;
this.dialogs.chmod.set({
targets: targets.map((t) => t.name),
initialMode: targets[0].mode,
onConfirm: async (mode) => {
const res = await this.rpc.request.chmod({
sessionId: panel.sessionId(),
paths: targets.map((t) => t.path),
mode,
});
if (!res.ok) this.toast.error(res.error);
await panel.refresh();
},
});
}
}
@@ -1,40 +1,40 @@
// File-list glyphs + per-category colors ("Multi" icon mode). Category colors // File-list glyphs + per-category colors. Category colors
// are fixed tokens (never follow the accent) so a folder is always folder-blue. // are fixed tokens (never follow the accent) so a folder is always folder-blue.
// Only the icon glyph is colored; row text stays neutral. Docs and unknown // Only the icon glyph is colored; row text stays neutral. Docs and unknown
// files stay muted on purpose — sparing color is what keeps it scannable. // files stay muted on purpose — sparing color is what keeps it scannable.
import type { FileEntry } from '@shared/types'; import type { FileEntry } from '@shared/types';
import type { Icon16 } from './icons';
type Category = 'image' | 'audio' | 'video' | 'archive' | 'code' | 'doc'; type Category = 'image' | 'audio' | 'video' | 'archive' | 'code' | 'doc';
// Class strings are literals so the Tailwind scanner picks them up. // Class strings are literals so the Tailwind scanner picks them up.
const MUTED = 'shrink-0 text-muted-foreground'; const MUTED = 'shrink-0 text-muted-foreground';
const PRIMARY = 'shrink-0 text-primary';
const FOLDER = 'shrink-0 text-icon-folder'; const FOLDER = 'shrink-0 text-icon-folder';
const LINK = 'shrink-0 text-icon-link'; const LINK = 'shrink-0 text-icon-link';
const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: string[] }> = { const CATEGORIES: Record<Category, { glyph: Icon16; class: string; extensions: string[] }> = {
image: { image: {
glyph: 'mynaImage', glyph: 'image16',
class: 'shrink-0 text-icon-image', class: 'shrink-0 text-icon-image',
extensions: 'png jpg jpeg gif webp avif svg ico icns bmp tiff tif heic heif raw'.split(' '), extensions: 'png jpg jpeg gif webp avif svg ico icns bmp tiff tif heic heif raw'.split(' '),
}, },
audio: { audio: {
glyph: 'mynaMusic', glyph: 'music16',
class: 'shrink-0 text-icon-media', class: 'shrink-0 text-icon-media',
extensions: 'mp3 wav flac aac ogg oga m4a opus wma aiff aif mid midi'.split(' '), extensions: 'mp3 wav flac aac ogg oga m4a opus wma aiff aif mid midi'.split(' '),
}, },
video: { video: {
glyph: 'mynaVideo', glyph: 'video16',
class: 'shrink-0 text-icon-media', class: 'shrink-0 text-icon-media',
extensions: 'mp4 mkv mov avi webm m4v mpg mpeg wmv flv 3gp'.split(' '), extensions: 'mp4 mkv mov avi webm m4v mpg mpeg wmv flv 3gp'.split(' '),
}, },
archive: { archive: {
glyph: 'mynaArchive', glyph: 'fileZip16',
class: 'shrink-0 text-icon-archive', class: 'shrink-0 text-icon-archive',
extensions: 'zip tar gz tgz bz2 xz zst 7z rar dmg iso jar deb rpm pkg apk'.split(' '), extensions: 'zip tar gz tgz bz2 xz zst 7z rar dmg iso jar deb rpm pkg apk'.split(' '),
}, },
code: { code: {
glyph: 'mynaCode', glyph: 'code16',
class: 'shrink-0 text-icon-code', class: 'shrink-0 text-icon-code',
// ".ts" is TypeScript here, not an MPEG transport stream (deliberate) // ".ts" is TypeScript here, not an MPEG transport stream (deliberate)
extensions: ( extensions: (
@@ -46,7 +46,7 @@ const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: s
).split(' '), ).split(' '),
}, },
doc: { doc: {
glyph: 'mynaFileText', glyph: 'fileText16',
class: MUTED, // docs keep the neutral color; only the glyph changes class: MUTED, // docs keep the neutral color; only the glyph changes
extensions: 'md markdown txt rtf pdf doc docx xls xlsx ppt pptx csv tsv log odt ods odp epub'.split(' '), extensions: 'md markdown txt rtf pdf doc docx xls xlsx ppt pptx csv tsv log odt ods odp epub'.split(' '),
}, },
@@ -64,18 +64,36 @@ function categoryOf(name: string): Category | null {
return EXT_CATEGORY.get(key) ?? null; return EXT_CATEGORY.get(key) ?? null;
} }
export function fileGlyph(entry: FileEntry, multi: boolean): string { const KIND_KEYS: Record<Category, string> = {
if (entry.name === '..') return 'mynaArrowUp'; image: 'image',
if (entry.type === 'dir') return 'mynaFolder'; audio: 'audio',
if (!multi) return 'mynaFile'; video: 'video',
if (entry.type === 'symlink') return 'mynaLink'; archive: 'archive',
code: 'code',
doc: 'document',
};
/**
* 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.type === 'dir') return 'folder';
if (entry.type === 'symlink') return 'symlink';
const category = categoryOf(entry.name); const category = categoryOf(entry.name);
return category ? CATEGORIES[category].glyph : 'mynaFile'; return category ? KIND_KEYS[category] : 'file';
} }
export function fileIconClass(entry: FileEntry, multi: boolean): string { export function fileGlyph(entry: FileEntry): Icon16 {
// plain mode: exactly the pre-color look ('..' has type 'dir' -> primary) if (entry.name === '..') return 'arrowUp16';
if (!multi) return entry.type === 'dir' ? PRIMARY : MUTED; if (entry.type === 'dir') return 'folder16';
if (entry.type === 'symlink') return 'link16';
const category = categoryOf(entry.name);
return category ? CATEGORIES[category].glyph : 'file16';
}
export function fileIconClass(entry: FileEntry): string {
if (entry.name === '..') return MUTED; if (entry.name === '..') return MUTED;
if (entry.type === 'dir') return FOLDER; if (entry.type === 'dir') return FOLDER;
if (entry.type === 'symlink') return LINK; if (entry.type === 'symlink') return LINK;
+36
View File
@@ -0,0 +1,36 @@
/** What Tab can land on. Tab order is DOM order: the app sets no positive tabindex. */
const FOCUSABLE = 'button, input, select, textarea, [tabindex]';
/**
* Tab / Shift+Tab through the focusable controls under `root`, in DOM order,
* wrapping. Done by hand because WKWebView (macOS) skips buttons, checkboxes
* and radios on Tab unless the user turned on System Settings → Keyboard →
* Keyboard navigation, which left dialogs, Settings and the connection manager
* mouse-only there. It is also the focus trap the modal dialogs lacked. A radio
* group is one stop (its checked radio), as natively; arrows move inside it.
* Returns true when it took the key.
*/
export function cycleFocus(root: Element, event: KeyboardEvent): boolean {
if (event.key !== 'Tab' || event.metaKey || event.ctrlKey || event.altKey) return false;
const stops = [...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(isTabStop);
if (!stops.length) return false;
event.preventDefault();
const at = stops.indexOf(document.activeElement as HTMLElement);
const step = event.shiftKey ? -1 : 1;
const next = at < 0 ? (step > 0 ? 0 : stops.length - 1) : (at + step + stops.length) % stops.length;
stops[next].focus();
return true;
}
function isTabStop(el: HTMLElement): boolean {
if (el.tabIndex < 0 || el.matches(':disabled')) return false;
if (!el.getClientRects().length) return false; // display:none, or inside a hidden parent
if (el instanceof HTMLInputElement && el.type === 'radio' && el.name) {
const group = [
...document.querySelectorAll<HTMLInputElement>(`input[type=radio][name="${CSS.escape(el.name)}"]`),
];
const checked = group.find((radio) => radio.checked);
return el === (checked ?? group[0]);
}
return true;
}
+16
View File
@@ -0,0 +1,16 @@
import { Injectable, signal } from '@angular/core';
/**
* "Give keyboard focus back to the main surface" signal. Dependency-free so
* dialogs can bump it from ANY window kind — in the main window the file
* panels listen (via PanelsService.focusTick); elsewhere nobody does, which
* is exactly right.
*/
@Injectable({ providedIn: 'root' })
export class FocusService {
readonly tick = signal(0);
bump(): void {
this.tick.update((n) => n + 1);
}
}
+84
View File
@@ -0,0 +1,84 @@
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
/** 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 unit = 0;
while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
value /= 1024;
unit++;
}
return `${unit === 0 ? num(value, locale) : num(Number(value.toFixed(1)), locale)} ${SIZE_UNITS[unit]}`;
}
const DATE_FORMATS = new Map<string, Intl.DateTimeFormat>();
export function formatDate(epochMs: number, locale?: string): string {
if (!epochMs) return '';
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. */
export function formatSpeed(bytesPerSec: number, unit: 'MBps' | 'Mbps', locale?: string): string {
if (unit === 'Mbps') {
return `${num(Number(((bytesPerSec * 8) / 1_000_000).toFixed(1)), locale)} Mbps`;
}
return `${formatSize(bytesPerSec, locale)}/s`;
}
/** Seconds -> "0:42" / "3:07" / "1:02:15". */
export function formatEta(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '';
const s = Math.round(seconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const mmss = `${m}:${String(sec).padStart(2, '0')}`;
return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : mmss;
}
/**
* Separator-aware join for UI-composed paths: remote and POSIX-local dirs use
* "/", a Windows-local dir ("C:\Users\x") keeps "\" so paths stay consistent.
*/
export function joinPath(dir: string, name: string): string {
const sep = dir.includes('\\') && !dir.includes('/') ? '\\' : '/';
return dir.endsWith(sep) ? dir + name : `${dir}${sep}${name}`;
}
/** "/" (POSIX) or a drive root like "C:\" — nothing above to navigate to. */
export function isRootPath(p: string): boolean {
return p === '/' || /^[A-Za-z]:[\\/]?$/.test(p);
}
/** 0o755 -> "rwxr-xr-x" */
export function formatMode(mode: number): string {
const flags = 'rwxrwxrwx';
let out = '';
for (let i = 0; i < 9; i++) {
out += mode & (0o400 >> i) ? flags[i] : '-';
}
return out;
}
+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());
}
}
+49
View File
@@ -0,0 +1,49 @@
import { expect, test } from 'bun:test';
import { APP_ICONS } from './icons';
import { PIXEL_ICONS } from './pixel-icons';
// The px grid of each size token (styles.css); lg only carries the spinner.
const GRID: Record<string, string> = { sm: '14', md: '16', lg: '20', xl: '24' };
const SKINS = `${import.meta.dir}/../skins`;
// Templates can't type-check icon names, so read them: every <ng-icon> has a
// size token, and every literal name (both sides of a [name] ternary too) is
// registered and drawn for that token's grid. Names computed in TS are typed.
test('every template icon is registered and drawn for the size it renders at', async () => {
const problems: string[] = [];
for (const file of new Bun.Glob('**/*.{html,ts}').scanSync(SKINS)) {
const src = (await Bun.file(`${SKINS}/${file}`).text()).replace(/<!--[\s\S]*?-->/g, '');
for (const [tag] of src.matchAll(/<ng-icon\b[^>]*>/g)) {
const token = tag.match(/size="var\(--icon-size-(\w+)\)"/)?.[1];
if (!token || !GRID[token]) {
problems.push(`${file}: no size token in ${tag}`);
continue;
}
const bound = tag.match(/\[name\]="([^"]*)"/)?.[1] ?? '';
// a ternary's results, not its condition ('local' in `id === 'local' ? …`)
const names = [...tag.matchAll(/\bname="(\w+)"/g), ...bound.matchAll(/[?:]\s*'(\w+)'/g)].map((m) => m[1]);
for (const name of names) {
if (!Object.hasOwn(APP_ICONS, name)) problems.push(`${file}: ${name} is not registered`);
else if (name !== 'tdesignLoading' && !name.endsWith(GRID[token])) {
problems.push(`${file}: ${name} renders at ${token} (${GRID[token]}px)`);
}
}
}
}
expect(problems).toEqual([]);
});
// A grid name's glyph is drawn on that grid (a TDesign glyph registered as
// foo14 would be a 24-unit viewBox), and the redrawn ones use plain attributes
// only: TDesign's stroke-width var or any style would bypass the grid.
test('every glyph is drawn on the grid its name says', () => {
const problems: string[] = [];
for (const [name, svg] of Object.entries(APP_ICONS)) {
const grid = name.match(/(\d+)$/)?.[1];
if (grid && !svg.includes(`viewBox="0 0 ${grid} ${grid}"`)) problems.push(`${name}: not drawn on a ${grid}px grid`);
}
for (const [name, svg] of Object.entries(PIXEL_ICONS)) {
if (/var\(|style=/.test(svg)) problems.push(`${name}: uses a CSS var or a style`);
}
expect(problems).toEqual([]);
});
+19
View File
@@ -0,0 +1,19 @@
// Single icon registry for the whole app, registered once in app.config.ts.
// A name is <icon><px>: the pixel grid the glyph is drawn for, which must match
// the size token where it renders (pixel-icons.ts). Templates can't type-check
// names and an unregistered one renders nothing (a console warning only), so
// icons.spec.ts checks every template name; TS code takes the Icon14/Icon16 types.
import { tdesignCloseCircle, tdesignLoading } from "@ng-icons/tdesign-icons";
import { PIXEL_ICONS } from "./pixel-icons";
export const APP_ICONS = {
...PIXEL_ICONS,
// TDesign's own 24px grid: as sharp as a circle and its diagonals can be
closeCircle24: tdesignCloseCircle,
// spins, so no pixel grid holds; any size
tdesignLoading,
};
export type IconName = keyof typeof APP_ICONS;
export type Icon14 = Extract<IconName, `${string}14`>;
export type Icon16 = Extract<IconName, `${string}16`>;
+27
View File
@@ -0,0 +1,27 @@
import { expect, test } from 'bun:test';
import { stepIndex } from './menu-nav';
// item, separator, item, disabled, item — the shape of a real row menu
const menu = [true, false, true, false, true];
test('a fresh menu: down lands on the first entry, up on the last', () => {
expect(stepIndex(menu, -1, 1)).toBe(0);
expect(stepIndex(menu, -1, -1)).toBe(4);
});
test('separators and disabled entries are skipped', () => {
expect(stepIndex(menu, 0, 1)).toBe(2);
expect(stepIndex(menu, 2, 1)).toBe(4);
expect(stepIndex(menu, 4, -1)).toBe(2);
});
test('wraps both ways', () => {
expect(stepIndex(menu, 4, 1)).toBe(0);
expect(stepIndex(menu, 0, -1)).toBe(4);
});
test('nothing selectable leaves the highlight alone', () => {
expect(stepIndex([false, false], -1, 1)).toBe(-1);
expect(stepIndex([false, false], -1, -1)).toBe(-1);
expect(stepIndex([], -1, -1)).toBe(-1);
});
+15
View File
@@ -0,0 +1,15 @@
/**
* Next highlighted menu index: skips unselectable entries and wraps. From -1
* (nothing highlighted yet), down lands on the first selectable entry and up
* on the LAST — stepping up from -1 directly would land one short of it.
* Returns `from` unchanged when nothing is selectable.
*/
export function stepIndex(selectable: boolean[], from: number, delta: 1 | -1): number {
const count = selectable.length;
let index = from < 0 && delta < 0 ? 0 : from;
for (let hops = 0; hops < count; hops++) {
index = (index + delta + count) % count;
if (selectable[index]) return index;
}
return from;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Rem-to-pixel conversion at the CURRENT root font size.
*
* The interface scale (settings.uiScale) works by setting the root font-size,
* so any TypeScript that positions or measures against rem-sized CSS must
* convert through the LIVE root — never through an assumed 16. Both context
* menus' flip/clamp math is the canonical consumer: their widths and row
* heights are rem in CSS, and hardcoding their px mirrors is exactly the bug
* that made the shadlike menu flip at the wrong edge once scaling landed.
*
* Read per call, not cached: the scale can change mid-session (stepper,
* Ctrl+/-) and a menu opened after that must measure against the new root.
*/
export function remPx(rem: number): number {
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
}
@@ -1,10 +1,22 @@
import { Injectable, computed, signal, type WritableSignal } from '@angular/core'; import { Injectable, computed, effect, inject, signal, type WritableSignal } from '@angular/core';
import type { ConnectionConfig, FileEntry, SavedConnection } from '@shared/types'; import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
import { fileKindKey } from './file-icons';
import { FocusService } from './focus.service';
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';
export type SortKey = 'name' | 'size' | 'mtime'; /** Any visible column sorts; Name is always available. */
export type SortKey = 'name' | ColumnKey;
/** Display string the text-based columns sort by. */
function sortText(entry: FileEntry, key: 'kind' | 'rights' | 'owner' | 'group'): string {
if (key === 'kind') return fileKindKey(entry);
if (key === 'rights') return formatMode(entry.mode);
return (key === 'owner' ? entry.owner : entry.group) ?? '';
}
interface PanelDeps { interface PanelDeps {
rpc: RpcService; rpc: RpcService;
@@ -54,6 +66,7 @@ export class PanelState {
let cmp = 0; let cmp = 0;
if (key === 'size') cmp = a.size - b.size; if (key === 'size') cmp = a.size - b.size;
else if (key === 'mtime') cmp = a.mtime - b.mtime; else if (key === 'mtime') cmp = a.mtime - b.mtime;
else if (key !== 'name') cmp = sortText(a, key).localeCompare(sortText(b, key));
if (cmp === 0) { if (cmp === 0) {
cmp = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }); cmp = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
if (key === 'name') return cmp * dir; if (key === 'name') return cmp * dir;
@@ -61,8 +74,8 @@ export class PanelState {
return cmp * dir; return cmp * dir;
}); });
const cwd = this.cwd(); const cwd = this.cwd();
if (cwd && cwd !== '/') { if (cwd && !isRootPath(cwd)) {
list.unshift({ name: '..', path: `${cwd}/..`, type: 'dir', size: 0, mtime: 0, mode: 0 }); list.unshift({ name: '..', path: joinPath(cwd, '..'), type: 'dir', size: 0, mtime: 0, mode: 0 });
} }
return list; return list;
}); });
@@ -96,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);
@@ -118,10 +146,13 @@ 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);
if (!silent) {
this.slowLoad.set(false); this.slowLoad.set(false);
this.loading.set(false); this.loading.set(false);
} }
this.refreshing = false;
}
} }
open(entry: FileEntry): void { open(entry: FileEntry): void {
@@ -132,7 +163,7 @@ export class PanelState {
up(): void { up(): void {
if (!this.cwd()) return; if (!this.cwd()) return;
void this.load(`${this.cwd()}/..`); void this.load(joinPath(this.cwd(), '..'));
} }
/** Update the filter and park the cursor on the first real match. */ /** Update the filter and park the cursor on the first real match. */
@@ -195,14 +226,16 @@ 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(() => {
const list = this.workspaces(); const list = this.workspaces();
return list.find((w) => w.id === this.activeWorkspaceId()) ?? list[0]; return list.find((w) => w.id === this.activeWorkspaceId()) ?? list[0];
}); });
private readonly focus = inject(FocusService);
/** Bumped to tell the active panel to grab keyboard focus again (e.g. after a dialog closes). */ /** Bumped to tell the active panel to grab keyboard focus again (e.g. after a dialog closes). */
readonly focusTick = signal(0); readonly focusTick = this.focus.tick;
private nextWorkspaceId = 1; private nextWorkspaceId = 1;
@@ -217,12 +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);
} }
} }
} }
}); });
// 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 }) => {
for (const panel of this.allPanels()) {
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 ----
@@ -258,7 +349,20 @@ export class PanelsService {
} }
refocus(): void { refocus(): void {
this.focusTick.update((n) => n + 1); this.focus.bump();
}
/** Each file list's key handler. A key that lands on <body> — a mouse click
* dropped focus (WebKit doesn't focus clicked buttons) — still reaches the
* active list through forwardListKey instead of dying until the next click. */
private readonly listKeys = new Map<Side, (event: KeyboardEvent) => void>();
registerListKeys(side: Side, handler: (event: KeyboardEvent) => void): void {
this.listKeys.set(side, handler);
}
forwardListKey(event: KeyboardEvent): void {
this.listKeys.get(this.activeSide())?.(event);
} }
// ---- workspace (tab) management ---- // ---- workspace (tab) management ----
@@ -307,6 +411,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);
@@ -326,8 +470,9 @@ export class PanelsService {
this.newWorkspace(); this.newWorkspace();
} }
// connectPanel resolves the panel synchronously (before awaiting), so // connectPanel resolves the panel synchronously (before awaiting), so
// firing these back-to-back binds each connection to its own tab // firing these back-to-back binds each connection to its own tab.
void this.connectPanel('left', connection, connection.name, connection.localDir); // Remote goes right, local stays left — the commander convention.
void this.connectPanel('right', connection, connection.name, connection.localDir);
} }
} }
@@ -368,7 +513,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 {
+89
View File
@@ -0,0 +1,89 @@
// Icons drawn on the pixel grid they render at. A TDesign glyph is a 24-unit
// SVG with 2-unit strokes on whole units, so at 14 or 16px every straight line
// lands between pixels and smears over two at half ink on a 1x screen. Each
// glyph here is drawn on its own grid (viewBox 0 0 N N for an N px icon, one
// unit = one CSS pixel at 100% scale) with 1px strokes, so straight lines fill
// whole pixels at 1x and 2x. The name's suffix is that grid and must match the
// size token where it renders: 14 = --icon-size-sm, 16 = -md, 24 = -xl
// (icons.spec.ts checks). Drawing rules: docs/gotchas.md.
/** One glyph on an n-px grid: 1px strokes in the text colour. */
const svg = (n: number, body: string) =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n} ${n}" fill="none" stroke="currentColor">${body}</svg>`;
// Straight lines are runs of whole pixels, in pixel coordinates (inclusive),
// and end exactly on pixel edges. Nothing relies on caps or joins: WebKit
// drops square caps on a path of several subpaths, and Skia (WebView2,
// WebKitGTK) strokes 1px lines as hairlines that leave a joined corner pixel
// three-quarters inked.
/** Pixels x0..x1 of row y. */
const h = (y: number, x0: number, x1: number) => `M${x0} ${y + 0.5}H${x1 + 1}`;
/** Pixels y0..y1 of column x. */
const v = (x: number, y0: number, y1: number) => `M${x + 0.5} ${y0}V${y1 + 1}`;
/** The outline of the box from pixel (x0, y0) to (x1, y1). */
const box = (x0: number, y0: number, x1: number, y1: number) => h(y0, x0, x1) + h(y1, x0, x1) + v(x0, y0, y1) + v(x1, y0, y1);
/** Filled shapes in the text colour. */
const fill = (...d: string[]) => `<path fill="currentColor" stroke="none" d="${d.join('')}"/>`;
/** Filled pixels (indicator dots, zipper teeth), size × size each. */
const dots = (at: [number, number][], size = 1) => fill(...at.map(([x, y]) => `M${x} ${y}h${size}v${size}h-${size}z`));
const path = (...d: string[]) => `<path d="${d.join('')}"/>`;
/** Slanted and curved strokes: anti-aliased wherever they sit, so 1.25px to read
* as heavy as the crisp 1px runs. Their ends and vertices sit on pixel centres. */
const slant = (...d: string[]) => `<path stroke-width="1.25" d="${d.join('')}"/>`;
/** A circle as path data, for slant() or fill(). A whole-pixel centre with a half-pixel
* radius puts its top, bottom and sides on pixel centres. */
const circle = (cx: number, cy: number, r: number) => `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0`;
/** A page with its top-right corner folded: the outline, the fold and the cut. */
const FILE14 = path(h(1, 2, 9), v(2, 1, 12), h(12, 2, 11), v(11, 3, 12), v(8, 1, 4), h(4, 8, 11)) + slant('M9.5 1.5l2 2');
const FILE16 = path(h(1, 2, 10), v(2, 1, 14), h(14, 2, 13), v(13, 4, 14), v(9, 1, 5), h(5, 9, 13)) + slant('M10.5 1.5l3 3');
export const PIXEL_ICONS = {
add14: svg(14, path(v(7, 3, 11), h(7, 3, 11))),
// ---- boxes ----
server14: svg(14, path(box(1, 1, 12, 5), box(1, 8, 12, 12)) + dots([[3, 3], [3, 10]])),
server16: svg(16, path(box(1, 1, 14, 6), box(1, 9, 14, 14)) + dots([[4, 3], [4, 11]])),
// TDesign's own geometry, already on the 24px grid; only the dots move onto whole pixels
server24: svg(24, '<g stroke-width="2"><rect x="3" y="3" width="18" height="7"/><rect x="3" y="14" width="18" height="7"/></g>' + dots([[5, 5], [5, 16]], 2)),
hardDrive14: svg(14, path(box(1, 1, 12, 12), h(8, 1, 12)) + dots([[3, 10], [5, 10]])),
file14: svg(14, FILE14),
file16: svg(16, FILE16),
fileText14: svg(14, FILE14 + path(h(7, 4, 9), h(9, 4, 9))),
fileText16: svg(16, FILE16 + path(h(8, 5, 10), h(11, 5, 10))),
fileAdd14: svg(14, path(h(1, 2, 9), v(2, 1, 12), h(12, 2, 6), v(11, 3, 6), v(8, 1, 4), h(4, 8, 11), v(11, 9, 13), h(11, 9, 13)) + slant('M9.5 1.5l2 2')),
fileZip16: svg(16, FILE16 + dots([[6, 2], [5, 3], [6, 4], [5, 5], [6, 6], [5, 7], [6, 8], [5, 9]])),
folder14: svg(14, path(h(2, 1, 5), h(4, 7, 12), v(1, 2, 11), v(12, 4, 11), h(11, 1, 12)) + slant('M5.5 2.5l2 2')),
folder16: svg(16, path(h(2, 1, 6), h(4, 8, 14), v(1, 2, 13), v(14, 4, 13), h(13, 1, 14)) + slant('M6.5 2.5l2 2')),
folderAdd14: svg(14, path(h(2, 1, 5), h(4, 7, 12), v(12, 4, 6), v(1, 2, 11), h(11, 1, 7), v(11, 8, 12), h(10, 9, 13)) + slant('M5.5 2.5l2 2')),
delete14: svg(14, path(h(3, 2, 12), v(3, 3, 12), h(12, 3, 11), v(11, 3, 12), v(5, 1, 3), h(1, 5, 9), v(9, 1, 3), v(7, 5, 10))),
lockOn14: svg(14, path(box(2, 6, 11, 12), v(4, 4, 5), v(9, 4, 5), h(9, 5, 8)) + slant('M4.5 4a2.5 2.5 0 0 1 5 0')),
video16: svg(16, path(box(1, 1, 14, 14), v(6, 4, 11)) + slant('M6.5 4.5L12 8L6.5 11.5')),
// ---- 45° ----
chevronDown14: svg(14, slant('M3.5 5.5l3 3 3-3')),
chevronUp14: svg(14, slant('M3.5 8.5l3-3 3 3')),
chevronRight14: svg(14, slant('M5.5 3.5l3 3-3 3')),
arrowRight14: svg(14, path(h(7, 2, 10)) + slant('M7.5 4.5l3 3-3 3')),
arrowLeft14: svg(14, path(h(7, 3, 11)) + slant('M6.5 4.5l-3 3 3 3')),
arrowUp16: svg(16, path(v(7, 3, 13)) + slant('M3.5 6.5l4-4 4 4')),
close14: svg(14, slant('M4 4l6 6M10 4l-6 6')),
check14: svg(14, slant('M3.5 6.5l3 3 5-5')),
download14: svg(14, path(v(7, 2, 8), v(2, 8, 11), v(12, 8, 11), h(11, 2, 12)) + slant('M4.5 5.5l3 3 3-3')),
code16: svg(16, slant('M4 5L1 8l3 3M12 5l3 3-3 3M9.5 2.5l-3 11')),
// ---- circles and arcs ----
checkCircle14: svg(14, slant(circle(7, 7, 5.5), 'M4.5 6.5l2 2 3-3')),
closeCircle14: svg(14, slant(circle(7, 7, 5.5), 'M5 5l4 4M9 5l-4 4')),
search14: svg(14, slant(circle(6, 6, 4.5), 'M9.5 9.5l3 3')),
setting14: svg(14, path(v(2, 4, 9), v(11, 4, 9)) + slant('M2.5 4.5L7 1.5l4.5 3M2.5 9.5L7 12.5l4.5-3', circle(7, 7, 2.5))),
setting16: svg(16, path(v(2, 4, 11), v(13, 4, 11)) + slant('M2.5 4.5L8 1.5l5.5 3M2.5 11.5L8 14.5l5.5-3', circle(8, 8, 2.5))),
refresh14: svg(14, path(v(1, 9, 12), h(9, 1, 3), v(12, 1, 4), h(4, 10, 12)) + slant('M12.47 7.58A5.5 5.5 0 0 1 2.1 9.5M1.53 6.42A5.5 5.5 0 0 1 11.9 4.5')),
refresh16: svg(16, path(v(1, 10, 13), h(10, 1, 4), v(14, 2, 5), h(5, 11, 14)) + slant('M14.46 8.68A6.5 6.5 0 0 1 2 10.5M1.54 7.32A6.5 6.5 0 0 1 14 5.5')),
// ---- the rest ----
link16: svg(16, slant('M7.7 4.8L9.23 3.23A2.5 2.5 0 0 1 12.77 6.77L11.2 8.3M8.3 11.2L6.77 12.77A2.5 2.5 0 0 1 3.23 9.23L4.8 7.7M9.5 6.5l-3 3')),
music16: svg(16, path(h(2, 4, 12), h(5, 4, 12), v(4, 2, 12), v(12, 2, 12)) + fill(circle(3, 13, 2), circle(11, 13, 2))),
image16: svg(16, path(box(1, 1, 14, 14)) + slant('M1.5 10.5l4-4 8 8', circle(10.5, 5.5, 1))),
edit14: svg(14, path(v(2, 9, 11), h(11, 2, 4)) + slant('M2.5 8.5l7-7 2 2-7 7M7.5 3.5l2 2')),
};
+55
View File
@@ -0,0 +1,55 @@
// 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' | 'settings';
/** 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';
export const APP_MODE: 'main' | 'editor' | 'manager' | 'settings' =
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
export const EDITOR_CONTEXT =
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
/** True when macOS traffic lights overlay the app's own header row (the fused
* hiddenInset layout, see chromeOptions), which reserves `pl-24` for them. */
export const IS_MAC_CHROME = IS_MAC;
/**
* WebView2 (Windows) reloads the whole page on F5 / Ctrl+R / Ctrl+F5 /
* Ctrl+Shift+R unless the page claims the key — dropping every session and any
* unsaved editor text. Every window root calls this for keys none of its
* bindings took. Known ceiling: unverified whether Electrobun already turns
* WebView2's browser keys off; harmless if it does.
*/
export function blockReloadKeys(event: KeyboardEvent): void {
if (!IS_WINDOWS || event.defaultPrevented) return;
if (event.key === 'F5' || (event.ctrlKey && event.code === 'KeyR')) event.preventDefault();
}
/**
* 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();
}
+171
View File
@@ -0,0 +1,171 @@
import { Injectable } from '@angular/core';
import { Electroview } from 'electrobun/view';
import type { AppRPC } from '@shared/rpc';
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.
const MAX_REQUEST_TIME_MS = 10 * 60_000;
export type SessionClosedEvent = { sessionId: string; reason: string };
@Injectable({ providedIn: 'root' })
export class RpcService {
/** False when the page is opened in a plain browser instead of the app. */
readonly available: boolean;
// Push messages are delivered through plain callbacks, NOT signals: a signal
// consumed from an effect that also writes signals re-triggers itself
// (this froze the app), and signal coalescing would drop progress snapshots.
private transferListeners = new Set<(s: TransferSnapshot) => void>();
private closedListeners = new Set<(e: SessionClosedEvent) => void>();
private menuListeners = new Set<(action: string) => void>();
private settingsListeners = new Set<(settings: AppSettings) => void>();
private pathRefreshListeners = new Set<(e: { sessionId: string; dir: string }) => void>();
private editorCloseListeners = new Set<() => void>();
private managerStateListeners = new Set<(open: boolean) => void>();
private connectionsListeners = new Set<() => void>();
private updateStatusListeners = new Set<(status: UpdateStatus) => void>();
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
private ready: Promise<void> = Promise.resolve();
constructor() {
this.available = typeof window !== 'undefined' && '__electrobun' in window;
if (!this.available) return;
this.rpc = Electroview.defineRPC<AppRPC>({
maxRequestTime: MAX_REQUEST_TIME_MS,
handlers: {
messages: {
transferUpdate: (snapshot) => {
for (const listener of this.transferListeners) listener(snapshot);
},
sessionClosed: (event) => {
for (const listener of this.closedListeners) listener(event);
},
menuAction: ({ action }) => {
for (const listener of this.menuListeners) listener(action);
},
settingsChanged: ({ settings }) => {
for (const listener of this.settingsListeners) listener(settings);
},
pathRefresh: (event) => {
for (const listener of this.pathRefreshListeners) listener(event);
},
editorCloseRequest: () => {
for (const listener of this.editorCloseListeners) listener();
},
managerState: ({ open }) => {
for (const listener of this.managerStateListeners) listener(open);
},
connectionsChanged: () => {
for (const listener of this.connectionsListeners) listener();
},
updateStatus: (status) => {
for (const listener of this.updateStatusListeners) listener(status);
},
},
},
});
const electroview = new Electroview({ rpc: this.rpc });
// Gate everything below on the RPC WebSocket being open (services fire calls
// at startup). v1 silently lost pre-open sends; 2.x Electroview queues them
// and falls back to the native bridge, so this is now just belt-and-braces.
const socket = electroview.hostSocket;
if (socket && socket.readyState !== WebSocket.OPEN) {
this.ready = new Promise((resolve) => {
socket.addEventListener('open', () => resolve(), { once: true });
socket.addEventListener('error', () => resolve(), { once: true });
});
}
}
onTransferUpdate(listener: (snapshot: TransferSnapshot) => void): void {
this.transferListeners.add(listener);
}
onSessionClosed(listener: (event: SessionClosedEvent) => void): void {
this.closedListeners.add(listener);
}
onMenuAction(listener: (action: string) => void): void {
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 {
this.settingsListeners.add(listener);
}
onPathRefresh(listener: (event: { sessionId: string; dir: string }) => void): void {
this.pathRefreshListeners.add(listener);
}
onEditorCloseRequest(listener: () => void): void {
this.editorCloseListeners.add(listener);
}
onManagerState(listener: (open: boolean) => void): void {
this.managerStateListeners.add(listener);
}
onConnectionsChanged(listener: () => void): void {
this.connectionsListeners.add(listener);
}
onUpdateStatus(listener: (status: UpdateStatus) => void): void {
this.updateStatusListeners.add(listener);
}
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
get request() {
const rpc = this.rpc;
if (!rpc) throw new Error('RPC is only available inside the desktop app');
const ready = this.ready;
return new Proxy({} as (typeof rpc)['request'], {
get:
(_target, method: string) =>
async (params?: unknown) => {
await ready;
const methods = rpc.request as unknown as Record<
string,
(p?: unknown) => Promise<unknown>
>;
return methods[method](params);
},
});
}
/** Fire-and-forget message proxy: `rpc.send.editorState({...})`. Waits for the bridge. */
get send() {
const rpc = this.rpc;
if (!rpc) throw new Error('RPC is only available inside the desktop app');
const ready = this.ready;
return new Proxy({} as (typeof rpc)['send'], {
get:
(_target, method: string) =>
(payload?: unknown) => {
void ready.then(() => {
const methods = rpc.send as unknown as Record<string, (p?: unknown) => void>;
methods[method](payload);
});
},
});
}
}
+204
View File
@@ -0,0 +1,204 @@
import { Injectable, computed, inject, signal } from '@angular/core';
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';
@Injectable({ providedIn: 'root' })
export class SettingsService {
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
readonly columns = signal<ColumnKey[]>(DEFAULT_SETTINGS.columns);
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
readonly uiScale = signal<number>(DEFAULT_SETTINGS.uiScale);
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency);
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
readonly editor = signal<AppSettings['editor']>(DEFAULT_SETTINGS.editor);
readonly editorApp = signal(DEFAULT_SETTINGS.editorApp);
/** Settings → Keyboard overrides; ShortcutsService applies them to the keymap. */
readonly keybindings = signal<AppSettings['keybindings']>(DEFAULT_SETTINGS.keybindings);
readonly openFilesOnEnter = signal(DEFAULT_SETTINGS.openFilesOnEnter);
// 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 i18n = inject(I18nService);
/** Saves are whole-object: one sent before the first getSettings lands would
* write these defaults over the user's settings (a ⌘+ pressed as a window
* opens would wipe every keyboard remap), so nothing persists until then. */
private loaded = false;
/** Whether the app is actually rendering dark right now (drives the Tone row). */
readonly effectiveDark = computed(
() => this.theme() === 'dark' || (this.theme() === 'system' && this.systemDark()),
);
private readonly systemDark = signal(window.matchMedia('(prefers-color-scheme: dark)').matches);
constructor() {
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => this.systemDark.set(e.matches));
if (!this.rpc.available) return;
void this.rpc.request.getSettings({}).then((res) => {
if (res.ok) this.applyAll(res.settings);
});
// editor windows: follow settings the main window saves, live
this.rpc.onSettingsChanged((settings) => this.applyAll(settings));
}
private applyAll(settings: AppSettings): void {
this.loaded = true;
this.locale.set(settings.locale);
this.theme.set(settings.theme);
this.darkTone.set(settings.darkTone);
this.columns.set(settings.columns);
this.speedUnit.set(settings.speedUnit);
this.uiScale.set(settings.uiScale);
this.transferMode.set(settings.transferMode);
this.transferConcurrency.set(settings.transferConcurrency);
this.autoClearCompleted.set(settings.autoClearCompleted);
this.editor.set(settings.editor);
this.editorApp.set(settings.editorApp);
this.keybindings.set(settings.keybindings);
this.openFilesOnEnter.set(settings.openFilesOnEnter);
this.applyTheme();
this.applyDarkTone();
this.applyUiScale();
this.applyLocale();
}
setLocale(locale: AppSettings['locale']): void {
this.locale.set(locale);
this.applyLocale();
void this.persist();
}
setTheme(theme: AppSettings['theme']): void {
this.theme.set(theme);
this.applyTheme();
void this.persist();
}
setDarkTone(tone: AppSettings['darkTone']): void {
this.darkTone.set(tone);
this.applyDarkTone();
void this.persist();
}
toggleColumn(key: ColumnKey): void {
this.columns.update((list) =>
list.includes(key) ? list.filter((c) => c !== key) : [...list, key],
);
void this.persist();
}
setSpeedUnit(unit: AppSettings['speedUnit']): void {
this.speedUnit.set(unit);
void this.persist();
}
setUiScale(scale: number): void {
this.uiScale.set(clampUiScale(scale));
this.applyUiScale();
void this.persist();
}
setTransferMode(mode: AppSettings['transferMode']): void {
this.transferMode.set(mode);
void this.persist();
}
setTransferConcurrency(count: number): void {
this.transferConcurrency.set(count);
void this.persist();
}
setAutoClearCompleted(auto: boolean): void {
this.autoClearCompleted.set(auto);
void this.persist();
}
setEditor(editor: AppSettings['editor']): void {
this.editor.set(editor);
void this.persist();
}
setEditorApp(path: string): void {
this.editorApp.set(path);
void this.persist();
}
setKeybindings(keybindings: AppSettings['keybindings']): void {
this.keybindings.set(keybindings);
void this.persist();
}
setOpenFilesOnEnter(open: boolean): void {
this.openFilesOnEnter.set(open);
void this.persist();
}
private applyTheme(): void {
const theme = this.theme();
if (theme === 'system') {
delete document.documentElement.dataset['theme'];
} else {
document.documentElement.dataset['theme'] = theme;
}
}
private applyDarkTone(): void {
const tone = this.darkTone();
if (tone === 'dark') {
delete document.documentElement.dataset['darkTone'];
} else {
document.documentElement.dataset['darkTone'] = tone;
}
}
/** The whole interface scale is ONE declaration: every size in the app is
* rem, so moving the root font-size moves the whole UI. 1 = the
* browser-default 16px root; delete-when-default keeps the pristine state
* 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 {
document.documentElement.style.fontSize = `${16 * scale}px`;
}
}
private applyLocale(): void {
this.i18n.locale.set(resolveLocale(this.locale(), navigator.language));
}
protected settings(): AppSettings {
return {
locale: this.locale(),
theme: this.theme(),
darkTone: this.darkTone(),
columns: this.columns(),
speedUnit: this.speedUnit(),
uiScale: this.uiScale(),
transferMode: this.transferMode(),
transferConcurrency: this.transferConcurrency(),
autoClearCompleted: this.autoClearCompleted(),
editor: this.editor(),
editorApp: this.editorApp(),
keybindings: this.keybindings(),
openFilesOnEnter: this.openFilesOnEnter(),
};
}
private async persist(): Promise<void> {
if (!this.loaded) return;
await this.rpc.request.saveSettings({ settings: this.settings() });
}
}
+166
View File
@@ -0,0 +1,166 @@
import { Injectable, computed, inject } from '@angular/core';
import {
type Combo,
EN_KEY_NAMES,
effectiveKeymap,
firesInTextField,
formatCombo,
isRepeatable,
type KeyEventLike,
type KeyNames,
matchesEvent,
parseCombo,
type ResolvedBinding,
type Scope,
sameCombo,
serializeCombo,
type ShortcutId,
} from '@shared/keymap';
import { I18nService } from './i18n.service';
import { IS_MAC } from './platform';
import { SettingsService } from './settings.service';
export type { ShortcutId } from '@shared/keymap';
/** A keydown as resolve() reads it: the matching fields plus where it came from. */
type KeyInput = KeyEventLike & { target?: EventTarget | null; isComposing?: boolean; keyCode?: number };
/**
* The keyboard layer over the shared keymap (src/shared/keymap.ts): resolves
* keydowns against the EFFECTIVE keymap — defaults plus the Settings → Keyboard
* remaps, live in every window — and formats the hints menus and tooltips show.
*
* - Matching is exact: ⌃F is not ⌘F, Ctrl+Tab is not Tab. `Mod` is ⌘ on
* macOS and Ctrl elsewhere.
* - On macOS the PAGE sees ⌘-keys first: WKWebView hands key equivalents to the
* page before the app menu (WebKit's WebViewImpl::performKeyEquivalent), so a
* key a window claims here (claim() → preventDefault) never reaches the menu
* bar, whose accelerators bun builds from the same table. Keys nobody claims
* fall through to it.
* - From a text field only firesInTextField() combos count (⌘S yes, Delete no).
* - Escape, the arrows inside menus and dialog Enter stay component-local:
* they are fixed, so nobody can remap themselves out of a dialog.
*/
@Injectable({ providedIn: 'root' })
export class ShortcutsService {
private readonly settings = inject(SettingsService);
private readonly i18n = inject(I18nService);
readonly keymap = computed(() => effectiveKeymap(this.settings.keybindings(), IS_MAC));
private readonly byId = computed(() => new Map(this.keymap().map((b) => [b.id, b])));
/** Localized key names for the non-mac spelling ("Strg+Entf"); reading t()
* makes every hint follow a language switch. */
private readonly names = computed<KeyNames>(() => {
const names = { ...EN_KEY_NAMES };
for (const key of Object.keys(names) as (keyof KeyNames)[]) {
names[key] = this.i18n.t(`keys.names.${key}`);
}
return names;
});
/** The command a keydown triggers in `scope`, or null. Window scopes include
* `any`; `panel` doesn't (the window root runs those). */
resolve(event: KeyInput, scope: Scope): ShortcutId | null {
// an IME's Enter commits a candidate, it is never a shortcut
if (event.isComposing || event.keyCode === 229) return null;
const typing = this.isTypingTarget(event);
for (const binding of this.keymap()) {
if (!binding.scopes.includes(scope) && (scope === 'panel' || !binding.scopes.includes('any'))) {
continue;
}
for (const combo of binding.combos) {
if (typing && !firesInTextField(combo, IS_MAC)) continue;
if (matchesEvent(combo, event, IS_MAC)) return binding.id;
}
}
return null;
}
/**
* resolve() + take the key: a match is preventDefault-ed, so the macOS menu
* never runs it a second time. A held key repeats only movement-like
* commands; any other repeat is swallowed (holding ⌘W closes one tab).
*/
claim(event: KeyboardEvent, scope: Scope): ShortcutId | null {
const id = this.resolve(event, scope);
if (!id) return null;
event.preventDefault();
return event.repeat && !isRepeatable(id) ? null : id;
}
/** Whether a keydown is one of `id`'s combos (an open menu matching its items). */
matches(id: ShortcutId, event: KeyEventLike): boolean {
return !!this.byId().get(id)?.combos.some((c) => matchesEvent(c, event, IS_MAC));
}
binding(id: ShortcutId): ResolvedBinding | undefined {
return this.byId().get(id);
}
format(combo: Combo): string {
return formatCombo(combo, IS_MAC, this.names());
}
/** The hint menus show: the command's first key, or '' when unbound. */
label(id: ShortcutId): string {
const first = this.byId().get(id)?.combos[0];
return first ? this.format(first) : '';
}
labels(id: ShortcutId): string[] {
return (this.byId().get(id)?.combos ?? []).map((c) => this.format(c));
}
/** A tooltip with the command's keys: "Delete (F8 / Del)"; bare when unbound. */
tip(text: string, id: ShortcutId): string {
const keys = this.labels(id);
return keys.length ? `${text} (${keys.join(' / ')})` : text;
}
/** A FIXED key — one no remap moves, such as Enter in the find box — in
* the platform's spelling (⇧↩ / Shift+Enter). */
fixedKey(combo: string): string {
const parsed = parseCombo(combo, IS_MAC);
return parsed ? this.format(parsed) : combo;
}
/** tip() for a fixed key. */
tipFixed(text: string, combo: string): string {
return `${text} (${this.fixedKey(combo)})`;
}
/**
* Store new combos for commands (Settings → Keyboard). Only rows that differ
* from their defaults are kept, so a reset — or a remap back to the
* default — leaves nothing behind.
*/
rebind(changes: Partial<Record<ShortcutId, Combo[]>>): void {
const next = { ...this.settings.keybindings() };
for (const [id, combos] of Object.entries(changes) as [ShortcutId, Combo[]][]) {
const defaults = this.byId().get(id)?.defaults ?? [];
const isDefault =
combos.length === defaults.length && combos.every((c, i) => sameCombo(c, defaults[i]));
if (isDefault) delete next[id];
else next[id] = combos.map(serializeCombo);
}
this.settings.setKeybindings(next);
}
resetAll(): void {
this.settings.setKeybindings({});
}
/** The platform command modifier: ⌘ on macOS, Ctrl elsewhere — exactly one.
* Mouse events too: Mod+click toggle-selects in the panels. */
isMod(event: KeyboardEvent | MouseEvent): boolean {
return IS_MAC ? event.metaKey : event.ctrlKey;
}
/** True when the event comes from something the user types into. */
isTypingTarget(event: { target?: EventTarget | null }): boolean {
const target = event.target as HTMLElement | null;
return !!target?.closest?.(
'textarea, select, [contenteditable="true"], input:not([type=checkbox]):not([type=radio]):not([type=button])',
);
}
}

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