Compare commits

...
21 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
112 changed files with 6065 additions and 9232 deletions
+8 -7
View File
@@ -1,14 +1,16 @@
# Release matrix — INERT until act_runner instances with these labels are # Release matrix — INERT until act_runner instances with these labels are
# registered on git.teknes.es. Each OS builds its own artifacts (electrobun # registered on git.teknes.es. Each OS builds its own artifacts (electrobun
# cannot cross-compile: packaging needs codesign/hdiutil on macOS and # cannot cross-compile: packaging needs codesign/hdiutil on macOS and
# rcedit/PowerShell on Windows). # rcedit/PowerShell on Windows). No mac-x64: Electrobun 2.x has no macOS x64
# core.
# #
# Runner prerequisites: # Runner prerequisites:
# macos — arm64 Mac: bun, Xcode CLT, Rosetta 2 (softwareupdate # macos — Apple Silicon Mac: bun, Xcode CLT, network to github.com and the
# --install-rosetta), network to github.com (electrobun downloads # Electrobun/Hutch release hosts (`bun install` runs `electrobun
# its per-arch core binaries on first build) # prepare`; the first build per Electrobun version fills ~/.hutch
# windows — Windows 11 x64: bun, network to github.com; WebView2 runtime is # with Hutch, the core, Cottontail and the pinned Bun, ~200 MB)
# preinstalled on Win 11 # windows — Windows 11 x64: bun, the same network access; WebView2 runtime
# is preinstalled on Win 11
# #
# Trigger: push a tag like build-62 (git tag build-$(git rev-list --count HEAD)). # Trigger: push a tag like build-62 (git tag build-$(git rev-list --count HEAD)).
name: release name: release
@@ -26,7 +28,6 @@ jobs:
fetch-depth: 0 # full history — the build number is the commit count fetch-depth: 0 # full history — the build number is the commit count
- run: bun install --frozen-lockfile - run: bun install --frozen-lockfile
- run: bun scripts/release.ts --target mac-arm64 - run: bun scripts/release.ts --target mac-arm64
- run: bun scripts/release.ts --target mac-x64
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
with: with:
name: mac name: mac
+3 -3
View File
@@ -8,6 +8,6 @@ ui/dist/
ui/.angular/ ui/.angular/
src/shared/version.gen.ts src/shared/version.gen.ts
release/ release/
# Hutch-generated (electrobun 2.x): the projected SDK devkit and build scratch
# headless screenshot workspace (scripts/shot.ts) .hutch/
.shot/ .cottontail-tmp/
+132 -38
View File
@@ -35,13 +35,15 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu
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 send — 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.
@@ -53,10 +55,10 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu
| Layer | Choice | | Layer | Choice |
| ---------- | ------------------------------------------------------------- | | ---------- | ------------------------------------------------------------- |
| Runtime | Bun (main process, all SSH/SFTP + file IO) | | Runtime | Bun (main process, all SSH/SFTP + file IO) |
| Desktop | Electrobun 1.18 (native webview, typed RPC bridge) | | Desktop | Electrobun 2.0 via Hutch (native webview, typed RPC bridge) |
| Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` | | Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` |
| Styling | Tailwind CSS v4, shadcn-style tokens (no component library) | | Styling | Tailwind CSS v4, Oxide-derived tokens (no component library) |
| Icons | @ng-icons/mynaui | | Icons | 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
@@ -83,6 +85,10 @@ ui/src/app/ Angular components + signal services
Details and diagnostics in [platform notes](docs/platform-notes.md). 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
@@ -107,22 +113,19 @@ Versioning is Sublime-style: **Build n**, where n is the commit count
`src/shared/version.gen.ts` by every build script (and `postinstall`). `src/shared/version.gen.ts` by every build script (and `postinstall`).
One artifact per invocation, on the matching host — electrobun cannot One artifact per invocation, on the matching host — electrobun cannot
cross-compile between OSes: cross-compile (Hutch builds for its own OS and arch):
```sh ```sh
bun run release -- --target mac-arm64 # on the Mac (native) bun run release -- --target mac-arm64 # on an Apple Silicon Mac
bun run release -- --target mac-x64 # on the same arm64 Mac, via Rosetta 2
bun run release -- --target win-x64 # on a Windows 11 x64 machine bun run release -- --target win-x64 # on a Windows 11 x64 machine
bun run release -- --target linux-x64 # on a Linux x64 machine bun run release -- --target linux-x64 # on a Linux x64 machine
``` ```
Artifacts land in `release/<target>/miniSCP-Build<n>-<target>.*` — a `.dmg` Artifacts land in `release/<target>/miniSCP-Build<n>-<target>.*` — a `.dmg`
per mac arch; on Windows a `-Setup.zip` installer **and** a `-portable.zip` on the Mac; on Windows a `-Setup.zip` installer **and** a `-portable.zip`
(unzip anywhere and run); on Linux a portable `.tar.gz` (unpack and run (unzip anywhere and run); on Linux Electrobun's `-Setup.tar.gz` installer.
`miniSCP.sh`, which sets the NVIDIA workaround then execs `bin/launcher`). There is no Intel Mac build: Electrobun 2.x publishes no macOS x64 core.
The mac-x64 build fetches electrobun's x64 CLI once CI: `.gitea/workflows/release.yml` runs the
into `~/.cache/miniscp/` and runs it under Rosetta so the whole pipeline
believes it is on an Intel Mac. CI: `.gitea/workflows/release.yml` runs the
same script on a macos + windows runner matrix once act_runners are same script on a macos + windows runner matrix once act_runners are
registered; trigger by pushing a `build-<n>` tag. registered; trigger by pushing a `build-<n>` tag.
@@ -137,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
@@ -177,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` send 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
@@ -220,7 +312,7 @@ application menu ([src/bun/menu.ts](src/bun/menu.ts)) is macOS-only for now;
the in-app UI covers every menu action. the in-app UI covers every menu action.
Right-click menus are **HTML, on every platform** Right-click menus are **HTML, on every platform**
([ui/src/app/context-menu.ts](ui/src/app/context-menu.ts)) rather than native — ([ui/src/app/core/context-menu.service.ts](ui/src/app/core/context-menu.service.ts)) rather than native —
electrobun's native context menu is a documented no-op on Linux, and one menu electrobun's native context menu is a documented no-op on Linux, and one menu
implementation beats two that drift. Only reveal / open-with-default / copy-path implementation beats two that drift. Only reveal / open-with-default / copy-path
cross to the backend, through the `shellItemAction` RPC. cross to the backend, through the `shellItemAction` RPC.
@@ -232,11 +324,12 @@ electrobun APIs the app uses are implemented on Windows; known issues are
upstream: upstream:
- **HiDPI blur** ([electrobun#324](https://github.com/blackboardsh/electrobun/issues/324)): - **HiDPI blur** ([electrobun#324](https://github.com/blackboardsh/electrobun/issues/324)):
WebView2 renders at 1x on scaled displays until upstream adds DPI awareness. WebView2 rendered at 1x on scaled displays on Electrobun 1.18; 2.x ships
per-monitor DPI setup — to re-check on the first 2.x Windows build.
- **Notifications** use legacy balloon style, not Windows toast. - **Notifications** use legacy balloon style, not Windows toast.
- **Unsigned binaries**: both the Setup.exe installer and the portable exe - **Unsigned binaries**: both the Setup.exe installer and the portable exe
trigger SmartScreen/Mark-of-the-Web warnings on first run. No Start Menu trigger SmartScreen/Mark-of-the-Web warnings on first run. The installer
entry or uninstaller (installer unpacks to `%LOCALAPPDATA%`). unpacks to `%LOCALAPPDATA%` and, since Electrobun 2.x, ships an uninstaller.
- `connections.json` is protected with an `icacls` current-user ACL instead of - `connections.json` is protected with an `icacls` current-user ACL instead of
POSIX 0600 (which is meaningless on Windows). POSIX 0600 (which is meaningless on Windows).
@@ -248,7 +341,8 @@ portable zip runs from any folder.
## Linux (beta) ## Linux (beta)
Linux x64 is supported as a **beta** target and builds a portable tarball. 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 One hard requirement: the AppIndicator library listed under
[Prerequisites](#prerequisites) — electrobun links it for the tray, so without [Prerequisites](#prerequisites) — electrobun links it for the tray, so without
it the app aborts at startup with `ERR_DLOPEN_FAILED` rather than merely losing it the app aborts at startup with `ERR_DLOPEN_FAILED` rather than merely losing
@@ -259,12 +353,12 @@ its tray icon.
HTML, so nothing is missing in practice. HTML, so nothing is missing in practice.
- **Tray visibility** depends on the desktop: KDE, XFCE, MATE and Cinnamon show - **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. it; GNOME 3.26+ needs the AppIndicator extension. The app works either way.
- **Unsigned and unpackaged**: no AppImage, `.deb`, desktop entry or icon - **Unsigned**, and no AppImage or `.deb` — unpack the `-Setup.tar.gz` and run
installation — unpack the tarball and run `miniSCP.sh`. its `installer`.
- **NVIDIA GPUs** need `WEBKIT_DISABLE_DMABUF_RENDERER=1` or the window renders - **NVIDIA GPUs** need `WEBKIT_DISABLE_DMABUF_RENDERER=1` or the window renders
black. Every launch path (dev scripts and the packaged `miniSCP.sh` wrapper) black. The dev scripts set it automatically when an NVIDIA driver is
sets it automatically when an NVIDIA driver is detected — see detected; an installed build needs it set by hand (e.g. in its desktop
[gotchas](docs/gotchas.md#linux) if you add a new way to launch the app. entry) — see [gotchas](docs/gotchas.md#linux).
Full dependency table, diagnostics and the first-boot smoke test: Full dependency table, diagnostics and the first-boot smoke test:
[platform notes](docs/platform-notes.md). [platform notes](docs/platform-notes.md).
@@ -272,17 +366,17 @@ Full dependency table, diagnostics and the first-boot smoke test:
## 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, Linux desktop integration (AppImage/.deb/.desktop entry — across restarts, Linux AppImage/.deb packages (the release is Electrobun's
the release is a portable tarball). Setup installer).
+13 -68
View File
@@ -5,7 +5,6 @@
"": { "": {
"name": "bunscp", "name": "bunscp",
"dependencies": { "dependencies": {
"electrobun": "1.18.1",
"ssh-config": "^5.2.0", "ssh-config": "^5.2.0",
"ssh2": "^1.17.0", "ssh2": "^1.17.0",
}, },
@@ -13,6 +12,7 @@
"@resvg/resvg-js": "^2.6.2", "@resvg/resvg-js": "^2.6.2",
"@types/bun": "latest", "@types/bun": "latest",
"@types/ssh2": "^1.15.5", "@types/ssh2": "^1.15.5",
"electrobun": "2.0.2",
"png-to-ico": "^3.0.2", "png-to-ico": "^3.0.2",
"typescript": "~5.9.3", "typescript": "~5.9.3",
}, },
@@ -21,13 +21,12 @@
"name": "ui", "name": "ui",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"7.css": "^0.21.1",
"@angular/common": "^22.0.0", "@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0", "@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0", "@angular/core": "^22.0.0",
"@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",
}, },
@@ -46,8 +45,6 @@
"@parcel/watcher", "@parcel/watcher",
], ],
"packages": { "packages": {
"7.css": ["7.css@0.21.1", "", {}, "sha512-Q+/aFUpGemoR/Zltj4a7yvHonyxfb+9cgiwBL+j/Lm+H28EOoLzWoJki7MdGPV+3mLCvKSCVqr3M2x2uNYyeWw=="],
"@algolia/abtesting": ["@algolia/abtesting@1.18.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-8siuLG+FIns1AjZ/g2SDVwHz9S+ObacDQISEJvS8XsNei1zl3FXqfqQrBpmrG7ACWCyesXHbicMJtvRbg00FEw=="], "@algolia/abtesting": ["@algolia/abtesting@1.18.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-8siuLG+FIns1AjZ/g2SDVwHz9S+ObacDQISEJvS8XsNei1zl3FXqfqQrBpmrG7ACWCyesXHbicMJtvRbg00FEw=="],
"@algolia/client-abtesting": ["@algolia/client-abtesting@5.52.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-wtwPgyPmO7b7sQPVgoK29c1VpfS08DnnJCmxX/oU1pV2DlMRJCzQcLN7JSloYpodyKHwM8+9wOzlAM0co3TDmA=="], "@algolia/client-abtesting": ["@algolia/client-abtesting@5.52.0", "", { "dependencies": { "@algolia/client-common": "5.52.0", "@algolia/requester-browser-xhr": "5.52.0", "@algolia/requester-fetch": "5.52.0", "@algolia/requester-node-http": "5.52.0" } }, "sha512-wtwPgyPmO7b7sQPVgoK29c1VpfS08DnnJCmxX/oU1pV2DlMRJCzQcLN7JSloYpodyKHwM8+9wOzlAM0co3TDmA=="],
@@ -136,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=="],
@@ -256,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=="],
@@ -310,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=="],
@@ -478,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=="],
@@ -500,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=="],
@@ -516,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=="],
@@ -586,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=="],
@@ -614,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=="],
@@ -642,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=="],
@@ -698,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=="],
@@ -740,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=="],
@@ -754,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=="],
@@ -810,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=="],
@@ -862,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=="],
@@ -906,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=="],
@@ -948,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=="],
@@ -1044,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=="],
@@ -1108,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,18 +1101,18 @@
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"electrobun/png-to-ico": ["png-to-ico@2.1.8", "", { "dependencies": { "@types/node": "^17.0.36", "minimist": "^1.2.6", "pngjs": "^6.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-Nf+IIn/cZ/DIZVdGveJp86NG5uNib1ZXMiDd/8x32HCTeKSvgpyg6D/6tUBn1QO/zybzoMK0/mc3QRgAyXdv9w=="],
"hosted-git-info/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "hosted-git-info/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"https-proxy-agent/agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
"log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@@ -1168,16 +1121,14 @@
"ora/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], "ora/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
"pac-proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
"png-to-ico/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="], "png-to-ico/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="],
"proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
"ui/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "ui/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
@@ -1190,16 +1141,10 @@
"@angular/compiler-cli/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@angular/compiler-cli/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@npmcli/git/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], "@npmcli/git/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], "@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"electrobun/png-to-ico/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
"electrobun/png-to-ico/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
+314 -291
View File
@@ -6,15 +6,21 @@ Dense reference for development and debugging. Companion files:
## Process model ## Process model
- **Bun main process** (`src/bun/`): electrobun runs it inside a WebWorker of - **Bun main process** (`src/bun/`): the native launcher runs electrobun's
its native launcher. Owns every privileged capability: filesystem, SSH, `main.js` on the Bun the 2.x devkit pins (1.4.0, shipped in the bundle —
window management, native menus/dialogs/tray, the connection/settings stores. `build.mainProcess: "bun"`), which starts our bundle in a Worker. Imports the
SDK as `electrobun/main` (projected into `.hutch/devkit/`). Owns every
privileged capability: filesystem, SSH, window management, native
menus/dialogs/tray, the connection/settings stores.
- **Webviews** (`ui/` Angular): one per window, system WebKit on macOS / - **Webviews** (`ui/` Angular): one per window, system WebKit on macOS /
WebView2 on Windows / WebKitGTK on Linux. Zero privileged access — everything WebView2 on Windows / WebKitGTK on Linux. Zero privileged access — everything
goes through RPC. goes through RPC.
- **RPC transport**: one WebSocket server in the bun process, multiplexed by - **RPC transport**: one WebSocket server in the bun process, multiplexed by
`webviewId` (each webview auto-connects using natively injected globals). `webviewId` (each webview auto-connects using natively injected globals;
Requests are typed by the `AppRPC` schema in `src/shared/rpc.ts`. packets AES-GCM-encrypted per webview). 2.x Electroview queues sends while
its `hostSocket` connects and falls back to a native bridge when the socket
is unavailable. Requests are typed by the `AppRPC` schema in
`src/shared/rpc.ts`.
### The one-schema rule ### The one-schema rule
@@ -30,8 +36,8 @@ Each `BrowserWindow` MUST receive its own `defineRPC(...)` instance: binding a
BrowserView calls `rpc.setTransport(...)`, so a shared instance would have its BrowserView calls `rpc.setTransport(...)`, so a shared instance would have its
transport stolen by the most recent window. `createAppRPC` (main), transport stolen by the most recent window. `createAppRPC` (main),
`createEditorRPC` (per editor window, hooks close over that window's record), `createEditorRPC` (per editor window, hooks close over that window's record),
`createManagerRPC` (the single manager window). `rpc.send.*` on an instance `createManagerRPC` (the single manager window), `createSettingsRPC` (the single
targets exactly its own webview. Settings window). `rpc.send.*` on an instance targets exactly its own webview.
### The `events` late-binding pattern ### The `events` late-binding pattern
@@ -39,22 +45,22 @@ targets exactly its own webview.
exist until after construction). `AppDeps.events` is a mutable object of exist until after construction). `AppDeps.events` is a mutable object of
no-op functions, reassigned in `src/bun/index.ts` after everything exists: no-op functions, reassigned in `src/bun/index.ts` after everything exists:
`sessionClosed`, `connectionsChanged`, `openEditor`, `settingsChanged`, `sessionClosed`, `connectionsChanged`, `openEditor`, `settingsChanged`,
`openConnectionManager`, `showAbout`. `openConnectionManager`, `openSettings`, `showAbout`, `applyUpdate` (the
update restart lives in index.ts beside the before-quit guard it must get past —
see build-and-release.md "Auto-updater").
## Window kinds ## Window kinds
`src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects `src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects
`__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager`), `__APP_SKIN` `__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager|settings`), `__APP_CTX`
(`modern|win7`), `__APP_CTX` before any page script. `ui/src/main.ts` selects on before any page script, plus the persisted theme attributes and interface
BOTH axes: the skin picks a component tree, `APP_MODE` picks a root within it scale so the first paint is already right. `ui/src/main.ts` picks the root
(see "UI layout" below). All roots use selector `app-root` (index.html hosts component from `APP_MODE`; all four roots use selector `app-root` (index.html
only that tag). Both globals are preload-baked because both are needed before hosts only that tag). `chromeOptions()` is the ONLY place the native frame is
first paint — settings arrive over an async RPC, far too late to choose a tree decided, spread by the main window and `openSecondaryWindow`: macOS `hiddenInset` +
without flashing the wrong UI. `chromeOptions(skin)` is the ONLY place the `trafficLightOffset {x:12,y:4}` (2.x semantics — see gotchas; the UI renders an
native frame is decided: win7 is frameless + transparent everywhere, modern `h-10 pl-24` drag-region header via `IS_MAC_CHROME`); other platforms keep the
keeps the native titlebar (macOS `hiddenInset` + `trafficLightOffset native titlebar and a compact `h-9` non-drag header.
{x:12,y:13}`, where the shadlike tree renders an `h-10 pl-24` drag-region header
via `IS_MAC_CHROME`).
`WindowRegistry<Rec>` (same file): keyed window map with dedupe/activate, `WindowRegistry<Rec>` (same file): keyed window map with dedupe/activate,
cascade placement off an anchor frame, focus tracking. electrobun's emitter cascade placement off an anchor frame, focus tracking. electrobun's emitter
@@ -62,18 +68,22 @@ has **no `off()`** — `track()` closures capture only `(this, key)` so closed
records can be garbage collected. A future "window follows window" grouping records can be garbage collected. A future "window follows window" grouping
belongs here (`anchor.on('move')` + `setPosition`). belongs here (`anchor.on('move')` + `setPosition`).
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/skins/*/editor-window/`) ### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/skins/shadlike/editor-window/`)
One window per file, key `sessionId+path` (sessionId, not fsKey: saves route One window per file, key `sessionId+path` (sessionId, not fsKey: saves route
through a concrete session, and `local` is one shared session anyway). through a concrete session, and `local` is one shared session anyway).
Re-opening focuses the existing window. The webview fetches its own content Re-opening focuses the existing window. The webview fetches its own content
(`readFile`) — `openEditor` just creates the window with ctx in the preload. (`readFile`) — `openEditor` just creates the window with ctx in the preload.
**Close safety** (electrobun window `close` is NOT cancellable — verified, no **Close safety** (designed when electrobun window `close` was NOT cancellable —
`allow` response like `before-quit` has): 1.18.1 had no `allow` response like `before-quit`; 2.x adds a cancellable
1. ⌘W (routed via the app menu → `editorCloseRequest`) / Close button → `will-close` for the titlebar close, not used yet — see gotchas):
styled in-window Save/Don't Save/Cancel; the confirm path calls 1. ⌘W (the page's own `close-window` key; a ⌘W it doesn't claim falls through
`editorClose` which sets `suppressPrompt` before `win.close()`. 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 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) 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) → native Save/Discard box from the held draft. A failed save (dead session)
@@ -89,12 +99,18 @@ to the main window (panels showing that directory refresh). Editor upgrades:
scroll-synced line gutter (hidden when wrap is on — wrapped line numbers would 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 need per-edit height measurement), Alt/⌥Z wrap toggle, custom find bar
(matches computed in TS, `setSelectionRange` + scrollTop math — deliberately (matches computed in TS, `setSelectionRange` + scrollTop math — deliberately
not `findInPage`, which is a shim on WebView2). 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`) ### Connection-manager window (`src/bun/manager-window.ts`)
Single instance, centered over main, **modal to main only** (editors stay Single instance, centered over main, **modal to main only** (editors stay
usable). No native modality exists, so three layers approximate it: bun usable). No native modality exists (re-checked on 2.0.2: no parent/owner/sheet
option or input lock in any SDK or the core's exports; `styleMask.DocModalWindow`
is only the cosmetic NSWindow bit), so three layers approximate it: bun
bounces main's `focus` event back to the manager; the main webview renders a bounces main's `focus` event back to the manager; the main webview renders a
click-swallowing overlay (`managerState` message); main drops its keyboard click-swallowing overlay (`managerState` message); main drops its keyboard
shortcuts and bun drops main-targeted menu actions while open. Connect / shortcuts and bun drops main-targeted menu actions while open. Connect /
@@ -105,6 +121,52 @@ main's `ConnectionsService` reloads; a reload-retry in `App.findConnection`
covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it
would float over editor windows). would float over editor windows).
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 ## Bun modules
- `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops + - `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops +
@@ -154,8 +216,16 @@ would float over editor windows).
atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
spread-merge on load, so NEW SETTINGS KEYS need no backend change. spread-merge on load, so NEW SETTINGS KEYS need no backend change.
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the - `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
Connect submenu mirrors the store). ⌘W is intercepted by the menu, so bun Connect submenu mirrors the store), language switch and keyboard remap.
routes `close-tab` to the focused editor window first. Labels go through **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. backend `i18n.t` and the menu is rebuilt on a locale change too.
- `i18n.ts` — backend translator (native menus + thrown error messages, which - `i18n.ts` — backend translator (native menus + thrown error messages, which
no Angular service can reach). `setLocale(setting)` caches the resolved no Angular service can reach). `setLocale(setting)` caches the resolved
@@ -171,39 +241,23 @@ would float over editor windows).
rpc; manager/editors constructed after win; quit guard; focus tracking for rpc; manager/editors constructed after win; quit guard; focus tracking for
notification suppression across all windows). notification suppression across all windows).
## UI layout: one core, two skin trees ## UI layout
``` ```
ui/src/app/ ui/src/app/
core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts, core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts,
and all 12 *.service.ts — everything skin-agnostic metrics.ts and every *.service.ts — the non-visual layer
skins/ skins/shadlike/ the component tree: four window roots (SHAD_ROOTS barrel,
shadlike/ the modern (shadcn-style) tree + shadlike.css picked by main.ts), dialogs, context menu
7like/ the Windows 7 tree + 7like.css + fonts/ + title-bar/
``` ```
Each skin owns a **complete** component tree, including its own three window Components reach `core/` through the **`@core/*`** tsconfig alias (alongside
roots, and exports a barrel (`SHAD_ROOTS` / `SEVEN_ROOTS`) with the same `@shared/*` → `src/shared`), which is deliberate: components sit at varying
`{ main, manager, editor }` shape. `main.ts` therefore knows nothing about depths and an alias keeps their imports identical wherever a file moves. Do not
either tree's internals: `APP_SKIN` picks the barrel, `APP_MODE` picks the root. replace it with relative paths.
Both trees are imported statically into one bundle — the inactive skin costs
bundle size but no runtime work, which keeps bootstrap synchronous.
Duplicate selectors across trees (`app-file-panel` exists in both) are safe: **Where a new rule goes**: behaviour → `core/`; markup and appearance → the
standalone components resolve through their importer's `imports:` array, never component; tokens → `ui/src/styles.css`.
a global registry, and the two trees are never mounted together.
Only `7like` has a `title-bar/`. The modern skin defers to the native frame,
which is exactly what `IS_MAC_CHROME` accounts for.
**Why this split was cheap**: the service layer was already skin-agnostic —
`git diff main...feat/7css-skin` touched none of it. Components reach it through
the **`@core/*`** tsconfig alias (alongside `@shared/*` → `src/shared`), which
is deliberate: skin components sit at varying depths and an alias keeps their
imports identical wherever a file moves. Do not replace it with relative paths.
**Where a new rule goes**: shared behaviour → `core/`; markup and appearance for
one look → that skin's folder; tokens both skins consume → `ui/src/styles.css`.
## UI services (`ui/src/app/core/`) ## UI services (`ui/src/app/core/`)
@@ -211,8 +265,9 @@ one look → that skin's folder; tokens both skins consume → `ui/src/styles.cs
through plain callback registries, never signals**: a signal consumed from through plain callback registries, never signals**: a signal consumed from
an effect that also writes signals re-triggers itself (this froze the whole an effect that also writes signals re-triggers itself (this froze the whole
app once), and signal coalescing drops progress snapshots. All requests are app once), and signal coalescing drops progress snapshots. All requests are
gated on the socket-open promise — calls fired during boot would otherwise gated on the socket-open promise (`electroview.hostSocket`) — on 1.18.1 calls
be silently lost. `request` and `send` are ready-gated Proxies. fired during boot were silently lost; 2.x Electroview queues them itself, so
the gate is belt-and-braces now. `request` and `send` are ready-gated Proxies.
- `panels.service.ts` — `PanelState` (per panel): sessionId, fsKey, cwd, - `panels.service.ts` — `PanelState` (per panel): sessionId, fsKey, cwd,
entries, sort, cursor, selection, type-to-filter. `sorted` computed: entries, sort, cursor, selection, type-to-filter. `sorted` computed:
filter → dirs-first sort (name/size/mtime numeric, kind/rights/owner/group filter → dirs-first sort (name/size/mtime numeric, kind/rights/owner/group
@@ -235,56 +290,106 @@ one look → that skin's folder; tokens both skins consume → `ui/src/styles.cs
enqueue and runs the collision flow (same-path auto-skip, type-mismatch enqueue and runs the collision flow (same-path auto-skip, type-mismatch
skip, overwrite dialog with all/none batch modes). skip, overwrite dialog with all/none batch modes).
- `settings.service.ts` — one signal per setting; **imperative apply pattern** - `settings.service.ts` — one signal per setting; **imperative apply pattern**
(`applyTheme/DarkTone/Accent/TextSize` set `data-*` attributes on (`applyTheme/DarkTone` set `data-*` attributes on `<html>`;
`<html>`); `applyAll` also serves the `settingsChanged` broadcast so editor `applyUiScale` sets its inline `font-size`); `applyAll` also serves the
and manager windows restyle live (they render no settings UI, so no `settingsChanged` broadcast, so every window follows a save made in any
persist echo). New setting = five touches here + types.ts (backend merges other. Every window RPC serves `saveSettings` (editor and manager windows
automatically). save too — the scale shortcuts — and the Settings window is nothing but
saves); bun's `settingsSaved()` in index.ts runs the
side effects once and rebroadcasts to every window **except the sender**, so
there is no echo to fight a held Cmd+. `applyAll` never persists. 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 - `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the
main window's panels listen. Exists so dialogs don't inject PanelsService main window's panels listen — but a panel only takes focus while NO dialog or
(whose constructor spawns a workspace — fatal in secondary windows). 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 - `connections.service.ts` — store mirror + ssh-config hosts; reloads on the
`connectionsChanged` push. `connectionsChanged` push.
- `i18n.service.ts` — runtime translator. `locale` signal + `t('ns.key', params)` - `i18n.service.ts` — runtime translator. `locale` signal + `t('ns.key', params)`
(dotted lookup, `{param}` interpolation, `{one,other}` plural maps via (dotted lookup, `{param}` interpolation, `{one,other}` plural maps via
`Intl.PluralRules`); missing keys fall back to English then the raw key. `Intl.PluralRules`); missing keys fall back to English then the raw key.
**Reactive by design**: `t()` reads the `locale` signal, so every template **Reactive by design**: `t()` reads the `locale` signal, so every template
binding that calls it re-renders live on a language switch (same call-stack binding that calls it re-renders live on a language switch (call-stack
tracking as `iconClass`) — no pipe. Also exposes locale-aware `date()`/`size()` tracking, see gotchas.md) — no pipe. Also exposes locale-aware `date()`/`size()`
/`speed()` so the file list reformats on switch. `SettingsService.applyLocale` /`speed()` so the file list reformats on switch. `SettingsService.applyLocale`
resolves the `locale` setting (`system` → `navigator.language`) into it, on resolves the `locale` setting (`system` → `navigator.language`) into it, on
boot and on the `settingsChanged` broadcast (so editor/manager windows follow). boot and on the `settingsChanged` broadcast (so editor/manager windows follow).
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always - `file-actions.service.ts` — **one file-command dispatcher**,
against an explicit panel; `menuTarget` remembers the right-clicked row. `run(id, { side, panel, entry })`, for every entry point: list keys (the
- `context-menu.service.ts` + `context-menu.ts` — the panel and tab right-click 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 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 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, platform-notes.md). The service builds the item list (row / empty-space menu,
and the header column picker) and holds the open request as a signal; the the header column picker, and the panel **source picker** — Local, drives,
component renders it at the pointer with edge-flip clamping, arrow/Enter/Escape connections under per-stack `header` items, `~/.ssh/config` hosts — which
keys, and close-on-blur/resize/scroll. Picked items are emitted verbatim as replaced a bespoke popover so it gets the same keys and look) and holds the
`ctx-*` / `col:*` / `tab:*` strings through `RpcService.emitMenuAction`, so they open request as a signal; the component renders it at a point
land in the SAME `App.handleMenuAction` dispatcher the macOS app menubar uses — (`MenuPoint`: a MouseEvent, or a row rect for a keyboard-opened menu) with
one handler, two sources. The three shell actions the webview cannot perform edge-flip clamping, a scrolling clamped box, and close-on-blur/resize/scroll.
(reveal, open-with-default, copy path) go back to bun via the `shellItemAction` Items carry `shortcut` (a keymap id: its first key is the soft hint at 60%
RPC, which is all that remains of the old native menu. 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 `tabItems()` builds the workspace-tab menu (`tab:new`, and
`tab:close|close-others|close-right|disconnect:<id>`), served by `tab:close|close-others|close-right|disconnect:<id>`), served by
`PanelsService.closeOtherWorkspaces` / `closeWorkspacesToRight` / `PanelsService.closeOtherWorkspaces` / `closeWorkspacesToRight` /
`disconnectWorkspace`. **The target id travels inside the action string**: the `disconnectWorkspace`. **The target id travels inside the action string**: the
right-clicked tab is not necessarily the active one, so nothing on that path right-clicked tab is not necessarily the active one, so nothing on that path
may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not
omitted — Win7 greys out, and a menu whose items move between right-clicks is omitted — a menu whose items move between right-clicks is worse than one with
worse than one with a dead row. 7.css hangs that state on a dead row. The disabled styling (`pointer-events: none`) only stops the
`li[role=menuitem][aria-disabled]` (opacity + `pointer-events: none`), which pointer; the keyboard path never consults it — so `pick()` and the arrow-key
the keyboard path never consults — so `pick()` and the arrow-key walker check walker check the flag themselves.
the flag themselves, in both skins.
- `file-icons.ts` — extension → category map driving glyphs, colors - `file-icons.ts` — extension → category map driving glyphs, colors
(fixed `--icon-*` tokens, never accent-tinted) and the Kind column. (fixed `--icon-*` tokens 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`, - `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
`EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`, `EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`) +
`MOD_LABEL`/`ALT_LABEL`) + `blockNativeContextMenu` (kills WebKit's `blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside text
Reload/Inspect menu outside text fields — the app draws its own menu instead). 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 - `updater.service.ts` — auto-updater state for the tab-bar pill. Signals
`state` (`idle→available→downloading→ready`|`error`), `version`, `progress`. `state` (`idle→available→downloading→ready`|`error`), `version`, `progress`.
Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on
@@ -294,215 +399,133 @@ one look → that skin's folder; tokens both skins consume → `ui/src/styles.cs
`src/bun/index.ts`; feed is `release.baseUrl` (`https://miniscp.com/dl`, the `src/bun/index.ts`; feed is `release.baseUrl` (`https://miniscp.com/dl`, the
`/dl` host in `site/`). See build-and-release.md "Auto-updater". `/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 ## Theming
`ui/src/styles.css`: shadcn-style OKLCH tokens; dark values are duplicated in `ui/src/styles.css`: OKLCH tokens adapted from **Oxide's design system**
`:root[data-theme='dark']` AND a `prefers-color-scheme` media block (system (docs.oxide.computer: a cool neutral scale at hue 260 with almost no chroma,
mode) — every dark token change goes in BOTH. Light is the zinc family; the one green accent, opaque strokes; the comments carry Oxide's step names
dark neutrals are a **warm gray family (hue 107)** anchored on `#1a1a19` n0…n1300 / green-800). Dark values are duplicated in `:root[data-theme='dark']`
(`--background`) — retoning one dark neutral means retoning them all, or warm AND a `prefers-color-scheme` media block (system mode) — every dark token
and cool surfaces clash. Surface elevation ramp, both themes: `--chrome` change goes in BOTH; retone neutrals along the one scale, or warm and cool
(frame strips) sits *below* `--background` (content panes) < `--card` surfaces clash. **There is no accent setting**: `--primary` is Oxide green per
(popovers/dialogs) < `--accent` (hover). Dark ramp `#151514` → `#1a1a19` → theme (green-800 dark, green-500 light), so selection (`bg-primary/15`), the
`#20201f` → `#2e2e2d`. The "darker" tone is true-black AMOLED: `--chrome` AND cursor row (`--cursor-tint`, a `color-mix` of `--primary`), focus rings
`--background` are both `#000` (the frame/content step collapses on purpose), (green-600) and `--success` all read green. **Surface layers**, each its own
elevation survives only via `--card`/`--accent`, and `--border`/`--input` go to shade: `--chrome` (frame — tab/transfer/status bars, the editor/manager/settings
16%/18% white because those lines then carry the whole layout. header+footer strips and sidebars), the file panel's header strip **stepping
`bg-chrome` is on the tab bar, status bar, transfer bar, the editor/manager toward the list** — session strip `--toolbar`, path bar `--toolbar-2`, column
header+footer strips and the manager's connection sidebar; panel-internal headers `--toolbar-3` (`color-mix` steps declared once in `:root`, so every
toolbars deliberately stay on `--background`. Accent tones: one theme follows) — then `--background` (file lists, editor text, forms;
`--tone-h/--tone-c` line per tone + three formula blocks derive `--primary`, `--row-alt` stripes odd file rows, skipped on the cursor/selected row so two
`--ring`, `--cursor-tint`; graphite = attribute removed = pristine zinc. `bg-*` utilities never fight) and `--card` (popovers, dialogs, toasts). The
right-click menu is the exception: the darkest surface (`bg-chrome`) behind a
faint `border-border/60`, and its highlighted item takes Oxide's selected style —
`--primary-surface` fill (Oxide surface-accent: green-200 dark, green-1200
light) with `text-primary`; mouse and keyboard share that one highlight
(`activeIndex`). The
ACTIVE panel is marked by a 0.125rem `--primary` line along its top edge (an
inset box-shadow on the session strip, so switching panels never shifts
layout). Dark: frame 0.162 < list 0.195 < header 0.275 → 0.248 → 0.221 < card
0.29; light steps greys out from near-white content: frame 0.925 < header 0.95
→ 0.965 → 0.98 < list = card 0.995. **`--accent` is translucent** (n0 at 5%
light, white 7% dark): hover/selected states sit on every layer, and an opaque
hover vanishes on a surface of its own lightness. Strokes are opaque
(`--border` n300 dark / n900 light, `--input` one step stronger). The
"darker" tone is true-black AMOLED: chrome, background, toolbar (+ its steps)
and row-alt are all `#000` (the layers collapse on purpose); elevation
survives only via `--card`/`--muted`/`--accent` (opaque there). **Square
design**: no `rounded-*` utilities and no radius tokens; an `@layer base`
block restyles native controls to match — checkboxes/radios (`appearance:
none`, a drawn check / square dot), selects (a `currentColor` caret; use
`pl-*`, never `px-*`, on a select), hidden number spinners, and square
`::-webkit-scrollbar`s (which make macOS scrollbars always visible).
`@theme inline` maps tokens to Tailwind utilities (`bg-primary`, `@theme inline` maps tokens to Tailwind utilities (`bg-primary`,
`text-icon-folder`, `bg-cursor-tint`). Selected rows are `bg-primary/15` `text-icon-folder`, `bg-toolbar-2`, `bg-cursor-tint`). File-type icon colors
(follow the accent); the cursor row uses `--cursor-tint` so graphite stays come from Oxide's hue scales (800 steps dark, 600 light). **Icon sizes** follow
pixel-identical to the old neutral hover. **Interface scale** (`settings.uiScale`, 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 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 `html { font-size: 16px * scale }`, and since EVERY size in the app is rem
(panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes, `--w7-fs` (panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes), the
and the 7.css bridge in 7like.css), both skins scale whole, chrome included. whole UI scales, chrome included.
Driven by the tab-bar stepper, Ctrl/Cmd +/−/0, and preload-baked for a 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 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 migrates old `textSize` values. TS that measures rem-sized CSS converts through
`@core/metrics` `remPx()` — never an assumed 16. `@core/metrics` `remPx()` — never an assumed 16.
### Skins (`settings.skin`: `modern | win7`)
A fifth theming axis, and the only one that is a **class** (`.win7` on
`<html>`) rather than a `data-*` attribute — `7.css/dist/7.scoped.css` is
precompiled against a literal `.win7`, so the name is not ours to pick.
Default `modern` = class absent, matching the delete-when-default rule.
`buildPreload` is the **only** writer of that class, ever — there is
deliberately no `SettingsService.applySkin`.
**Unlike the other four axes, this one is launch-time only.** It selects a
whole component tree (see "UI layout") as well as CSS, and neither half can
change on a live window: the tree is bootstrapped once from the preload-baked
`__APP_SKIN`, and `titleBarStyle`/`transparent` are `BrowserWindow` constructor
options with no setters. Clicking a skin therefore only persists it; the
options row shows the saved value as pending (`skinPending()` compares
`settings.skin()` against `APP_SKIN`) and the running UI is untouched until the
next app start.
The bun side pins this: `initSessionSkin` freezes the skin at startup, before
any window exists, and both `buildPreload` and `chromeOptions` read that pinned
value rather than the live settings snapshot. Otherwise an editor or manager
opened after a pending change would boot the *new* tree and frame while the
main window still ran the old one.
**Stylesheet split.** `ui/src/styles.css` holds only what BOTH skins consume —
the `@layer` statement, the Tailwind import, OKLCH tokens, `@theme inline`,
panel-chrome tokens, animations, the accent axis, and the `html, body` base. Each
skin's own rules live in its folder (`skins/7like/7like.css`,
`skins/shadlike/shadlike.css`) and are `@import`ed from there. `shadlike.css` is
near-empty by nature: that skin IS the shared token system plus inline Tailwind.
Two constraints: the `@layer` statement must stay the FIRST statement in
`styles.css` (it is one of only two rule types allowed above `@import`), and
`@font-face` `url()`s resolve relative to the file that declares them — which is
why 7like's Noto Sans lives at `skins/7like/fonts/`, inside the skin.
**Cascade** (`ui/src/styles.css` first lines + `skins/7like/7like.css`): the
explicit `@layer theme, base, components, utilities, skin, skinfix;` statement
precedes every import. 7.css is imported with `layer(skin)`, i.e. **above
`utilities`**,
so a bare `button` rule in 7.css outranks `rounded-md` on that same button —
that inversion is what reskins ~1900 inline utility classes with no template
edits. Consequences: (1) any utility later added to a `button`/`input`/
`select`/`textarea`/`table` silently loses under the skin; (2) neutralizers
that want the Tailwind value back must sit **inside `@layer skin`**, not
`skinfix` — `revert-layer` rolls back one layer, so from `skin` it lands on
`utilities`, but from `skinfix` it lands back on 7.css and does nothing.
`skinfix` is for rules that state an explicit value (`.w7-btn`).
**Token bridge** (`skins/7like/7like.css`): `:root.win7` re-points the existing
semantic tokens at Win7 values, so utilities Tailwind still controls come out
right too. It adds NO new tokens on purpose — a new colour would need another
`@theme inline` line. The Win7 surface ramp **inverts** the modern one:
`--background` (content panes) is Explorer white and `--chrome` is the darker
`#f0f0f0` window surface. `--radius: 5px` so `rounded-md`
(`calc(--radius - 2px)`, 74 uses) lands exactly on 7.css's 3px `--w7-el-bdr`.
`--primary`/`--ring` are deliberately left alone so the accent axis stays live;
`:root.win7[data-accent]` feeds it to `--w7-w-bg`, which is authentic (Win7
shipped "Window Color"). Dark Win7 is hand-authored (Microsoft never shipped
one) as ~20 `--w7-*` overrides, duplicated forced + system like every other
dark block. `--w7-font` is restated as a rem `font` shorthand so the interface
scale reaches it and 7.css's `pt` shorthand stops resetting weight/line-height;
the rest of 7.css's px metrics are re-expressed in rem by the scale bridge in
7like.css's `skinfix`.
**Markup hooks**: `role="progressbar"` (+aria) on the transfer bars,
`has-scrollbar` on scroll containers (7.css only styles `::-webkit-scrollbar`
under that class), `w7-toolbar` on every command row — the three window header
rows plus each file panel's session strip and path bar (their buttons go flat
with a hover glow — Explorer's command bar was flat, unlike dialog push
buttons; it only targets `button`, so the path `<input>` keeps its 7.css
textbox treatment), `w7-flat`
as the per-button escape hatch for the same treatment outside a toolbar (the
status-bar transfers summary is a text toggle, not a push button), `w7-btn`
on the 10 dialog action buttons. The flat treatment is split across two
layers on purpose: `background: revert-layer` must sit in `skin` to reach
`utilities`, while `border-color`/`box-shadow` sit in `skinfix` because 7.css
restates `border-color` at (0,3,1) and no sane `.w7-flat` selector outranks
that.
**Tabs**: the workspace strip is 7.css's real tab component —
`menu[role=tablist]` + one `button[role=tab][aria-selected]` per workspace, so
the framework draws the joined inactive tabs and the raised active one. The
selector is a DESCENDANT (`menu[role=tablist] button`), so the `+` button, the
update pill and the right-hand tools deliberately sit outside the `<menu>`.
Tabs are exempted from the `w7-toolbar` flattening via `:not([role='tab'])` —
they want the full Aero slab their neighbours are stripped of. There is no
per-tab ✕ (Win7 tab controls had none, and a nested button is invalid HTML):
closing is `Ctrl/Cmd+W` or middle-click. `role="tabpanel"` on the panel area
was considered and rejected — its `padding: 14px`, `margin-bottom: 9px` and
border fight the split-pane layout for a join that cannot read anyway with the
panel toolbar row in between.
**Frame transparency**: the rounded frame only reads as rounded because
nothing paints an opaque full-bleed ground behind it. That is not free —
`html, body` paint `var(--background)` **unlayered**, which beats every
`@layer`, so `.win7` resets them to transparent in an equally unlayered rule
next to the `font-family` one. A maximized window drops the radius (7.css
declares it three times: `.window`, `::before`, `::after` — all three must be
zeroed); the frame reads the state off its own child with
`:has(> .title-bar.is-maximized)`, which `title-bar.ts` publishes from its
existing `maximized` signal rather than subscribing to `windowState` twice.
**Selection band**: the file list's selected row is a FLAT Win7-Explorer blue
(`--w7-sel-bg`, `#cbe8f6`) with **no border** — not 7.css's `--w7-el-grad-h`,
which is its Aero element-hover ramp and stays reserved for buttons and tabs
that still want the gloss, and not its `#aaddfa` row outline, which stacked a
second edge inside the fill and, with the cursor row's dotted rect, drew three
concentric outlines on one row. Every row still reserves `border: 1px solid
transparent` for geometry; the fill paints under it (`background-clip`
defaults to `border-box`). The unfocused pane gets `--w7-sel-bg-idle`,
Explorer's grey inactive selection — the band never disappears with focus,
only weakens (the commander convention above). Both are dark-overridden in
BOTH dark blocks. The context menu's item highlight uses the same
`--w7-sel-bg`, so selection reads identically wherever it appears.
**Context menu**: pure 7.css via its real menu contract —
`ul[role=menu] > li[role=menuitem] > button` for actions,
`input[type=checkbox] + label` for the checkable column toggles, dividers as
`.has-divider` on the PRECEDING item (an `::after` bevel, not a node). The
contract is exact: an earlier iteration rendered `div > button`, matched
nothing, and wrongly concluded "7.css has no menu component" — it has a full
one (`dist/gui/menu.css`), including the gutter stripe, disabled state and
submenu arrows. Keyboard moves REAL focus between items (7.css styles
`:focus-visible` like `:hover`); Enter/Space activate buttons natively. Only
menu.css's hardcoded light literals are restated for dark, in `skinfix` — plus
three overrides keyed on the app's own `w7-ctx` marker: `position: fixed`
(7.css's `position: relative` on `ul[role=menu]` had been silently winning),
the flat `--w7-sel-bg` highlight in place of its gradient wash, and
`content: none` on the item buttons' pseudo-elements, which kills both the
1s Aero afterglow and the infinite `pulse-anim` on a focused item (see
gotchas.md). `scripts/menu-probe.ts` regression-tests placement AND highlight
flatness, each with a control that proves the check can fail.
**Font**: Segoe UI is absent off Windows and not redistributable. Noto Sans —
the substitute 7.css itself names — is vendored (OFL-1.1, from
`@fontsource/noto-sans`) into `ui/src/fonts/`; under `src/` and not `public/`
so esbuild emits it hashed into `media/` and rewrites the `url()`.
**Window chrome**: `chromeOptions(skin)` in `src/bun/windows.ts` is the single
place either skin's frame is decided, spread by both the main window and
`openSecondaryWindow`. `win7` uses `titleBarStyle: "hidden"` on **every**
platform — that clears `Titled` from the Cocoa style mask, which is also the
only way to remove the macOS traffic lights. **Linux honours it too**: the GTK
wrapper calls `gtk_window_set_decorated(false)` and logs "GTK: Created window
without decorations (custom titlebar)" (verified on GNOME/Wayland), so the
frameless Aero chrome is not a mac/win-only feature. The webview then draws the whole
frame via `ui/src/app/skins/7like/title-bar/title-bar.ts` (7.css's `.title-bar` >
`.title-bar-text` + `.title-bar-controls`, buttons keyed by `aria-label`),
mounted by all three of that skin's window roots. Because `titleBarStyle` is
fixed at construction, **a skin change only reaches the frame on the next app
start** — and nothing about the skin flips live, deliberately. `IS_WIN7_CHROME` in
`platform.ts` reads the preload-baked `__APP_SKIN` (settings resolve too late
for layout) and forces `IS_MAC_CHROME` false, since there are no traffic
lights to reserve `pl-24` for.
**Window resize (STOPGAP)**: undecorating the window also removes the WM's
resize borders, and electrobun 1.18.1 has no native resize gesture — so
`skins/7like/window-resize/` synthesizes one: eight handles overlaying the frame
drag it through `windowGetFrame`/`windowSetFrame` → `BrowserWindow.setFrame`.
Mounted by all three win7 roots, hidden while maximized by a CSS `:has()` rule
(no second `windowState` subscription), and every handle carries
`electrobun-webkit-app-region-no-drag` or the drag region would move the window
instead. The frame math is a dependency-free module so
`scripts/resize-math-probe.ts` can assert all eight edges without a window.
**This is meant to be deleted**: the component header carries the removal
checklist and `scripts/check-electrobun-resize.ts` (wired into `postinstall`)
prints it when upstream ships the primitive. See platform-notes.md.
**Window controls**: new `windowMinimize` / `windowToggleMaximize` /
`windowClose` / `windowIsMaximized` requests, built by
`windowControlHandlers(getWindow, onClose?)` in `rpc-handlers.ts` and served by
all three window kinds (each passing a late-bound getter for its own window —
the window takes the rpc in its constructor, same reason `events` is
late-bound). `onClose` is overridden per window kind so the X reuses the
existing close path: editors run their dirty prompt, the manager runs
`managerClose`, main falls through to the cancellable `before-quit` guard.
electrobun emits **no maximize event**, so `trackMaximizeState` derives it from
`resize` + the synchronous `isMaximized()` and pushes `windowState` — which
also covers Aero Snap and Win+Up. Double-click-to-maximize is likewise absent
from electrobun's drag regions and is implemented in the title-bar component.
+115 -69
View File
@@ -10,73 +10,105 @@ electrobun}`). It is regenerated by `postinstall` and at the start of every
records, hence gitignored. Fresh checkouts fail `tsc` until `bun install` has records, hence gitignored. Fresh checkouts fail `tsc` until `bun install` has
run once (postinstall creates the file). `BRANDING.version` stays run once (postinstall creates the file). `BRANDING.version` stays
semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel` semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel`
("Build n") is what humans see (About box, options popover). ("Build n") is what humans see (About box, Settings → About).
## Dev loop ## Dev loop
- `bun run dev` — gen-version → scripts/dev.ts: starts `ng serve` (HMR) and - `bun run dev` — gen-version → `electrobun prepare` → scripts/dev.ts: starts
`electrobun dev --watch`, with `DEV_SERVER_URL` pointing the webviews at the `ng serve` (HMR) and `electrobun dev --watch`, with `DEV_SERVER_URL` pointing
dev server (only honored on the dev channel and only if the server answers). the webviews at the dev server (only honored on the dev channel and only if
- `bun run build` — gen-version → Angular build → `electrobun build` the server answers).
(**dev channel** — fine for boot checks, NOT a release). - `bun run build` — gen-version → `electrobun prepare` → Angular build →
`electrobun build` (**dev channel** — fine for boot checks, NOT a release).
- `scripts/node-version-shim.ts` (preloaded via `bunfig.toml`, root AND ui/) - `scripts/node-version-shim.ts` (preloaded via `bunfig.toml`, root AND ui/)
fakes a Node version the Angular CLI accepts under Bun. Both preloads are fakes a Node version the Angular CLI accepts under Bun. Both preloads are
load-bearing. load-bearing.
- **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 ## Release builds
`bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64|linux-x64]` `bun scripts/release.ts [--target mac-arm64|win-x64|linux-x64]`
(default: host). (default: host).
Steps: gen-version → gen-icons → Angular build → `electrobun build Steps: gen-version → electrobun prepare → gen-icons → Angular build → empty
--env=stable` → copy/rename artifacts into `artifacts/` → `electrobun build --env=stable` → copy/rename the installers into
`release/<target>/miniSCP-Build<n>-<target>.*`. `release/<target>/miniSCP-Build<n>-<target>.*`.
Facts the script encodes: Facts the script encodes:
- **`--env=stable` is mandatory for releases** — the CLI defaults to the dev - **`--env=stable` is mandatory for releases** — `electrobun build` defaults to
channel (different app name `miniSCP-dev`, no distributable artifacts). the dev channel (different app name `miniSCP-dev`, no distributable artifacts).
- **No OS cross-compile.** The CLI builds for its host (its `targets` config - **No cross-compile, and no Intel Mac.** Hutch builds for its host OS *and*
field is unread in 1.18.1); packaging needs host tools (codesign/hdiutil on arch only; packaging needs host tools (codesign/hdiutil on macOS,
macOS, rcedit/PowerShell on Windows). Hence one script run per artifact, on rcedit/PowerShell on Windows). Hence one script run per artifact, on the
the matching machine; `win-x64` on a non-Windows host and `linux-x64` on a matching machine; `win-x64` on a non-Windows host and `linux-x64` on a
non-Linux host both error out. non-Linux host both error out. **Electrobun 2.x publishes no macOS x64 core
- **mac-x64 from the arm64 Mac (Rosetta)**: the script downloads electrobun's (nor an x64 Hutch)**, so mac-x64 was dropped — the v1 trick of running the x64
x64 CLI binary once (`electrobun-cli-darwin-x64.tar.gz` from the GitHub CLI under Rosetta has nothing left to run. `--target mac-x64`, and any build
release matching the installed version, cached at on an Intel Mac, fail with that message.
`~/.cache/miniscp/electrobun-cli-x64-<ver>/`) and runs it directly — - **Runtime downloads**: on first use per Electrobun version Hutch fetches the
Rosetta translates it, the CLI sees `process.arch === "x64"`, downloads the platform core from `github.com/blackboardsh/electrobun/releases`
`dist-macos-x64` core into the electrobun package dir on first use, and (SHA-256-checked against `electrobun-artifacts.json`), plus Cottontail and the
packages a genuine x64 app. Note: Bun's `fetch`→`Bun.write` streaming hung pinned Bun, into `~/.hutch` — network is a build-time requirement on every
on the GitHub redirect chain; the script uses `curl -fsSL` deliberately. fresh machine/runner.
- **Core binary downloads**: the installed npm package ships mac-arm64 - **`artifacts/` is emptied before the build** — `site/deploy.sh` publishes it
binaries only; other `dist-<os>-<arch>` cores are fetched from verbatim, so it must hold exactly this build (stale files, e.g. v1-named
`github.com/blackboardsh/electrobun/releases` at build time — network to installers, would otherwise ship).
GitHub is a build-time requirement on every fresh machine/runner. - **Artifact shapes** (stable channel, in `artifacts/`): 2.x installers carry
- **Artifact shapes** (stable channel, in `artifacts/`): macOS → **no channel prefix**; only the updater feed keeps `stable-<os>-<arch>-` (the
`stable-macos-<arch>-miniSCP.dmg` + `.app.tar.zst` + `update.json` names v1.18.1+ clients request). macOS → `macos-arm64-miniSCP.dmg` +
(updater feed); Windows → `stable-win-x64-miniSCP-Setup.zip` `stable-macos-arm64-miniSCP.app.tar.zst` + `stable-macos-arm64-update.json`
(self-extracting installer → `%LOCALAPPDATA%`) plus the script's own (verified); Windows → `win-x64-miniSCP-Setup.zip` (setup exe + hidden payload)
**portable zip** made from the assembled app folder under + the `stable-win-x64-*` feed, plus the script's own **portable zip** of
`build/stable-win-x64/` (layout to confirm on the first real Windows build — `build/stable-win-x64/miniSCP` (warns instead of failing if that folder is
the step warns instead of failing); Linux → only missing); Linux → `linux-x64-miniSCP-Setup.tar.gz` (a self-extracting
`stable-linux-x64-miniSCP.tar.zst` + `update.json`, i.e. the updater feed and `installer` + README) + the `stable-linux-x64-*` feed. The official Linux
nothing installable, so the script's **`.tar.gz` of the assembled app folder** installer IS the release artifact — the v1 portable tarball and its
under `build/stable-linux-x64/` IS the release artifact (same best-effort `miniSCP.sh` NVIDIA wrapper are gone, so an installed build on NVIDIA needs
warn). No AppImage/.deb/.desktop — unpack and run the `miniSCP.sh` wrapper the `WEBKIT_DISABLE_DMABUF_RENDERER=1` from the user (see gotchas.md). The
script writes beside `bin/launcher` (it applies the NVIDIA/DMABUF workaround, Windows/Linux names come from the 2.x docs — confirm on the first real 2.x
which the app cannot set for itself — see gotchas.md). 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 - **Linux builds need the AppIndicator dev libraries present to RUN** (not to
build) — see platform-notes.md; a packaged tarball started on a machine build) — see platform-notes.md; an installed build started on a machine
without them dies at `dlopen`. without them dies at `dlopen`.
- The script filters artifacts by app name, so stale artifacts from older - The script filters artifacts by app name, so stale artifacts from older
brandings don't get collected. brandings don't get collected.
- **v1 → 2.x bridge**: Windows users on 0.1.78 (the only feed live on
miniscp.com/dl) update through their v1 updater because `app.name`,
`app.identifier` and `release.baseUrl` are unchanged, the build is
`--env=stable`, and `artifacts/` is published unrenamed. 2.x keeps the v1
top-level `update.json` fields (`version`/`hash`/`platform`/`arch`) and the
physical `stable` state root, so user data stays put. Unverified until the
first 2.x Windows release reaches a v1 install.
## CI (Gitea Actions) ## CI (Gitea Actions)
`.gitea/workflows/release.yml` — tag-triggered (`build-*`), macos + windows `.gitea/workflows/release.yml` — tag-triggered (`build-*`), macos (mac-arm64) +
runner matrix, each running the same release script; inert until act_runners windows (win-x64) runner matrix, each running the same release script; inert
with those labels are registered. Needs `fetch-depth: 0` (the build number is until act_runners with those labels are registered. Needs `fetch-depth: 0` (the
the commit count). Runner prereqs are commented in the workflow file. build number is the commit count); `bun install --frozen-lockfile` already
prepares the devkit (postinstall). Runner prereqs are commented in the workflow
file.
## Download host (`site/` + Caddy) ## Download host (`site/` + Caddy)
@@ -84,27 +116,31 @@ the commit count). Runner prereqs are commented in the workflow file.
internal box (plain HTTP `:80`, no domain/TLS). Deploys to `/srv/miniscp/`. internal box (plain HTTP `:80`, no domain/TLS). Deploys to `/srv/miniscp/`.
- `site/index.html` — pure static, no JS, tinygrad-minimal. Download buttons link to **fixed - `site/index.html` — pure static, no JS, tinygrad-minimal. Download buttons link to **fixed
stable filenames** (`/dl/stable-macos-arm64-miniSCP.dmg`, …) so the page never changes per stable filenames** (`/dl/macos-arm64-miniSCP.dmg`, `/dl/win-x64-miniSCP-Setup.zip`, the portable
release. Logo/screenshot `<img>` slots are commented in, images live in `site/assets/`. zip) so the page never changes per release. 2.x dropped the `stable-` prefix from installer
names (the feed kept it), so the v1 `stable-…-Setup.zip`/`.dmg` installers still on the server
are stale — delete them by hand. Logo/screenshot `<img>` slots are commented in, images live in
`site/assets/`.
- `site/Caddyfile` — one flat `/dl/*` folder with `file_server browse` + range requests, and - `site/Caddyfile` — one flat `/dl/*` folder with `file_server browse` + range requests, and
per-type `Cache-Control`: `*.patch` immutable (unique hashed names), `*update.json` `no-cache` per-type `Cache-Control`: `*.patch` immutable (unique hashed names), `*update.json` `no-cache`
(updater must see new versions), installers (`*.dmg *.zip *.tar.zst`) `max-age=300` (stable (updater must see new versions), installers (`*.dmg *.zip *.tar.zst *.tar.gz`) `max-age=300`
names overwritten each release). Landing page has **no** SPA fallback → unknown paths 404. (stable names overwritten each release). Landing page has **no** SPA fallback → unknown paths 404.
Path matcher gotcha: use suffix globs like `*update.json`, not `*/*update.json`. Path matcher gotcha: use suffix globs like `*update.json`, not `*/*update.json`.
- **One flat folder, no per-build dirs** — this layout IS electrobun's `artifacts/` feed, so it - **One flat folder, no per-build dirs** — this layout IS electrobun's `artifacts/` feed, so it
doubles as the (future) updater `baseUrl`. Publishing = drop this machine's `artifacts/stable-*` doubles as the updater `baseUrl`. Publishing = drop this machine's whole `artifacts/` into
into `/dl/`; installers overwrite in place, `.patch` files accumulate (never delete them, or `/dl/`, unrenamed; installers overwrite in place, `.patch` files accumulate (never delete them,
older installs lose their incremental-update trail). **Tradeoff:** only the latest full or older installs lose their incremental-update trail). **Tradeoff:** only the latest full
installer is downloadable; old full versions are overwritten. installer is downloadable; old full versions are overwritten.
- `site/deploy.sh` — run per build machine after `scripts/release.ts`; copies `artifacts/stable-*` - `site/deploy.sh` — run per build machine after `scripts/release.ts`; copies `artifacts/*`
and renames the build-numbered win portable zip to the stable `stable-win-x64-miniSCP-portable.zip`. verbatim and renames the build-numbered win portable zip to the stable
`MINISCP_DL_DIR` overrides the target (stage locally, then rsync to the server). `stable-win-x64-miniSCP-portable.zip`. `MINISCP_DL_DIR` overrides the target (stage locally,
then rsync to the server).
- `release.baseUrl = https://miniscp.com/dl` is set in `electrobun.config.ts`; `miniscp.com` is - `release.baseUrl = https://miniscp.com/dl` is set in `electrobun.config.ts`; `miniscp.com` is
fronted by an external proxy for TLS (Caddy itself stays on `:80` behind it). The value is baked fronted by an external proxy for TLS (Caddy itself stays on `:80` behind it). The value is baked
into `version.json` at build time, so only builds made after it was added check for updates. into `version.json` at build time, so only builds made after it was added check for updates.
With `generatePatch` (default true), release builds now need **build-time network to With `generatePatch` (default true), release builds need **build-time network to
`miniscp.com/dl`** so electrobun can fetch the currently-published version to diff a `.patch` `miniscp.com/dl`** so Hutch can fetch the currently-published version to diff a `.patch` (a
(the first build after enabling it has nothing to diff and just ships the full artifacts). target with no published feed yet — currently mac and Linux — just ships the full artifacts).
## Auto-updater (app side) ## Auto-updater (app side)
@@ -116,16 +152,21 @@ Wired on top of the download host above. See the "Auto-updater" section in
`Updater.onStatusChange` streams `download-progress` (0–100) → `updateStatus{downloading}`. `Updater.onStatusChange` streams `download-progress` (0–100) → `updateStatus{downloading}`.
`checkForUpdate` self-guards the `dev` channel, so `bun run dev` never shows the pill. `checkForUpdate` self-guards the `dev` channel, so `bun run dev` never shows the pill.
- **RPC** ([src/shared/rpc.ts](../src/shared/rpc.ts)): requests `checkForUpdate` / `downloadUpdate` / - **RPC** ([src/shared/rpc.ts](../src/shared/rpc.ts)): requests `checkForUpdate` / `downloadUpdate` /
`applyUpdate` (handlers in [rpc-handlers.ts](../src/bun/rpc-handlers.ts) call the electrobun `Updater`); `applyUpdate` (handlers in [rpc-handlers.ts](../src/bun/rpc-handlers.ts) call the electrobun `Updater`;
push message `updateStatus`. `applyUpdate` goes through `events.applyUpdate` in index.ts); push message `updateStatus`.
- **`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 - **UI**: `UpdaterService` ([ui/src/app/core/updater.service.ts](../ui/src/app/core/updater.service.ts)) holds signals
(`state`/`version`/`progress`); the tab-bar "update pill" ([tab-bar.ts](../ui/src/app/skins/7like/tab-bar/tab-bar.ts)) (`state`/`version`/`progress`); the tab-bar "update pill" ([tab-bar.ts](../ui/src/app/skins/shadlike/tab-bar/tab-bar.ts))
is one accent (`bg-primary`) control that announces → fills as a progress bar while downloading → is one accent (`bg-primary`) control that announces → fills as a progress bar while downloading →
"Restart to apply". `state` is `available → downloading → ready` (ready derived from the "Restart to apply". `state` is `available → downloading → ready` (ready derived from the
`downloadUpdate` response). App-menu "Check for Updates…" ([menu.ts](../src/bun/menu.ts)) routes a `downloadUpdate` response). App-menu "Check for Updates…" ([menu.ts](../src/bun/menu.ts)) routes a
`check-for-updates` menu action into `UpdaterService.check()`. `check-for-updates` menu action into `UpdaterService.check()`.
- **Unsigned is fine:** `applyUpdate()` strips the macOS quarantine xattr before relaunch, so the - **Unsigned was fine on 1.18.1:** `applyUpdate()` stripped the macOS quarantine xattr before
swapped bundle isn't Gatekeeper-blocked. relaunch. 2.x hands the swap to a native post-exit helper (transactional replace, restores the
old app on failure) — re-check Gatekeeper on the first unsigned 2.x mac update.
## Icons ## Icons
@@ -137,8 +178,13 @@ Rerun after touching either SVG; outputs are committed (build inputs).
## electrobun config notes ## electrobun config notes
`electrobun.config.ts`: app identity comes from `BRANDING`; `build.bun.external` `electrobun.config.ts`: app identity comes from `BRANDING`; **`build.mainProcess:
must keep `["cpu-features", "*.node"]` (ssh2's optional native accelerators — "bun"` is load-bearing** — 2.x defaults to Cottontail, which ignores the whole
bundling them breaks the build; runtime falls back to pure JS); `build.bun` block (verified: without the line, `bunx electrobun config
`build.copy` ships the Angular dist and tray assets into `views://`; --env=stable` resolves `mainProcess: "cottontail"` with no externals);
`bundleCEF: false` everywhere (system WebKit / WebView2). `build.bun.external` must keep `["cpu-features", "*.node"]` (ssh2's optional
native accelerators — bundling them breaks the build; runtime falls back to pure
JS; a 2.x stable bundle keeps both as runtime `require`s); `build.copy` ships the
Angular dist and tray assets into `views://`; `bundleCEF: false` everywhere
(system WebKit / WebView2). `bunx electrobun config --env=stable` prints the
merged config Hutch will build with.
+255 -237
View File
@@ -20,25 +20,15 @@ touching the adjacent code.
`[class.shadow-[...]]` (brackets) does NOT parse — use `[style.box-shadow]`. `[class.shadow-[...]]` (brackets) does NOT parse — use `[style.box-shadow]`.
Static class attributes may use arbitrary values freely. Static class attributes may use arbitrary values freely.
- Functions called from templates ARE reactive: signals read inside them - Functions called from templates ARE reactive: signals read inside them
register on the view's consumer (call-stack-based tracking). That's why register on the view's consumer (call-stack-based tracking). That's why a
`iconClass(entry)` reacting to `settings.iconColors()` works. template calling `i18n.t()` re-renders on a language switch — `t()` reads
the `locale` signal.
- Dedicated tools > shell: IDE diagnostics between paired template/class - Dedicated tools > shell: IDE diagnostics between paired template/class
edits are often stale mid-sequence — trust `bun run build:ui` (full AOT edits are often stale mid-sequence — trust `bun run build:ui` (full AOT
type-check), not the first red squiggle. type-check), not the first red squiggle.
- Tailwind v4 canonical class names: `wrap-break-word` not `break-words`, - Tailwind v4 canonical class names: `wrap-break-word` not `break-words`,
`accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`. `accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`.
Every color used in a template must resolve through `@theme inline`. Every color used in a template must resolve through `@theme inline`.
- **The win7 skin layer outranks Tailwind utilities.** 7.css is imported
`layer(skin)`, ordered above `utilities`, so any utility you add to a
`button`/`input`/`select`/`textarea`/`table` is silently dead under that
skin. If it matters, neutralize in the `@layer skin` block in
`app/skins/7like/7like.css` — and note `revert-layer` only reaches
`utilities` from **inside `skin`**; used from `skinfix` it falls back onto
7.css and is a no-op. This bit once already.
- `@import … layer(name)` works through `@tailwindcss/postcss` (it wraps the
imported AST), but the **deep path is mandatory**: 7.css ships no `exports`
map and Tailwind's resolver reads only `style`/`mainFields`, so a bare
`@import "7.css"` resolves to nothing. Use `7.css/dist/7.scoped.css`.
- Stylesheet `url()` must resolve at BUILD time: assets referenced from - Stylesheet `url()` must resolve at BUILD time: assets referenced from
`styles.css` live under `ui/src/` (esbuild emits them hashed into `media/` `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 and rewrites the url), NOT `ui/public/` — a public path fails the build with
@@ -53,6 +43,64 @@ touching the adjacent code.
- **A component `template:` is a TS template literal, so no backticks inside - **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 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. 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) ## The interface scale (root font-size lever)
@@ -64,149 +112,73 @@ touching the adjacent code.
`webviewGetPageZoom` returns a hardcoded `1.0` from `.rodata`, so the failure `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 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. 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 - **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 root font-size, so a px value freezes while everything around it grows. The
three px reservoirs were: the panel-chrome tokens (`--row-h` etc.), 93 px reservoirs were the panel-chrome tokens (`--row-h` etc.) and 93
`text-[11px]` arbitraries, and 7.css itself — 370 px values, zero rem, with `text-[11px]` arbitraries. 1px hairlines and focus rects stay px deliberately
`--w7-font` reaching only 15 rules' typography. 7like.css's `skinfix` carries (crisp at any scale).
a scale bridge re-expressing 7.css's vars and hardcoded box metrics in rem.
1px hairlines and focus rects stay px deliberately (crisp at any scale; the
`tr` border exists to stop selection shifting rows).
- **Restating a 7.css rule in `skinfix` also outranks 7.css's VARIANTS of that
rule — by layer, regardless of specificity.** The title-bar-controls
restatement silently shrank the close button from 48px to 29px (7.css widens
it via `button[aria-label=Close]`), and the tablist padding flattened the
active tab's 4px raise. Caught only by the harness pixel diff. When bridging
a rule, grep 7.scoped.css for every variant that overrides the same
properties and restate those too.
- **px constants in TS that mirror rem-sized CSS are latent scale bugs.** The - **px constants in TS that mirror rem-sized CSS are latent scale bugs.** The
shadlike context menu's `MENU_W = 224` was a hardcoded mirror of `w-56` 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 (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, `@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 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. `charWidth` is cached keyed on the computed font string for the same reason.
## Hover states and the frame edge
- **A child's box-shadow is NOT clipped by its parent's border or radius.** The
close button's hover glow (7.css: `0 0 7px 3px #e68e75` on a `::before`)
painted across the window's top border and onto the desktop beyond it — the
caption buttons sit flush against the top edge (`border-top: 0`), and the
frame's rounded transparent corners clip nothing. Fixed with
`clip-path: inset(0 0 -1rem 0)` on `.w7-frame > .title-bar`: glow stops at
the frame like the real DWM, bottom bleed (authentic) stays open. Note
clip-path also clips hit-testing — safe here because the drag region is the
title-bar's own box, entirely inside the clip.
- **Hover states CAN be tested headlessly**: `scripts/hover-probe.ts` copies
the built stylesheet with `:hover` rewritten to `.simhover`, renders the
frame markup with the state forced, and pixel-scans artifact regions,
exiting non-zero on hover-introduced red outside the frame. Firefox
`--screenshot` has no pointer and `import -window` captures the real
pointer's state, so the class rewrite is the only programmatic hover on this
machine. Re-run it after touching title-bar or hover styling.
## The two skin trees (`ui/src/app/skins/`)
- **`.win7` is not a name we may change.** `7.scoped.css` is precompiled with
every selector prefixed by that literal class, so the skin id in
`AppSettings` stays `"win7"` even though its folder is `7like/`. The folder
names are cosmetic; the class and the persisted setting value are not.
- **Reach `core/` through the `@core/*` alias, never a relative path.** Skin
components sit at varying depths and get moved; the alias is what let 67
imports survive two relocations untouched. A `../../core/...` path is a
future merge conflict.
- **`@font-face` `url()` resolves relative to the file that declares it.** When
the win7 rules moved three directories deeper, `./fonts/...` would silently
have 404'd — build still green, app just renders a fallback font. The fonts
therefore live inside the skin that uses them.
- **The `@layer` statement must stay the first statement in `ui/src/styles.css`.**
It is one of only two rule types allowed above `@import`, and it is what sorts
`skin` above `utilities`. Every `@import` (Tailwind and both skin sheets) must
also precede all other rules.
- **Duplicate selectors across skins are safe.** `app-file-panel` exists in both
trees; standalone components resolve through their importer's `imports:`
array, never a global registry, and the trees are never mounted together.
- **The build DOES type-check components nothing imports.** ngtsc compiles the
whole program, so a skin that is currently unreachable still fails
`bun run build:ui` on a type or template error. Verified deliberately by
injecting an error into an unreferenced file — worth knowing before assuming
a green build proves nothing about the inactive skin.
## The win7 skin (7.css)
- **Unlayered declarations beat every `@layer`, regardless of specificity.**
`html, body { background: … }` in styles.css is unlayered, so no `skinfix`
rule can override it — the win7 transparent-corner reset has to be unlayered
too. The frame's rounded corners only read as rounded because nothing paints
an opaque full-bleed ground behind them; anything that does fills the corners
and the window becomes a rectangle with a curve drawn on it.
- **7.css restates `border-color` at (0,3,1)** via
`.win7 button:not(:disabled):not(:hover)` and its `:hover` twin. A two-class
selector like `.win7 .w7-flat` (0,2,0) loses to it, so paint suppression for
flat buttons lives in `skinfix` where layer order settles it. The
`.w7-toolbar` arm only ever won by tying on specificity and landing later in
the same layer — an accident `w7-flat` did not share, which is why it
rendered a bordered slab while its neighbours went flat.
- **`overflow-x: auto` also clips vertically.** CSS refuses to pair `visible`
with a non-visible axis, so the computed `overflow-y` becomes `auto` too.
7.css raises the active tab with `margin-top: -2px`; inside the horizontally
scrolling tab strip that put its top border outside the scroll box and
deleted it silently. The strip reserves `padding-top: 2px` to compensate —
mirroring the `padding-left: 3px` 7.css already reserves for the same tab's
`-3px` left margin.
- **7.css hardcodes light-mode literals outside its own var system** —
`color: #222` on tablist buttons, black/white bevels in the ListView and
menu. Each one has to be routed through a `--w7-*` token declared in
`:root.win7` and overridden in **both** dark blocks (the `[data-theme]`
attribute one *and* the `prefers-color-scheme` one; system dark sets no
attribute).
- **The skin layer can also override `position`, and that breaks LAYOUT, not
just paint.** `ul[role=menu]` carries `position: relative` in 7.css (for the
gutter stripe's `::before` and submenu anchoring), which outranked Tailwind's
`.fixed` on the app's own context menu. `[style.left/top.px]` then became
*flow* offsets from the menu's static position — the last child of the
`h-screen` frame — so every right-click menu rendered below the viewport,
unreachable behind `overflow: hidden`. The backdrop is a plain `<div>`, which
7.css does not match, so it stayed genuinely fixed and ate the next click:
the symptom was "right-click does nothing, then the app ignores me once".
Fixed with a `position: fixed` restatement in `skinfix`, scoped to a `w7-ctx`
marker class so 7.css's own menus keep theirs. Regressed in `0ae2a62`, when
the menu root became a real `ul[role=menu]` — i.e. adopting the framework's
markup is what exposed it. **A resting pixel diff cannot catch this class of
bug**: nothing about the idle window changes. `scripts/menu-probe.ts` renders
the menu at declared coordinates and scans whether it landed there, with a
marker-less control menu as its own discriminator.
- **7.css's button effects reach anything that IS a button — including menu
items — and one of them fades out over a FULL SECOND.** `button::before` is
an Aero glow overlay with `opacity: 0`, raised to 1 on hover, and
`button:not(:hover):before` carries `transition: opacity 1s linear`.
menu.css suppresses it with `content: none` on the **`:hover` arm only**, so
while an item is hovered nothing paints, but the moment the pointer leaves,
the pseudo returns with opacity animating 1 → 0: a blue ghost trailing the
cursor down the menu. Killing `content` in *every* state is the fix —
suppressing the transition alone leaves a glow that simply snaps. Two more
from the same family: `button:focus` runs `pulse-anim` *infinitely*
(`1s ease infinite alternate`), which matters because the context menu moves
REAL focus between items for keyboard nav, and the hover paint is 7.css's
translucent gradient wash — the same one the ListView selection dropped.
All three are overridden together, scoped to `w7-ctx`.
- **Its selectors are exact, and a near-miss looks exactly like "7.css doesn't
support this".** `menu[role=tablist] button` is a DESCENDANT selector, so
every button inside the strip becomes a tab — the `+` and the right-hand
tools must live outside it. `.window.glass > .title-bar` is direct-child, so
an inner wrapper breaks the glass coupling. A wrong guess here already
produced one hand-written lookalike that was deleted.
## Electrobun ## Electrobun
- **Window `close` is not cancellable** (response type `{}`; only the - **The `electrobun` npm package (2.x) is a bootstrap whose every export is a
app-level `before-quit` supports `event.response = {allow:false}`). All throwing stub** ("Electrobun 2.x APIs come from the Hutch devkit, not
unsaved-state guards must either intercept app-controlled close paths node_modules"). Every `electrobun/*` import must resolve through the
(⌘W, buttons) or survive the close (editor draft mirroring). 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 - **One rpc instance per window** — BrowserView binding steals the rpc's
transport; a shared instance silently sends to the wrong webview. transport; a shared instance silently sends to the wrong webview.
- **RPC before socket open is silently lost.** Everything gates on the - **RPC before socket open was silently lost on 1.18.1.** 2.x Electroview queues
socket-open promise (RpcService `ready`). sends while its `hostSocket` (v1: `bunSocket`) is CONNECTING and falls back to
the native bridge; RpcService still gates everything on the socket-open
promise (`ready`) as belt-and-braces.
- The emitter has no `off()`. Window event closures must not capture records - The emitter has no `off()`. Window event closures must not capture records
you want garbage collected. you want garbage collected.
- `electrobun build` defaults to the **dev** channel; release builds need - `electrobun build` defaults to the **dev** channel; release builds need
@@ -218,12 +190,65 @@ touching the adjacent code.
- `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are - `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are
single-character only. single-character only.
- **`ContextMenu.showContextMenu` is a silent no-op on Linux** (electrobun's own - **`ContextMenu.showContextMenu` is a silent no-op on Linux** (electrobun's own
`dist/api/bun/proc/linux.md` says so; the app also prints "Application menus are `.hutch/devkit/api/sdks/main/proc/linux.md` says so, still in 2.0.2; the app
not supported on Linux" at boot). Because the UI suppresses the webview's own also prints "Application menus are not supported on Linux" at boot). Because
right-click menu, that left EVERY panel context menu dead on Linux — no error, the UI suppresses the webview's own right-click menu, that left EVERY panel
nothing happens. The menu is plain HTML now (`ui/src/app/skins/*/context-menu.ts`) on context menu dead on Linux — no error, nothing happens. The menu is plain
every platform; don't reintroduce the native one for "nativeness" on mac/win HTML now (`ui/src/app/skins/shadlike/context-menu.ts`) on every platform;
without keeping a Linux path. 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 ## Linux
@@ -243,12 +268,15 @@ touching the adjacent code.
- **The app cannot apply this to itself.** WebKit reads the variable when it - **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 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 child processes** (verified: a spawned child sees it unset). Setting it in
`src/bun/index.ts` — even in a module imported before `electrobun/bun` — `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. does nothing. It must be in the environment before the app process starts.
- Hence every launch path injects it: `scripts/linux-gpu.ts` (`launchEnv()`) - Only the dev launch paths inject it: `scripts/linux-gpu.ts` (`launchEnv()`)
for `bun run dev` / `bun run start`, and the `miniSCP.sh` wrapper that for `bun run dev` / `bun run start`. **A new dev launch path must call
`release.ts` writes beside `bin/launcher` in the tarball. **A new launch `launchEnv()` or it will render black on NVIDIA.** The release is the
path must call `launchEnv()` or it will render black on NVIDIA.** 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 - Gated on `/proc/driver/nvidia` so Intel/AMD keep hardware compositing, and
an explicit `WEBKIT_DISABLE_DMABUF_RENDERER` from the user always wins. 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 - A "does it boot?" log check does NOT catch this — grep for the GBM line, or
@@ -266,90 +294,63 @@ touching the adjacent code.
pattern appears in the killing command's OWN argv. Break the literal with a 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` character class — `pkill -f 'dev-linux-x[6]4'` — the way `ps | grep [x]yz`
has always worked. has always worked.
- **A frameless window cannot be resized, and `preventDefault` will not save - **Launching the app steals focus — ask first.** `bun run start` opens a real
you from the drag region.** Undecorating the GTK window (what window that takes focus, which interrupts whatever the user is doing; there
`titleBarStyle: "hidden"` does) takes the WM's resize borders with it, and is no Xvfb/xvfb-run on this machine and Wayland offers no
electrobun exposes no resize gesture — hence the synthesized handles in launch-without-focus. Batch every live check into a single launch.
`ui/src/app/skins/7like/window-resize/` (a STOPGAP; that file carries the
removal checklist, and `scripts/check-electrobun-resize.ts` fires from
`postinstall` when upstream makes it deletable). Three traps live in that
code, all verified against `dist/api/bun/preload/dragRegions.ts`:
- It starts a window **move** from a `mousedown` listener on `document`, in
the **bubble** phase. It never reads `defaultPrevented`, so
`event.preventDefault()` does **nothing** to stop it.
- A `pointerdown` handler does not stop it either: the compatibility
`mousedown` still fires afterwards, and stopping propagation on a *pointer*
event has no effect on a *mouse* event. This is the trap that looks fixed
in every test until the window jumps.
- `isAppRegionDrag` checks `.electrobun-webkit-app-region-no-drag` **first**
and short-circuits, so that class — on the handle itself — is the only
reliable suppressor. Without it, every resize drag also drags the window.
Also: deltas must come from `screenX/screenY`. North/west resizes move the
window's origin, which moves the webview's client origin under a stationary
pointer, so `clientX` feeds that movement back into the next delta and the
window runs away. And the minimum-size clamp must re-anchor the opposite
edge — clamping the width alone lets `x` keep tracking the pointer, walking
the window off-screen while it looks frozen (`scripts/resize-math-probe.ts`
asserts exactly that, and was verified to catch it).
- **Prefer the headless probes over launching the app.** `bun run start` opens a
real window that takes focus, which interrupts whatever the user is doing;
there is no Xvfb/xvfb-run on this machine and Wayland offers no
launch-without-focus. `scripts/shot.ts` (+ `--dark`), `hover-probe.ts` and
`menu-probe.ts` all render through headless Firefox and open nothing. Launch
the real app only for what Gecko genuinely cannot answer — WebKitGTK
behaviour, the native frame, transparency, live RPC — ask first, and batch
every live check into a single launch.
- **X11 capture cannot answer "is this pixel transparent?"** for the frameless
window. `import -window` grabs the window's own pixels with no compositing,
so a transparent corner and an opaque black one both come out black;
`png32:` just fills alpha with 1 and proves nothing. XWayland's root window
is not the composited desktop, so `import -window root` fails too. Frame
transparency and the maximized corners need a human looking at the screen —
say so rather than reporting a screenshot as confirmation.
- Benign startup noise on Linux — non-fatal, the app runs normally: - Benign startup noise on Linux — non-fatal, the app runs normally:
`libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib` `libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib`
(upstream electrobun's choice, not ours). (upstream electrobun's choice, not ours).
- **Window-open flash prevention is three-layer** — remove any one and the
white/inverted blink returns: (1) windows are created `hidden: true` and ## Keyboard (`src/shared/keymap.ts`, `ui/src/app/core/shortcuts.service.ts`)
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
fallback), (2) the bun preload bakes the persisted `data-theme`/tone/accent - **WKWebView skips buttons, checkboxes and radios on Tab** unless the user
attributes before first paint (`buildPreload`), (3) `ui/src/index.html` turned on System Settings → Keyboard → Keyboard navigation (or presses ⌥Tab).
carries an inline `<style>` with the html background per theme — its oklch Dialogs, Settings and the manager were mouse-only on a default Mac. Tab is
values MUST stay in sync with the `--background` tokens in styles.css. walked by hand: `cycleFocus(root, event)` (core/focus-cycle.ts) — use it in
The win7 skin adds a **fourth value to keep in sync**: `buildPreload` also any new dialog or window.
bakes the `.win7` class, and index.html carries a matching `html.win7` arm - **WebKit doesn't focus a clicked button — focus drops to `<body>`** (Windows
for all four theme states. Miss it and every win7 window flashes white. parks it on the button, where Space clicks it again). Every list key died
- **The skin is a LAUNCH-TIME choice. Never apply one to a running window.** until the list was clicked. `App` `(mousedown)` keeps focus on chrome-button
This shipped broken once: `SettingsService.applySkin` toggled the `.win7` clicks and forwards `<body>` keys to the active list; keep both.
class the moment the setting changed, which was harmless when there was one - **Match modifiers exactly.** The old panel switch caught every Tab, so
component tree and became a broken window as soon as there were two — 7.css Ctrl+Tab flipped the active panel AND switched tabs; `isMod = metaKey ||
painted onto shadlike markup that has no `.window.glass`, no `.title-bar`, no ctrlKey` made ⌃F (Cocoa's forward-one-character) open the editor's find.
`menu[role=tablist]` and no `<table>` ListView. Each skin's CSS is written `matchesEvent` compares all four modifiers, platform-exact.
against its OWN tree's markup; the class must only ever be written by - **AltGr is Ctrl+Alt on Windows/Linux.** A Ctrl+Alt+letter binding steals
`buildPreload`, for the tree that window actually booted. "@", "€", "ł"… — never a default there, never fired from a text field, and
- **A reload does not rescue it.** The preload is a persistent an event with `getModifierState('AltGraph')` matches nothing.
`WebKitUserScript` injected at DOCUMENT_START, so its string is frozen at - **Type-to-filter takes every printable key.** A plain-letter binding (vim
webview construction — `webview.loadURL()` re-runs it with the OLD j/k) must resolve BEFORE it — and then that letter no longer starts a query
`__APP_SKIN`. (`updatePreloadScriptToWebView` exists in the FFI but is (Settings warns). While a query is being typed, printable keys (Space too)
reachable only from electrobun's internal `<electrobun-webview>` handler.) always extend it.
- **`titleBarStyle`/`transparent` are construction-time only** — no setters - **In a master-detail list, selection must not follow focus** when the detail
exist, so a live window can never be re-framed. win7 → modern is the is an unsaved form: the connection manager's `edit()` replaces the draft
dangerous direction: the modern tree draws no title bar, so it would land without asking, so arrowing through the list would silently discard edits.
in a frameless transparent window with no way to move, minimize or close. Arrows move focus; Enter loads.
- Don't "fix" this by recreating the main window either: `exitOnLastWindowClosed` - **A dialog's Esc must `stopPropagation`.** The connection manager closes on
is true, the main `rpc` is captured by six push closures, the tray captures a window-level Escape; the dialog cleared its own signal first, so the
`win`, listeners cannot be removed (no `off()`) — and it would drop every window handler saw "no dialog open" and closed the whole window.
session and tab. - **An unclaimed key behind a dialog reaches the macOS menu** — ⌘T opened a
- Bun pins it: `initSessionSkin` freezes the skin before any window exists, tab behind the rename prompt. The main window swallows window commands and
and `buildPreload`/`chromeOptions` read that, never the live settings. Skip the menu bar's panel commands while a dialog or menu is up.
the pin and a window opened after a pending change boots the other skin. - **WebView2 reloads the page on unclaimed F5 / Ctrl+R** (default browser
- Dev vs prod URL: `getMainViewUrl()` resolves the ng-serve dev server only accelerators), dropping every session and the editor's unsaved text. Every
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the window root ends with `blockReloadKeys`; a blanket `stopPropagation` (the old
server answers a HEAD request; secondary windows must reuse the resolved path bar) hides keys from it — don't add one.
URL, never hardcode `views://`. - **Hold-to-repeat** fired ⌘W once per repeat — every tab closed. `claim()`
- Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`): repeats only movement-like commands (`isRepeatable`).
rare segfault under long sessions; upstream electrobun×Bun issue, restart - **Electrobun's native menu accelerator turns a combo with no modifier token
recovers, nothing app-side to fix. 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 ## Domain logic
@@ -368,6 +369,16 @@ touching the adjacent code.
- Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so - Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so
new keys auto-default, but the UI SettingsService reads field-by-field — new keys auto-default, but the UI SettingsService reads field-by-field —
a new setting silently stays at default until wired in all five spots. a new setting silently stays at default until wired in all five spots.
- **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); - The editor draft mirror sends full content per tick (2 MB cap upstream);
don't shrink the 400 ms debounce. don't shrink the 400 ms debounce.
- `panel.touched` gates localDir auto-navigation; `connectToken` guards - `panel.touched` gates localDir auto-navigation; `connectToken` guards
@@ -406,7 +417,14 @@ touching the adjacent code.
breaks `ng`. breaks `ng`.
- ssh2's optional native deps must stay `external: ["cpu-features", "*.node"]` - ssh2's optional native deps must stay `external: ["cpu-features", "*.node"]`
in `electrobun.config.ts` — bundling them breaks the build; ssh2 falls back in `electrobun.config.ts` — bundling them breaks the build; ssh2 falls back
to pure JS at runtime. to pure JS at runtime. (Inert without `mainProcess: "bun"` — see Electrobun.)
- **2.x installers dropped the `stable-` prefix; the updater feed kept it.**
`macos-arm64-miniSCP.dmg` / `win-x64-miniSCP-Setup.zip` /
`linux-x64-miniSCP-Setup.tar.gz` next to `stable-<os>-<arch>-update.json`,
`.tar.zst` and `.patch`. Anything that globs `artifacts/stable-*` misses the
installers — release.ts collects by `<os>-<arch>-`, deploy.sh publishes
`artifacts/*` verbatim (release.ts empties `artifacts/` first), and the site
links the new names.
- `bun run --cwd ui build` (flag order matters; `bun --cwd ui run build` does - `bun run --cwd ui build` (flag order matters; `bun --cwd ui run build` does
not do what you want). not do what you want).
- `version.gen.ts` is gitignored and regenerated by postinstall + every build - `version.gen.ts` is gitignored and regenerated by postinstall + every build
+47 -56
View File
@@ -1,31 +1,35 @@
# Platform notes — macOS / Windows / Linux behavior matrix # Platform notes — macOS / Windows / Linux behavior matrix
macOS is the primary target; Windows 11+ x64 and Linux x64 are **beta** targets macOS (Apple Silicon) is the primary target; Windows 11+ x64 and Linux x64 are
(win-arm64 and linux-arm64 deferred). Every platform seam carries a **beta** targets (win-arm64 and linux-arm64 deferred). **Intel Macs were
`PORTABILITY:` comment at the branch site. 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) ## Seam inventory (where platform branches live)
| Concern | File | mac | win | linux | | Concern | File | mac | win | linux |
|---|---|---|---|---| |---|---|---|---|---|
| Window chrome (modern skin) | `src/bun/windows.ts`, `index.ts` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win | | Window chrome | `src/bun/windows.ts chromeOptions` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
| Window chrome (win7 skin) | `src/bun/windows.ts chromeOptions` | `hidden` (clears Titled → no traffic lights) | frameless | `gtk_window_set_decorated(false)` | | Scrollbars | `ui/src/styles.css` (`::-webkit-scrollbar`) | custom square, always visible (styling them turns off overlay scrollbars) | custom square | custom square |
| Window **resize** | `ui/.../7like/window-resize/` | native frame resizes; win7 frameless uses the synthesized handles | as mac | as mac |
| External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` | | External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` |
| Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any | | Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any |
| Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` | | Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` |
| Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty | | Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty |
| chmod/Permissions | context-menu.service.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full | | chmod/Permissions | context-menu.service.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full |
| Shell wording | `ui context-menu.service.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager | | Shell wording | `ui context-menu.service.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
| Shortcut labels | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ | | Shortcut labels | `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) | | Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored (needs AppIndicator, see below) |
| App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — About lives in the options popover | none (unsupported upstream) | | 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 | | connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 |
| App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config | | App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config |
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK | | ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" | | Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
The **context menu is HTML on every platform** (`ui/src/app/skins/*/context-menu.ts` + 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 `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 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. the default app, copy path) cross to bun, via the `shellItemAction` RPC.
@@ -33,27 +37,6 @@ the default app, copy path) cross to bun, via the `shellItemAction` RPC.
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
(`path.posix` throughout `fs/sftp.ts`). (`path.posix` throughout `fs/sftp.ts`).
### Frameless windows lose the WM's resize borders (win7 skin, every platform)
`titleBarStyle: "hidden"` is what lets 7.css draw the Aero caption — on Linux the
GTK wrapper honours it with `gtk_window_set_decorated(false)`. Undecorating also
removes the window manager's resize borders and corner grips, and electrobun
1.18.1 offers **no** native resize gesture to put back: the shipped
`libNativeWrapper` exports `startWindowMove`/`stopWindowMove` with no resize
counterpart, does not link `gtk_window_begin_resize_drag`, and `BrowserWindow`
has neither `setResizable` nor min-size constructor options. The window is still
*programmatically* resizable (`setFrame`/`getFrame` work, and
`gtk_window_set_resizable(false)` is never called), so the app **synthesizes the
input affordance**: eight handles in the webview drag the native frame over RPC
(`ui/src/app/skins/7like/window-resize/`).
That is a **stopgap** — each drag tick is an RPC hop plus an FFI call, where a
real WM grab is compositor-side. `scripts/check-electrobun-resize.ts` watches the
installed electrobun for the missing primitives and prints the removal checklist
when they appear; it runs from `postinstall`, so an electrobun bump surfaces it
without anyone remembering to look. The modern skin keeps the native frame and
needs none of this.
## Memory footprint (expected, not a bug) ## Memory footprint (expected, not a bug)
Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB
@@ -103,7 +86,8 @@ ldd build/dev-linux-x64/*/bin/libNativeWrapper.so | grep 'not found'
`libasar.so => not found` in that output is **expected and harmless** — it ships `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 in the same `bin/` folder and resolves at load time; only an unresolved
`libayatana-appindicator3.so.1` is fatal. `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 **Tray visibility** is a separate matter from linking: GNOME 3.26+ hides the
legacy tray, so the icon needs the legacy tray, so the icon needs the
@@ -114,23 +98,26 @@ either way — the tray is a convenience, and every action it offers exists in-a
## Linux known issues (upstream electrobun) ## Linux known issues (upstream electrobun)
- **No native menus of any kind.** Application menus and `showContextMenu` are - **No native menus of any kind.** Application menus and `showContextMenu` are
both no-ops (electrobun's `dist/api/bun/proc/linux.md`; the app logs 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 "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, 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 so nothing is missing at the UI level — but do not add a feature that depends
on a native menu. on a native menu.
- **NVIDIA renders black without the DMABUF workaround.** WebKitGTK can't - **NVIDIA renders black without the DMABUF workaround.** WebKitGTK can't
allocate GBM buffers on the proprietary driver, so the window paints nothing allocate GBM buffers on the proprietary driver, so the window paints nothing
while the log looks perfectly healthy. Every launch path sets while the log looks perfectly healthy. The dev launches
`WEBKIT_DISABLE_DMABUF_RENDERER=1` when `/proc/driver/nvidia` exists — (`scripts/linux-gpu.ts`) set `WEBKIT_DISABLE_DMABUF_RENDERER=1` when
`scripts/linux-gpu.ts` for dev, the `miniSCP.sh` wrapper in the tarball for `/proc/driver/nvidia` exists; an installed build (the official Setup
releases. The app can't do it itself (Bun's `process.env` writes don't reach installer) does not — launch it with that variable set (e.g. in the desktop
child processes); see [gotchas.md](gotchas.md#linux). 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 - **Benign startup noise**: a deprecation warning pointing at
`libayatana-appindicator-glib`. Not fatal. `libayatana-appindicator-glib`. Not fatal.
- **Unsigned, unpackaged**: the release artifact is a portable tarball — no - **Unsigned; Electrobun's own installer**: the release artifact is
AppImage, `.deb`, `.desktop` entry or icon installation. Unpack and run `linux-x64-miniSCP-Setup.tar.gz` — a self-extracting `installer` + README
`miniSCP.sh` (which execs `bin/launcher`). (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 ## First-Linux-boot smoke test
@@ -152,21 +139,24 @@ either way — the tray is a convenience, and every action it offers exists in-a
(temp download → save → auto-upload) with the inotify watcher. (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. 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 7. `bun scripts/release.ts --target linux-x64` produces
`release/linux-x64/miniSCP-Build<N>-linux-x64.tar.gz`; unpack it elsewhere and `release/linux-x64/miniSCP-Build<N>-linux-x64-Setup.tar.gz`; unpack it
run `miniSCP.sh`. elsewhere, run its `installer`, then launch the installed app (on NVIDIA with
`WEBKIT_DISABLE_DMABUF_RENDERER=1`).
## Windows known issues (upstream electrobun, documented not fixed) ## Windows known issues (upstream electrobun, documented not fixed)
- **HiDPI blur** — WebView2 renders at 1x on scaled displays - **HiDPI blur** — WebView2 renders at 1x on scaled displays
(blackboardsh/electrobun#324, open). Most user-visible defect; wait for (blackboardsh/electrobun#324, seen on 1.18.1). Most user-visible defect; 2.x
upstream DPI awareness. lists per-monitor DPI setup among its Windows fixes — re-check on the first
2.x Windows build.
- **Notifications** are legacy balloon style (`Shell_NotifyIcon`), not - **Notifications** are legacy balloon style (`Shell_NotifyIcon`), not
Windows toasts. Windows toasts.
- **Unsigned binaries** — Setup.exe and the portable exe both trigger - **Unsigned binaries** — Setup.exe and the portable exe both trigger
SmartScreen / Mark-of-the-Web on first run. No Authenticode support in SmartScreen / Mark-of-the-Web on first run. No Authenticode support (2.x:
electrobun 1.18.1 (signtool post-hook or electrobun-builder are options "Windows release signing is not currently part of Hutch's packaging
later). Installer unpacks to `%LOCALAPPDATA%`, desktop shortcut only — no pipeline"; a signtool post-hook is an option later). Installer unpacks to
Start Menu entry or uninstaller. `%LOCALAPPDATA%`, desktop shortcut only; 2.x installer builds add an
uninstaller (`%LOCALAPPDATA%\<identifier>\<install-root>\uninstall.exe`).
- `hiddenInset` degrades to `hidden` on Windows — irrelevant here because - `hiddenInset` degrades to `hidden` on Windows — irrelevant here because
non-mac windows use the native titlebar. non-mac windows use the native titlebar.
- `findInPage` is a `window.find()` shim on WebView2 — irrelevant here, the - `findInPage` is a `window.find()` shim on WebView2 — irrelevant here, the
@@ -177,10 +167,11 @@ either way — the tray is a convenience, and every action it offers exists in-a
Code paths written blind (no Windows box was available); verify in order: Code paths written blind (no Windows box was available); verify in order:
1. Build: `bun scripts/release.ts --target win-x64` on the Windows machine — 1. Build: `bun scripts/release.ts --target win-x64` on the Windows machine —
core binaries download from GitHub releases on first run; Setup.zip AND Hutch, the core and the pinned Bun download into `~/.hutch` on first run;
the portable zip appear under `release/win-x64/` (the portable step warns Setup.zip AND the portable zip appear under `release/win-x64/` (the portable
instead of failing if the assembled-app folder layout differs — fix the step warns instead of failing if the assembled-app folder layout differs —
`appDir` path in release.ts if so). fix the `appDir` path in release.ts if so). A v1 0.1.78 install must pick up
the published 2.x build through its updater (the bridge release).
2. App boots, tray icon renders colored, window has a native titlebar. 2. App boots, tray icon renders colored, window has a native titlebar.
3. Drives dropdown in each panel lists `C:\` (+ others); navigating to a 3. Drives dropdown in each panel lists `C:\` (+ others); navigating to a
drive root hides the `'..'` row; the path bar shows consistent `\` paths. drive root hides the `'..'` row; the path bar shows consistent `\` paths.
@@ -195,7 +186,7 @@ Code paths written blind (no Windows box was available); verify in order:
7. Ctrl+T/W/F/S and Ctrl+digit tab jumps; tooltips read "Ctrl+…". 7. Ctrl+T/W/F/S and Ctrl+digit tab jumps; tooltips read "Ctrl+…".
8. connections.json ACL: `icacls connections.json` shows only the current 8. connections.json ACL: `icacls connections.json` shows only the current
user after saving a connection. user after saving a connection.
9. Editor/manager windows open, modal bounce works, About shows from the 9. Editor/manager/Settings windows open, modal bounce works, Ctrl+, opens
options popover. Settings and About shows from its About section.
10. Known-issue confirmation: HiDPI blur on a scaled display (expected until 10. Known-issue check: HiDPI blur on a scaled display (seen on 1.18.1 — 2.x's
upstream fixes it). per-monitor DPI setup may have fixed it).
+3
View File
@@ -16,6 +16,9 @@ export default {
baseUrl: "https://miniscp.com/dl", baseUrl: "https://miniscp.com/dl",
}, },
build: { build: {
// Load-bearing: 2.x defaults to Cottontail, which ignores the whole
// `bun` block below — the ssh2 externals included.
mainProcess: "bun",
bun: { bun: {
entrypoint: "src/bun/index.ts", entrypoint: "src/bun/index.ts",
// ssh2's optional native accelerators must never be bundled; its // ssh2's optional native accelerators must never be bundled; its
+5 -5
View File
@@ -7,16 +7,15 @@
"ui" "ui"
], ],
"scripts": { "scripts": {
"dev": "bun scripts/gen-version.ts && bun scripts/dev.ts", "dev": "bun scripts/gen-version.ts && bunx electrobun prepare && bun scripts/dev.ts",
"dev:ui": "bun run --cwd ui start", "dev:ui": "bun run --cwd ui start",
"build:ui": "bun run --cwd ui build", "build:ui": "bun run --cwd ui build",
"build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build", "build": "bun scripts/gen-version.ts && bunx electrobun prepare && bun run build:ui && bunx electrobun build",
"start": "bun scripts/gen-version.ts && bun run build:ui && bun scripts/start.ts", "start": "bun scripts/gen-version.ts && bunx electrobun prepare && bun run build:ui && bun scripts/start.ts",
"postinstall": "bun scripts/gen-version.ts && bun scripts/check-electrobun-resize.ts", "postinstall": "bun scripts/gen-version.ts && bunx electrobun prepare",
"release": "bun scripts/release.ts" "release": "bun scripts/release.ts"
}, },
"dependencies": { "dependencies": {
"electrobun": "1.18.1",
"ssh-config": "^5.2.0", "ssh-config": "^5.2.0",
"ssh2": "^1.17.0" "ssh2": "^1.17.0"
}, },
@@ -24,6 +23,7 @@
"@resvg/resvg-js": "^2.6.2", "@resvg/resvg-js": "^2.6.2",
"@types/bun": "latest", "@types/bun": "latest",
"@types/ssh2": "^1.15.5", "@types/ssh2": "^1.15.5",
"electrobun": "2.0.2",
"png-to-ico": "^3.0.2", "png-to-ico": "^3.0.2",
"typescript": "~5.9.3" "typescript": "~5.9.3"
}, },
-140
View File
@@ -1,140 +0,0 @@
// Watches for electrobun growing a real window-resize gesture, so the
// synthesized one can be deleted the moment it becomes dead weight.
//
// bun scripts/check-electrobun-resize.ts (also runs from `postinstall`)
//
// WHY THIS EXISTS
//
// The win7 skin is frameless, which on GTK means undecorated, which means the
// window manager supplies no resize borders. electrobun 1.18.1 exposes no
// native resize gesture at all, so the app synthesizes one: handles in the
// webview drag the frame via RPC. That is a genuine stopgap — jerkier than a
// compositor-side grab and always will be — and the thing most likely to
// happen to a stopgap is that it outlives its reason and nobody notices.
//
// So the trigger is automated rather than a comment somebody has to re-read: an
// electrobun bump runs `postinstall`, which runs this, which prints the removal
// checklist the moment upstream ships the primitive we are waiting for.
//
// It NEVER fails the install (always exit 0) and is silent when there is
// nothing to say — a check that cries wolf gets muted, and a muted check is
// worse than none.
import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
/** The version the synthesized resize was written against and verified on. If
* the installed version differs, this script says so once — that is the cue to
* re-check upstream's changelog and then bump this constant. */
const VALIDATED_VERSION = "1.18.1";
/**
* Symbols that would mean "upstream can resize now". Verified ABSENT in 1.18.1:
* the shipped library exports `startWindowMove`/`stopWindowMove` with no resize
* counterpart, and imports `gtk_window_begin_move_drag` but not the resize
* twin. Any of these appearing is the signal.
*
* Searched as raw ASCII in the binary rather than via `nm`/`strings`: only the
* host platform's library is installed (`dist-linux-x64/…so`, `.dylib`, `.dll`
* elsewhere), and `nm` does not exist on Windows. Symbol names live in the
* binary as plain text either way.
*/
const NATIVE_NEEDLES = [
"gtk_window_begin_resize_drag", // the GTK primitive, if they ever link it
"gtk_window_set_resizable",
"startWindowResize", // the obvious sibling of startWindowMove
"setWindowResizable",
"beginWindowResize",
];
/** Source-level signals in the shipped TS API. */
const SOURCE_NEEDLES: { file: string; needle: string; means: string }[] = [
{
file: "dist/api/bun/core/BrowserWindow.ts",
needle: "setResizable",
means: "BrowserWindow gained a resizability setter",
},
{
file: "dist/api/bun/core/BrowserWindow.ts",
needle: "minWidth",
means: "the window constructor gained min-size options (our JS clamp may be redundant)",
},
{
file: "dist/api/bun/preload/dragRegions.ts",
needle: "resize",
means: "the drag-region preload now knows about resizing",
},
];
const CHECKLIST = `
┌────────────────────────────────────────────────────────────────────────┐
│ electrobun may now support window resizing natively. │
│ The synthesized resize is a STOPGAP and should be deleted. │
│ Full checklist: │
│ ui/src/app/skins/7like/window-resize/window-resize.ts │
└────────────────────────────────────────────────────────────────────────┘`;
function main(): void {
const pkgDir = path.join(import.meta.dir, "..", "node_modules", "electrobun");
if (!existsSync(pkgDir)) return; // not installed yet (fresh clone mid-install)
const hits: string[] = [];
// --- the shipped native library, whichever platform's is installed ---
for (const entry of readdirSync(pkgDir)) {
if (!entry.startsWith("dist-")) continue;
const dir = path.join(pkgDir, entry);
for (const file of readdirSync(dir)) {
if (!/^libNativeWrapper.*\.(so|dylib|dll)$/.test(file)) continue;
// latin1: we are searching for ASCII symbol names inside a binary, and
// utf-8 decoding would mangle bytes >0x7f (and can drop invalid
// sequences), which could hide a needle sitting next to one.
const text = readFileSync(path.join(dir, file), "latin1");
for (const needle of NATIVE_NEEDLES) {
if (text.includes(needle)) hits.push(`${entry}/${file} contains ${needle}`);
}
}
}
// --- the shipped TypeScript API ---
for (const { file, needle, means } of SOURCE_NEEDLES) {
const full = path.join(pkgDir, file);
if (!existsSync(full)) continue;
if (readFileSync(full, "utf8").includes(needle)) hits.push(`${file} mentions ${needle} — ${means}`);
}
const version = installedVersion(pkgDir);
if (hits.length) {
console.log(CHECKLIST);
console.log(` electrobun ${version ?? "?"} — what changed:`);
for (const hit of hits) console.log(` · ${hit}`);
console.log("");
return;
}
// No resize support. Stay quiet unless the version moved out from under the
// hack, in which case one line is the reminder to look.
if (version && version !== VALIDATED_VERSION) {
console.log(
`[electrobun] ${VALIDATED_VERSION} → ${version}: no window-resize API found, so the ` +
`synthesized resize stays. Re-check, then bump VALIDATED_VERSION in ` +
`scripts/check-electrobun-resize.ts to silence this.`,
);
}
}
function installedVersion(pkgDir: string): string | null {
try {
return JSON.parse(readFileSync(path.join(pkgDir, "package.json"), "utf8")).version ?? null;
} catch {
return null;
}
}
// A broken check must never break an install: swallow everything, exit 0.
try {
main();
} catch (error) {
// Deliberately quiet — this is advisory. Surface it only when run by hand.
if (process.env["DEBUG"]) console.error("[electrobun] resize check failed:", error);
}
-171
View File
@@ -1,171 +0,0 @@
// Programmatic HOVER test for the win7 title-bar close button.
//
// bun scripts/hover-probe.ts
//
// Why this exists: hovering the X paints 7.css's red glow ::before
// (box-shadow: 0 0 7px 3px #e68e75), and a stray red mark was reported on the
// window border's far edge. Hover cannot be captured by any tool on this
// machine — Firefox --screenshot has no pointer, `import -window` grabs
// whatever the real pointer is (not) doing — so this script makes hover a
// CLASS: it copies the built stylesheet with every `:hover` rewritten to
// `.simhover`, renders the app's exact frame markup with the X force-hovered,
// and then PIXEL-SCANS the regions where the artifact could live, printing a
// verdict table. Re-run after any change to the title-bar/hover styling; the
// artifact regions must stay at zero.
//
// Inherits shot.ts's constraints: dedicated Firefox profile (a running Firefox
// holds the default profile's lock), and the page must be written INTO the
// dist folder or strict_origin_policy silently unstyles it. Gecko vs WebKitGTK
// is fine here — box-shadow/border-radius compositing is engine-agnostic.
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import path from "node:path";
const ROOT = path.join(import.meta.dir, "..");
const DIST = path.join(ROOT, "ui", "dist", "ui", "browser");
const WORK = path.join(ROOT, ".shot");
function fail(message: string): never {
console.error(`[hover-probe] ERROR: ${message}`);
process.exit(1);
}
// ---- stylesheet with :hover rewritten to .simhover ----
if (!existsSync(DIST)) fail(`no build output at ${DIST} — run: bun run build:ui`);
const styles = readdirSync(DIST).find((f) => f.startsWith("styles-") && f.endsWith(".css"));
if (!styles) fail(`no styles-*.css in ${DIST} — run: bun run build:ui`);
const css = await Bun.file(path.join(DIST, styles)).text();
// Global on purpose: EVERY hover rule becomes assertable markup. `:hover` has
// no partial-token hazard (no `:hoverish` exists in CSS).
await Bun.write(path.join(DIST, "_probe.css"), css.replaceAll(":hover", ".simhover"));
// ---- the probe page: the app's frame markup, X force-hovered ----
// Mirrors ui/src/app/skins/7like/app.html + title-bar.ts EXACTLY where it
// matters: .window.glass.w7-frame.active with .title-bar as a DIRECT child
// (7.css couples the glass with `.window.glass > .title-bar`), controls keyed
// by aria-label. Fixed-size windows instead of h-screen so two fit one shot.
const win = (id: string, hover: boolean) => `
<div id="${id}" class="window glass w7-frame active" style="width: 420px; height: 120px; position: relative;">
<div class="title-bar electrobun-webkit-app-region-drag">
<div class="title-bar-text">miniSCP</div>
<div class="title-bar-controls">
<button aria-label="Minimize"></button>
<button aria-label="Maximize"></button>
<button aria-label="Close"${hover ? ' class="simhover"' : ""}></button>
</div>
</div>
<div class="window-body" style="height: 60px"></div>
</div>`;
const page = `<!doctype html>
<html class="win7" lang="en">
<head><meta charset="utf-8"><link rel="stylesheet" href="_probe.css">
<style>
/* checkerboard ground so semi-transparent escapes are visible over ANY tone */
body { margin: 0; padding: 40px; background: #808080; }
.slot { margin-bottom: 40px; }
</style></head>
<body>
<div class="slot">${win("control", false)}</div>
<div class="slot">${win("hovered", true)}</div>
</body></html>`;
const pagePath = path.join(DIST, "_hover-probe.html");
await Bun.write(pagePath, page);
// ---- shoot ----
mkdirSync(WORK, { recursive: true });
const profile = path.join(WORK, "profile");
mkdirSync(profile, { recursive: true });
rmSync(path.join(profile, "parent.lock"), { force: true });
const out = path.join(WORK, "hover-probe.png");
const proc = Bun.spawnSync(
["firefox", "--headless", "--profile", profile, "--screenshot", out, "--window-size", "560,420", `file://${pagePath}`],
{ cwd: ROOT, stdout: "pipe", stderr: "pipe" },
);
if (!existsSync(out)) {
console.error(proc.stderr.toString().trim());
fail("firefox produced no image");
}
// ---- pixel scan ----
// Page geometry (CSS px = image px; no DPR scaling in headless Firefox):
// body padding 40, window at x:40..460 (width 420), first window y:40..160,
// second y:200..320. The window's own 1px border is AT x=40 and x=459.
const WIN_X = 40;
const WIN_W = 420;
const CONTROL_Y = 40;
const HOVER_Y = 200;
const WIN_H = 120;
interface Region {
name: string;
x: number;
y: number;
w: number;
h: number;
artifact: boolean; // true = red here is a DEFECT; false = red here is the intended glow
}
// The close button: last control, min-width 48px (3rem), controls end 6px
// (--w7-w-space padding) from the window's right edge — so the button spans
// roughly x = right-6-48 .. right-6. The glow reaches up to blur+spread = 10px
// beyond any edge of it.
const regions = (top: number): Region[] => [
// the window's right border column (2px wide: border + inset ring), full title-bar height
{ name: "right border (A)", x: WIN_X + WIN_W - 2, y: top + 2, w: 2, h: 30, artifact: true },
// OUTSIDE the window entirely, right of the border, title-bar height
{ name: "outside right (B)", x: WIN_X + WIN_W, y: top, w: 12, h: 34, artifact: true },
// OUTSIDE above the top edge / rounded corner
{ name: "outside top (B)", x: WIN_X + WIN_W - 80, y: top - 12, w: 92, h: 12, artifact: true },
// the gutter between the button and the border — glow HERE is the intended look
{ name: "gutter (glow ok)", x: WIN_X + WIN_W - 7, y: top + 2, w: 5, h: 30, artifact: false },
];
async function redCount(img: string, r: Region): Promise<number> {
// txt: gives one "x,y: (r,g,b[,a])" line per pixel
const p = Bun.spawnSync(
["magick", img, "-crop", `${r.w}x${r.h}+${r.x}+${r.y}`, "+repage", "txt:"],
{ stdout: "pipe", stderr: "pipe" },
);
let count = 0;
for (const line of p.stdout.toString().split("\n").slice(1)) {
const m = /\((\d+),(\d+),(\d+)/.exec(line);
if (!m) continue;
const [red, green, blue] = [Number(m[1]), Number(m[2]), Number(m[3])];
// "red" = the red channel dominating both others decisively. The Aero
// chrome is blue/white, so any genuine red here is the glow.
if (red > 120 && red > green + 30 && red > blue + 30) count++;
}
return count;
}
console.log("[hover-probe] region scan (red-dominant pixels):\n");
console.log(" region control hovered verdict");
let defects = 0;
for (const [i, region] of regions(CONTROL_Y).entries()) {
const hoverRegion = regions(HOVER_Y)[i];
const inControl = await redCount(out, region);
const inHover = await redCount(out, hoverRegion);
const introduced = inHover - inControl;
let verdict: string;
if (!region.artifact) verdict = introduced > 0 ? "glow present (intended)" : "no glow";
else if (introduced > 0) {
verdict = "DEFECT: red introduced by hover";
defects++;
} else verdict = "clean";
console.log(
` ${region.name.padEnd(20)}${String(inControl).padStart(7)}${String(inHover).padStart(9)} ${verdict}`,
);
}
console.log(`\n[hover-probe] image: ${out}`);
if (defects > 0) {
console.log(`[hover-probe] ${defects} artifact region(s) show hover-introduced red`);
process.exit(2);
}
console.log("[hover-probe] clean");
+4 -3
View File
@@ -7,9 +7,10 @@
// It has to be in the environment BEFORE the app process starts: WebKit reads // 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 // 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 // propagate to children (verified — a child sees the variable unset), so the
// app cannot set this for itself from inside src/bun/. Every launch path has to // app cannot set this for itself from inside src/bun/. Only the dev launch
// inject it: the dev scripts here, and the wrapper script that release.ts ships // paths (`bun run dev` / `bun run start`) inject it; an installed build (the
// beside the packaged launcher. // 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 // Scoped to NVIDIA so Intel/AMD keep hardware compositing, and an explicit
// value from the user's environment always wins. // value from the user's environment always wins.
-225
View File
@@ -1,225 +0,0 @@
// Programmatic POSITIONING + HIGHLIGHT test for the win7 context menu.
//
// bun scripts/menu-probe.ts
//
// Why this exists: 7.css declares `position: relative` on ul[role=menu] (it
// needs it for the gutter stripe and submenus), and the `skin` layer outranks
// `utilities` — so Tailwind's `.fixed` on the app's own menu lost, its
// [style.left/top.px] became FLOW offsets, and every right-click menu rendered
// below the viewport of the h-screen frame. Nothing about the idle window
// changes when that breaks, so the screenshot harness (scripts/shot.ts) cannot
// see it, and hover-probe.ts tests a different axis. This script renders the
// menu markup at declared coordinates and pixel-scans whether it actually
// landed there.
//
// It carries its own control: a second menu WITHOUT the `w7-ctx` marker class,
// i.e. the bug verbatim. That one must stay invisible — if it ever shows up,
// 7.css stopped forcing `relative` and this probe's premise is stale.
//
// It also checks the item HIGHLIGHT, using hover-probe.ts's trick (`:hover`
// rewritten to a `.simhover` class, since headless Firefox has no pointer):
// 7.css paints hovered items with a translucent gradient wash, and this app
// wants one flat colour. A gradient is detectable as vertical colour spread
// down a text-free strip of the item, so "is it flat?" is a measurement, not
// an opinion. What canNOT be measured here is the 1s afterglow fade — a
// temporal effect — but its cause is structural (`button::before` still has
// `content`), and this probe would show the glow's colour if the pseudo ever
// came back.
//
// Inherits shot.ts's constraints: dedicated Firefox profile (a running Firefox
// holds the default profile's lock), and the page must be written INTO the
// dist folder or strict_origin_policy silently unstyles it.
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import path from "node:path";
const ROOT = path.join(import.meta.dir, "..");
const DIST = path.join(ROOT, "ui", "dist", "ui", "browser");
const WORK = path.join(ROOT, ".shot");
function fail(message: string): never {
console.error(`[menu-probe] ERROR: ${message}`);
process.exit(1);
}
if (!existsSync(DIST)) fail(`no build output at ${DIST} — run: bun run build:ui`);
const styles = readdirSync(DIST).find((f) => f.startsWith("styles-") && f.endsWith(".css"));
if (!styles) fail(`no styles-*.css in ${DIST} — run: bun run build:ui`);
// Hover as a CLASS: same rewrite hover-probe.ts uses, for the same reason —
// `--screenshot` has no pointer. `:hover` has no partial-token hazard.
const css = await Bun.file(path.join(DIST, styles)).text();
const PROBE_CSS = "_menu-probe.css";
await Bun.write(path.join(DIST, PROBE_CSS), css.replaceAll(":hover", ".simhover"));
// ---- the probe page ----
// Geometry (CSS px = image px; headless Firefox applies no DPR scaling).
const MENU_X = 200;
const MENU_Y = 100;
const CONTROL_X = 560;
const MENU_W = 200;
// Mirrors ui/src/app/skins/7like/context-menu.ts EXACTLY where it matters:
// ul[role=menu] > li[role=menuitem] > button, the `w7-ctx fixed` class pair,
// and left/top as inline pixel styles. `w7-ctx` is what the skinfix rule in
// 7like.css keys on; the control deliberately omits it.
const menu = (x: number, marker: boolean) => `
<ul role="menu" tabindex="-1"
class="${marker ? "w7-ctx " : ""}fixed z-50 outline-none"
style="left: ${x}px; top: ${MENU_Y}px; width: ${MENU_W}px">
<li role="menuitem"><button type="button"${marker ? ' class="simhover"' : ""}>Send to Other Panel</button></li>
<li role="menuitem" class="has-divider"><button type="button">Rename</button></li>
<li role="menuitem"><button type="button">Copy Path</button></li>
<li role="menuitem" aria-disabled="true"><button type="button">Delete</button></li>
</ul>`;
// The frame reproduces the app's: .window.w7-frame is a 100vh flex column and
// <app-context-menu> is one of its LAST children, so a menu that falls back to
// static flow lands past the bottom edge — which is the whole bug.
// The body gets an explicit white background (inline, so no layer can touch
// it) purely so "is anything drawn here?" is an unambiguous question.
const page = `<!doctype html>
<html class="win7" lang="en">
<head><meta charset="utf-8"><link rel="stylesheet" href="${PROBE_CSS}">
</head>
<body>
<div class="window glass w7-frame active" style="display: flex; flex-direction: column; height: 100vh">
<div class="title-bar">
<div class="title-bar-text">miniSCP</div>
<div class="title-bar-controls"><button aria-label="Close"></button></div>
</div>
<div class="window-body" style="flex: 1; min-height: 0; background: #fff"></div>
${menu(MENU_X, true)}
${menu(CONTROL_X, false)}
</div>
</body></html>`;
const pagePath = path.join(DIST, "_menu-probe.html");
await Bun.write(pagePath, page);
// ---- shoot ----
mkdirSync(WORK, { recursive: true });
const profile = path.join(WORK, "profile");
mkdirSync(profile, { recursive: true });
rmSync(path.join(profile, "parent.lock"), { force: true });
const out = path.join(WORK, "menu-probe.png");
const proc = Bun.spawnSync(
["firefox", "--headless", "--profile", profile, "--screenshot", out, "--window-size", "800,400", `file://${pagePath}`],
{ cwd: ROOT, stdout: "pipe", stderr: "pipe" },
);
if (!existsSync(out)) {
console.error(proc.stderr.toString().trim());
fail("firefox produced no image");
}
// ---- pixel scan ----
/**
* Count pixels that are visibly NOT the white window body: the menu's border
* (#0006 over white ≈ #999), its shadow and its black item text all qualify.
* A menu that rendered where it was told fills this with hundreds of them; an
* empty white region scores 0.
*/
async function inkCount(img: string, x: number, y: number, w: number, h: number): Promise<number> {
const p = Bun.spawnSync(["magick", img, "-crop", `${w}x${h}+${x}+${y}`, "+repage", "txt:"], {
stdout: "pipe",
stderr: "pipe",
});
let count = 0;
for (const line of p.stdout.toString().split("\n").slice(1)) {
const m = /\((\d+),(\d+),(\d+)/.exec(line);
if (!m) continue;
const [r, g, b] = [Number(m[1]), Number(m[2]), Number(m[3])];
if (Math.max(r, g, b) < 200) count++;
}
return count;
}
/**
* Colour spread down a text-free strip of the first (force-hovered) item: the
* ul's own 2px padding + the item's 1px border put the item's background at
* x >= menu+3, and its 32px left padding keeps label text out to menu+32. So a
* column at menu+5..menu+26 is pure fill — except for the gutter stripe, which
* 7.css puts at left: 28px. A flat colour reads 0 spread; a gradient does not.
*/
async function fillSpread(
img: string,
x: number,
y: number,
w: number,
h: number,
): Promise<{ spread: number; mean: [number, number, number] }> {
const p = Bun.spawnSync(["magick", img, "-crop", `${w}x${h}+${x}+${y}`, "+repage", "txt:"], {
stdout: "pipe",
stderr: "pipe",
});
const lo: number[] = [255, 255, 255];
const hi: number[] = [0, 0, 0];
const sum = [0, 0, 0];
let n = 0;
for (const line of p.stdout.toString().split("\n").slice(1)) {
const m = /\((\d+),(\d+),(\d+)/.exec(line);
if (!m) continue;
for (let c = 0; c < 3; c++) {
const v = Number(m[c + 1]);
lo[c] = Math.min(lo[c], v);
hi[c] = Math.max(hi[c], v);
sum[c] += v;
}
n++;
}
if (!n) return { spread: -1, mean: [0, 0, 0] };
return {
spread: Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]),
mean: [Math.round(sum[0] / n), Math.round(sum[1] / n), Math.round(sum[2] / n)],
};
}
// Inset by 2px so the scan cannot straddle the region's own edge.
const BOX = { w: MENU_W - 4, h: 60 };
const placed = await inkCount(out, MENU_X + 2, MENU_Y + 2, BOX.w, BOX.h);
const control = await inkCount(out, CONTROL_X + 2, MENU_Y + 2, BOX.w, BOX.h);
console.log("[menu-probe] ink at the declared coordinates:\n");
console.log(" menu px verdict");
console.log(
` w7-ctx (position: fixed)${String(placed).padStart(6)} ${placed > 0 ? "placed" : "MISSING: menu did not render where it was told"}`,
);
console.log(
` control (no marker) ${String(control).padStart(6)} ${control > 0 ? "NOTE: 7.css no longer forces relative — premise stale" : "off-viewport, as expected"}`,
);
// The force-hovered first item: fill strip, and the same strip on the item
// below it as a not-hovered reference (which must stay the menu surface).
const FILL = { x: MENU_X + 5, w: 21, h: 14 };
const hovered = await fillSpread(out, FILL.x, MENU_Y + 7, FILL.w, FILL.h);
const resting = await fillSpread(out, FILL.x, MENU_Y + 34, FILL.w, FILL.h);
const FLAT_MAX = 6; // antialiasing tolerance; 7.css's wash spreads far wider
console.log("\n[menu-probe] item highlight (force-hovered vs resting):\n");
console.log(" item mean rgb spread verdict");
console.log(
` hovered (.simhover) ${hovered.mean.join(",").padEnd(15)} ${String(hovered.spread).padStart(6)} ${
hovered.spread <= FLAT_MAX ? "flat" : "GRADIENT: highlight is not a flat colour"
}`,
);
console.log(` resting ${resting.mean.join(",").padEnd(15)} ${String(resting.spread).padStart(6)} reference`);
console.log(`\n[menu-probe] image: ${out}`);
let failed = false;
if (placed === 0) {
console.log("[menu-probe] the context menu is positioned in FLOW again — see 7like.css .w7-ctx");
failed = true;
}
if (hovered.spread > FLAT_MAX) {
console.log("[menu-probe] the item highlight regained a gradient — see the menu block in 7like.css");
failed = true;
}
if (hovered.mean.join(",") === resting.mean.join(",")) {
console.log("[menu-probe] hovered and resting items are identical — the highlight is not painting at all");
failed = true;
}
if (failed) process.exit(2);
console.log("[menu-probe] clean");
+38 -89
View File
@@ -1,23 +1,21 @@
// Build one release artifact for one target, on the matching host: // Build one release artifact for one target, on the matching host:
// bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64|linux-x64] // bun scripts/release.ts [--target mac-arm64|win-x64|linux-x64]
// Default target = the host. electrobun cannot cross-compile OSes (its // Default target = the host. electrobun cannot cross-compile (Hutch builds for
// packaging needs host tools: codesign/hdiutil on mac, rcedit/PowerShell on // its host OS and arch only; packaging needs host tools: codesign/hdiutil on
// Windows), so win-x64 must run on a Windows machine. mac-x64 IS buildable on // mac, rcedit/PowerShell on Windows), so win-x64 must run on a Windows machine.
// an arm64 Mac: we fetch electrobun's x64 CLI binary and run it under // There is no mac-x64: Electrobun 2.x publishes no macOS x64 core at all.
// Rosetta 2 — it sees an x64 host, pulls the x64 core, and packages mac-x64. // Artifacts land in release/<target>/miniSCP-Build<N>-<target><ext>.
// Artifacts land in release/<target>/miniSCP-Build<N>-<target>.<ext>. import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { cpSync, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path"; import path from "node:path";
const ROOT = path.join(import.meta.dir, ".."); const ROOT = path.join(import.meta.dir, "..");
const TARGETS = ["mac-arm64", "mac-x64", "win-x64", "linux-x64"] as const; const TARGETS = ["mac-arm64", "win-x64", "linux-x64"] as const;
type Target = (typeof TARGETS)[number]; type Target = (typeof TARGETS)[number];
function hostTarget(): Target { function hostTarget(): Target {
if (process.platform === "win32") return "win-x64"; if (process.platform === "win32") return "win-x64";
if (process.platform === "linux") return "linux-x64"; if (process.platform === "linux") return "linux-x64";
return process.arch === "arm64" ? "mac-arm64" : "mac-x64"; return "mac-arm64";
} }
function fail(message: string): never { function fail(message: string): never {
@@ -38,8 +36,10 @@ function run(cmd: string[], opts: { cwd?: string } = {}): void {
// ---- target selection + host guards ---- // ---- target selection + host guards ----
const argIndex = process.argv.indexOf("--target"); const argIndex = process.argv.indexOf("--target");
const requested = (argIndex >= 0 ? process.argv[argIndex + 1] : hostTarget()) as Target; const arg = argIndex >= 0 ? process.argv[argIndex + 1] : hostTarget();
if (!TARGETS.includes(requested)) fail(`unknown target "${requested}" (${TARGETS.join(", ")})`); if (arg === "mac-x64") fail("mac-x64 was dropped — Electrobun 2.x publishes no macOS x64 core.");
if (!TARGETS.includes(arg as Target)) fail(`unknown target "${arg}" (${TARGETS.join(", ")})`);
const requested = arg as Target;
if (requested === "win-x64" && process.platform !== "win32") { if (requested === "win-x64" && process.platform !== "win32") {
fail("win-x64 must be built on a Windows machine/runner — electrobun cannot cross-compile."); fail("win-x64 must be built on a Windows machine/runner — electrobun cannot cross-compile.");
@@ -47,14 +47,14 @@ if (requested === "win-x64" && process.platform !== "win32") {
if (requested === "linux-x64" && process.platform !== "linux") { if (requested === "linux-x64" && process.platform !== "linux") {
fail("linux-x64 must be built on a Linux machine/runner — electrobun cannot cross-compile."); fail("linux-x64 must be built on a Linux machine/runner — electrobun cannot cross-compile.");
} }
if (requested.startsWith("mac") && process.platform !== "darwin") { if (requested === "mac-arm64" && (process.platform !== "darwin" || process.arch !== "arm64")) {
fail(`${requested} must be built on macOS.`); fail("mac-arm64 must be built on an Apple Silicon Mac (Electrobun 2.x has no macOS x64 build).");
} }
const viaRosetta = requested === "mac-x64" && process.arch === "arm64";
// ---- prepare: version, icons, UI bundle ---- // ---- prepare: version, devkit, icons, UI bundle ----
run(["bun", "scripts/gen-version.ts"]); run(["bun", "scripts/gen-version.ts"]);
run(["bunx", "electrobun", "prepare"]);
run(["bun", "scripts/gen-icons.ts"]); run(["bun", "scripts/gen-icons.ts"]);
run(["bun", "run", "--cwd", "ui", "build"]); run(["bun", "run", "--cwd", "ui", "build"]);
@@ -62,98 +62,47 @@ const { BUILD } = await import("../src/shared/version.gen");
const { BRANDING } = await import("../src/shared/branding"); const { BRANDING } = await import("../src/shared/branding");
const buildName = `Build${BUILD.number}`; const buildName = `Build${BUILD.number}`;
// ---- electrobun build (native, or x64 CLI under Rosetta) ---- // ---- electrobun build ----
async function x64Cli(): Promise<string> { // Start from an empty artifacts/: site/deploy.sh publishes it verbatim (the
const pkg = await Bun.file(path.join(ROOT, "node_modules", "electrobun", "package.json")).json(); // updater contract), so an earlier build's files must not ride along.
const dir = path.join(os.homedir(), ".cache", "miniscp", `electrobun-cli-x64-${pkg.version}`); const artifactsDir = path.join(ROOT, "artifacts");
const bin = path.join(dir, "electrobun"); rmSync(artifactsDir, { recursive: true, force: true });
if (existsSync(bin)) return bin; run(["bunx", "electrobun", "build", "--env=stable"]);
console.log(`[release] downloading electrobun x64 CLI ${pkg.version} (one-time)…`);
mkdirSync(dir, { recursive: true });
const url = `https://github.com/blackboardsh/electrobun/releases/download/v${pkg.version}/electrobun-cli-darwin-x64.tar.gz`;
const tarball = path.join(dir, "cli.tar.gz");
run(["curl", "-fsSL", "-o", tarball, url]);
run(["tar", "-xzf", tarball, "-C", dir]);
if (!existsSync(bin)) fail(`x64 CLI tarball did not contain "electrobun" (${dir})`);
run(["chmod", "+x", bin]);
return bin;
}
if (viaRosetta) {
// x64 Mach-O runs under Rosetta transparently; it sees process.arch === x64
const rosetta = Bun.spawnSync(["arch", "-x86_64", "true"]);
if (!rosetta.success) {
fail("Rosetta 2 is not installed — run: softwareupdate --install-rosetta --agree-to-license");
}
run([await x64Cli(), "build", "--env=stable"]);
} else {
run(["bunx", "electrobun", "build", "--env=stable"]);
}
// ---- collect artifacts ---- // ---- collect artifacts ----
const [osName, archName] = const [osName, archName] =
requested === "win-x64" requested === "win-x64" ? ["win", "x64"] : requested === "linux-x64" ? ["linux", "x64"] : ["macos", "arm64"];
? ["win", "x64"] // 2.x installers carry no channel prefix (macos-arm64-miniSCP.dmg); only the
: requested === "linux-x64" // update feed (stable-<os>-<arch>-*) keeps it, for v1-compatible clients.
? ["linux", "x64"] const prefix = `${osName}-${archName}-`;
: ["macos", requested.slice(4)];
const prefix = `stable-${osName}-${archName}-`;
const artifactsDir = path.join(ROOT, "artifacts");
const outDir = path.join(ROOT, "release", requested); const outDir = path.join(ROOT, "release", requested);
mkdirSync(outDir, { recursive: true }); mkdirSync(outDir, { recursive: true });
const produced = readdirSync(artifactsDir).filter( const produced = readdirSync(artifactsDir).filter(
(f) => f.startsWith(prefix) && f.includes(BRANDING.appName), (f) => f.startsWith(prefix) && f.includes(BRANDING.appName),
); );
if (!produced.length) fail(`no ${prefix}* artifacts found in artifacts/ — did the build run?`); if (!produced.length) fail(`no ${prefix}* installer found in artifacts/ — did the build run?`);
const out: string[] = []; const out: string[] = [];
for (const file of produced) { for (const file of produced) {
const ext = file.endsWith(".dmg") ? ".dmg" : file.endsWith("-Setup.zip") ? "-Setup.zip" : null; const ext = [".dmg", "-Setup.zip", "-Setup.tar.gz"].find((e) => file.endsWith(e));
if (!ext) continue; // skip the raw .app.tar.zst + update.json if (!ext) continue;
const name = `${BRANDING.appName}-${buildName}-${requested}${ext}`; const name = `${BRANDING.appName}-${buildName}-${requested}${ext}`;
cpSync(path.join(artifactsDir, file), path.join(outDir, name)); cpSync(path.join(artifactsDir, file), path.join(outDir, name));
out.push(name); out.push(name);
} }
// Windows / Linux: package the assembled app folder directly. On Windows that // Windows: also zip the assembled app folder — the portable build (unzip
// complements the Setup.exe installer (portable = unzip anywhere and run); on // anywhere and run) next to the Setup installer. Best-effort: the folder
// Linux it is the ONLY artifact, since electrobun emits just the .tar.zst // layout is confirmed by the first real 2.x Windows build.
// updater blob there — no installer, AppImage or .deb. Best-effort: the folder if (requested === "win-x64") {
// layout is confirmed by the first real build on each OS. const appDir = path.join(ROOT, "build", "stable-win-x64", BRANDING.appName);
if (requested === "win-x64" || requested === "linux-x64") {
const appDir = path.join(ROOT, "build", `stable-${osName}-${archName}`, BRANDING.appName);
if (existsSync(appDir)) { if (existsSync(appDir)) {
if (requested === "win-x64") { const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`); run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]);
run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]); out.push(path.basename(portable));
out.push(path.basename(portable));
} else {
// Ship a wrapper beside the launcher: the packaged app never goes
// through scripts/start.ts, and the NVIDIA/DMABUF workaround has to be
// in the environment BEFORE the process starts (see scripts/linux-gpu.ts).
const wrapper = path.join(appDir, `${BRANDING.appName}.sh`);
writeFileSync(
wrapper,
[
"#!/bin/sh",
"# WebKitGTK's DMABUF renderer cannot allocate GBM buffers on the NVIDIA",
"# proprietary driver — the window renders black. Disable it there only.",
'if [ -z "$WEBKIT_DISABLE_DMABUF_RENDERER" ] && [ -e /proc/driver/nvidia ]; then',
" export WEBKIT_DISABLE_DMABUF_RENDERER=1",
"fi",
'exec "$(dirname "$(readlink -f "$0")")/bin/launcher" "$@"',
"",
].join("\n"),
{ mode: 0o755 },
);
const tarball = path.join(outDir, `${BRANDING.appName}-${buildName}-linux-x64.tar.gz`);
// -C the parent so the archive unpacks into one named folder
run(["tar", "-czf", tarball, "-C", path.dirname(appDir), path.basename(appDir)]);
out.push(path.basename(tarball));
}
} else { } else {
console.warn(`[release] WARN: portable skipped — app folder not found at ${appDir}`); console.warn(`[release] WARN: portable skipped — app folder not found at ${appDir}`);
} }
-121
View File
@@ -1,121 +0,0 @@
// Programmatic test for the synthesized window resize's frame arithmetic.
//
// bun scripts/resize-math-probe.ts
//
// Why this exists: the win7 skin resizes itself by dragging handles and moving
// the native frame (see ui/src/app/skins/7like/window-resize/window-resize.ts —
// a STOPGAP with a removal checklist). Pointer drags cannot be synthesized
// headlessly on this machine, so the interaction itself needs a human; but the
// part most likely to be subtly wrong is pure arithmetic — eight edges, four of
// which move the window's ORIGIN as well as its size, plus a minimum-size clamp
// that must not let the anchored edge drift. That is all in `resize-math.ts`,
// which is deliberately dependency-free so this script can drive it directly.
//
// The failure this guards against is not "the window does not resize" (obvious
// on sight) but "dragging the west edge inward past the minimum walks the whole
// window across the screen" — correct-looking until you hit the limit.
import {
MIN_HEIGHT,
MIN_WIDTH,
RESIZE_EDGES,
type Frame,
type ResizeEdge,
resizeFrame,
} from "../ui/src/app/skins/7like/window-resize/resize-math";
const START: Frame = { x: 100, y: 200, width: 1000, height: 800 };
interface Case {
name: string;
edge: ResizeEdge;
dx: number;
dy: number;
want: Frame;
}
// Every expectation is written out in full rather than computed, so a bug in
// resizeFrame cannot be mirrored into the expectation by sharing its algebra.
const CASES: Case[] = [
// --- edges: each moves only what it owns ---
{ name: "east grows width only", edge: "e", dx: 50, dy: 999, want: { x: 100, y: 200, width: 1050, height: 800 } },
{ name: "south grows height only", edge: "s", dx: 999, dy: 40, want: { x: 100, y: 200, width: 1000, height: 840 } },
{ name: "west moves x AND width", edge: "w", dx: -30, dy: 0, want: { x: 70, y: 200, width: 1030, height: 800 } },
{ name: "north moves y AND height", edge: "n", dx: 0, dy: -25, want: { x: 100, y: 175, width: 1000, height: 825 } },
// --- corners: two axes at once ---
{ name: "south-east", edge: "se", dx: 10, dy: 20, want: { x: 100, y: 200, width: 1010, height: 820 } },
{ name: "north-west", edge: "nw", dx: -10, dy: -20, want: { x: 90, y: 180, width: 1010, height: 820 } },
{ name: "north-east", edge: "ne", dx: 10, dy: -20, want: { x: 100, y: 180, width: 1010, height: 820 } },
{ name: "south-west", edge: "sw", dx: -10, dy: 20, want: { x: 90, y: 200, width: 1010, height: 820 } },
// --- the anchored edge must not drift once clamped ---
// west dragged far east: width parks at MIN, and x parks where the EAST edge
// (100 + 1000 = 1100) minus MIN puts it. If x kept tracking dx, the window
// would slide away while appearing frozen.
{
name: "west clamps, east edge holds",
edge: "w",
dx: 5000,
dy: 0,
want: { x: 1100 - MIN_WIDTH, y: 200, width: MIN_WIDTH, height: 800 },
},
{
name: "north clamps, south edge holds",
edge: "n",
dx: 0,
dy: 5000,
want: { x: 100, y: 1000 - MIN_HEIGHT, width: 1000, height: MIN_HEIGHT },
},
// east/south clamp the size but never touch the origin
{ name: "east clamps, origin holds", edge: "e", dx: -5000, dy: 0, want: { x: 100, y: 200, width: MIN_WIDTH, height: 800 } },
{ name: "south clamps, origin holds", edge: "s", dx: 0, dy: -5000, want: { x: 100, y: 200, width: 1000, height: MIN_HEIGHT } },
// a corner clamping on BOTH axes at once
{
name: "north-west clamps both",
edge: "nw",
dx: 5000,
dy: 5000,
want: { x: 1100 - MIN_WIDTH, y: 1000 - MIN_HEIGHT, width: MIN_WIDTH, height: MIN_HEIGHT },
},
// a zero drag must be a no-op, or a click on a handle would nudge the window
{ name: "zero drag is identity", edge: "se", dx: 0, dy: 0, want: START },
];
const fmt = (f: Frame) => `${f.x},${f.y} ${f.width}x${f.height}`;
console.log("[resize-math] frame arithmetic:\n");
console.log(" case edge got verdict");
let failed = 0;
for (const c of CASES) {
const got = resizeFrame(c.edge, START, c.dx, c.dy);
const ok = fmt(got) === fmt(c.want);
if (!ok) failed++;
console.log(
` ${c.name.padEnd(32)} ${c.edge.padEnd(5)} ${fmt(got).padEnd(22)} ${ok ? "ok" : `FAIL: want ${fmt(c.want)}`}`,
);
}
// Structural check: every edge must be exercised above, so adding a ninth
// handle without a case cannot slip through.
const covered = new Set(CASES.map((c) => c.edge));
const missing = RESIZE_EDGES.filter((e) => !covered.has(e));
if (missing.length) {
console.log(`\n[resize-math] no case covers: ${missing.join(", ")}`);
failed++;
}
// The clamp must never produce a frame smaller than the minimum, whatever the
// drag — a cheap sweep over every edge and a range of deltas.
for (const edge of RESIZE_EDGES) {
for (const d of [-9999, -1000, -1, 0, 1, 1000, 9999]) {
const got = resizeFrame(edge, START, d, d);
if (got.width < MIN_WIDTH || got.height < MIN_HEIGHT) {
console.log(`\n[resize-math] ${edge} at delta ${d} produced ${fmt(got)} — under the minimum`);
failed++;
}
}
}
if (failed) {
console.log(`\n[resize-math] ${failed} failure(s)`);
process.exit(2);
}
console.log("\n[resize-math] clean");
-106
View File
@@ -1,106 +0,0 @@
// Headless screenshot of the skin harness, so the Win7 look can be reviewed
// visually during development rather than inferred from a clean build log.
//
// bun scripts/shot.ts [--dark] [--width 1200] [--height 1400] [--out path.png]
// bun scripts/shot.ts --url http://localhost:4200 (any page, not just the harness)
//
// Firefox is used because it ships on this machine and has a built-in
// --screenshot mode; no Chromium/puppeteer download is needed. Two constraints
// it imposes:
// - a DEDICATED PROFILE is mandatory. A running Firefox holds a lock on the
// default profile and the headless run aborts with "Firefox is already
// running, but is not responding".
// - it renders with Gecko, NOT the WebKitGTK engine the app actually uses.
// Good enough for skin fidelity (layout, colour, gradients, borders);
// useless for engine-specific behaviour or native window transparency.
// Real confirmation is still `bun run start`.
//
// The harness links the REAL hashed stylesheet from ui/dist (produced by the
// normal `bun run build:ui`), so there is no second build pipeline to keep in
// sync.
//
// The composed page is written INTO that same dist folder, and this is not
// incidental: Firefox's security.fileuri.strict_origin_policy stops a file://
// document from loading a stylesheet that lives in a different directory. Host
// the page elsewhere and it renders completely unstyled — which looks exactly
// like "the skin is broken" rather than "the CSS never loaded". Keeping them
// co-located also lets media/ fonts resolve.
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import path from "node:path";
const ROOT = path.join(import.meta.dir, "..");
const DIST = path.join(ROOT, "ui", "dist", "ui", "browser");
const HARNESS = path.join(ROOT, "ui", "dev", "harness.html");
const WORK = path.join(ROOT, ".shot");
function arg(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : undefined;
}
const flag = (name: string) => process.argv.includes(`--${name}`);
function fail(message: string): never {
console.error(`[shot] ERROR: ${message}`);
process.exit(1);
}
// ---- resolve the built stylesheet ----
if (!existsSync(DIST)) fail(`no build output at ${DIST} — run: bun run build:ui`);
const styles = readdirSync(DIST).find((f) => f.startsWith("styles-") && f.endsWith(".css"));
if (!styles) fail(`no styles-*.css in ${DIST} — run: bun run build:ui`);
// ---- compose the page ----
mkdirSync(WORK, { recursive: true });
const profile = path.join(WORK, "profile");
mkdirSync(profile, { recursive: true });
const url = arg("url");
let target: string;
if (url) {
target = url;
} else {
const dark = flag("dark");
let html = await Bun.file(HARNESS).text();
// replaceAll, not replace: the harness documents its own {{STYLES}} token in
// a header comment that precedes the <link>, so a first-match replace
// substitutes the comment and silently leaves the stylesheet unlinked.
html = html.replaceAll("{{STYLES}}", styles); // relative: page sits next to it
// The preload bakes these attributes in the real app; do it by hand here.
if (dark) html = html.replace('<html class="win7"', '<html class="win7" data-theme="dark"');
const page = path.join(DIST, dark ? "_harness.dark.html" : "_harness.light.html");
await Bun.write(page, html);
target = `file://${page}`;
}
// ---- shoot ----
const out = path.resolve(arg("out") ?? path.join(WORK, flag("dark") ? "dark.png" : "light.png"));
const width = arg("width") ?? "1200";
const height = arg("height") ?? "1500";
// A stale lock from a killed run makes every later shot fail confusingly.
rmSync(path.join(profile, "parent.lock"), { force: true });
const proc = Bun.spawnSync(
[
"firefox",
"--headless",
"--profile",
profile,
"--screenshot",
out,
"--window-size",
`${width},${height}`,
target,
],
{ cwd: ROOT, stdout: "pipe", stderr: "pipe" },
);
if (!existsSync(out)) {
console.error(proc.stderr.toString().trim());
fail("firefox produced no image (see stderr above)");
}
console.log(`[shot] ${out}`);
+1 -1
View File
@@ -19,7 +19,7 @@
header @feed Cache-Control "no-cache" header @feed Cache-Control "no-cache"
# Installers/payloads keep stable names but get overwritten each release. # Installers/payloads keep stable names but get overwritten each release.
@pkg path *.dmg *.zip *.tar.zst @pkg path *.dmg *.zip *.tar.zst *.tar.gz
header @pkg Cache-Control "public, max-age=300" header @pkg Cache-Control "public, max-age=300"
# browse => directory listing; file_server serves range requests # browse => directory listing; file_server serves range requests
+7 -5
View File
@@ -42,7 +42,7 @@ electrobun can't cross-compile, so each OS/arch is built on its own machine. On
machine, after producing artifacts, drop them into the shared `dl/` folder: machine, after producing artifacts, drop them into the shared `dl/` folder:
```bash ```bash
bun scripts/release.ts # or --target mac-x64 / win-x64 bun scripts/release.ts # or --target win-x64 / linux-x64
site/deploy.sh # -> /srv/miniscp/dl site/deploy.sh # -> /srv/miniscp/dl
``` ```
@@ -56,14 +56,16 @@ rsync -av ./_dl/ user@host:/srv/miniscp/dl/
That's the whole workflow: **build, drop the artifacts, done.** Installers keep stable names and That's the whole workflow: **build, drop the artifacts, done.** Installers keep stable names and
overwrite in place, so the download links on the page never change. overwrite in place, so the download links on the page never change.
> **Don't delete old `*.patch` files.** They're the incremental-update trail for the (upcoming) > **Don't delete old `*.patch` files.** They're the incremental-update trail for the
> built-in updater — removing them forces older installs to re-download the full app. > built-in updater — removing them forces older installs to re-download the full app.
## Notes ## Notes
- Downloads live under `/dl/` and are browsable at `http://<host>/dl/`. - Downloads live under `/dl/` and are browsable at `http://<host>/dl/`.
- The page links to fixed, stable filenames (`stable-macos-arm64-miniSCP.dmg`, …) so it stays - The page links to fixed, stable filenames (`macos-arm64-miniSCP.dmg`, `win-x64-miniSCP-Setup.zip`,
static and always points at the latest build. …) so it stays static and always points at the latest build. Electrobun 2.x names installers
without the `stable-` prefix; only the updater feed (`stable-<os>-<arch>-update.json`, `.tar.zst`,
`.patch`) keeps it. Installers left over under the old v1 `stable-…` names are stale — delete them.
- Builds are currently unsigned — the FAQ on the page covers the macOS `xattr` step and the - Builds are currently unsigned — the FAQ on the page covers the macOS `xattr` step and the
Windows SmartScreen prompt. Windows SmartScreen prompt.
- `/dl/` is also the intended `release.baseUrl` for the future electrobun auto-updater. - `/dl/` is also the electrobun auto-updater's `release.baseUrl`.
+9 -6
View File
@@ -3,7 +3,7 @@ set -euo pipefail
# Publish this machine's built artifacts into the served flat download folder. # Publish this machine's built artifacts into the served flat download folder.
# #
# Run from the repo root AFTER building for this host's target, e.g.: # Run from the repo root AFTER building for this host's target, e.g.:
# bun scripts/release.ts # or --target mac-x64 / win-x64 # bun scripts/release.ts # or --target win-x64 / linux-x64
# site/deploy.sh # site/deploy.sh
# #
# electrobun cannot cross-compile, so each OS/arch is built (and deployed) on its # electrobun cannot cross-compile, so each OS/arch is built (and deployed) on its
@@ -19,12 +19,15 @@ set -euo pipefail
dl="${MINISCP_DL_DIR:-/srv/miniscp/dl}" dl="${MINISCP_DL_DIR:-/srv/miniscp/dl}"
mkdir -p "$dl" mkdir -p "$dl"
# electrobun's flat artifacts (stable-<os>-<arch>-*): installers, update payloads, # electrobun's flat artifacts, verbatim — the updater contract, never rename them:
# update.json, and (once release.baseUrl is set) .patch files. # the update feed (stable-<os>-<arch>-update.json, .tar.zst, .patch) plus the
if compgen -G 'artifacts/stable-*' >/dev/null; then # installers, which 2.x names without the channel prefix (macos-arm64-miniSCP.dmg,
cp -f artifacts/stable-* "$dl"/ # win-x64-miniSCP-Setup.zip). release.ts empties artifacts/ first, so it holds
# exactly this build.
if compgen -G 'artifacts/*' >/dev/null; then
cp -f artifacts/* "$dl"/
else else
echo "deploy: WARN — no artifacts/stable-* found (did scripts/release.ts run?)" >&2 echo "deploy: WARN — artifacts/ is empty (did scripts/release.ts run?)" >&2
fi fi
# Windows portable zip is a release.ts extra (build-numbered) → give it a stable name. # Windows portable zip is a release.ts extra (build-numbered) → give it a stable name.
+4 -5
View File
@@ -215,9 +215,8 @@
<h2 id="download">Download</h2> <h2 id="download">Download</h2>
<p>Always the latest stable build.</p> <p>Always the latest stable build.</p>
<div class="downloads"> <div class="downloads">
<a class="dl" href="/dl/stable-macos-arm64-miniSCP.dmg">macOS<small>Apple Silicon</small></a> <a class="dl" href="/dl/macos-arm64-miniSCP.dmg">macOS<small>Apple Silicon</small></a>
<a class="dl" href="/dl/stable-macos-x64-miniSCP.dmg">macOS<small>Intel</small></a> <a class="dl" href="/dl/win-x64-miniSCP-Setup.zip">Windows<small>Installer</small></a>
<a class="dl" href="/dl/stable-win-x64-miniSCP-Setup.zip">Windows<small>Installer</small></a>
<a class="dl" href="/dl/stable-win-x64-miniSCP-portable.zip">Windows<small>Portable</small></a> <a class="dl" href="/dl/stable-win-x64-miniSCP-portable.zip">Windows<small>Portable</small></a>
</div> </div>
<p class="browse">Looking for everything? <a href="/dl/">browse all files</a>.</p> <p class="browse">Looking for everything? <a href="/dl/">browse all files</a>.</p>
@@ -226,8 +225,8 @@
<h2 id="faq">FAQ</h2> <h2 id="faq">FAQ</h2>
<dl class="faqtable"> <dl class="faqtable">
<dt>Which platforms are supported?</dt> <dt>Which platforms are supported?</dt>
<dd>macOS (Apple Silicon and Intel) and Windows 11+ (x64). macOS is the primary target; <dd>macOS (Apple Silicon) and Windows 11+ (x64). macOS is the primary target;
Windows is currently in beta.</dd> Windows is currently in beta. Intel Macs are not supported.</dd>
<dt>macOS says the app is "damaged and can't be opened".</dt> <dt>macOS says the app is "damaged and can't be opened".</dt>
<dd>The builds aren't code-signed yet, so macOS quarantines them on download. Clear the <dd>The builds aren't code-signed yet, so macOS quarantines them on download. Clear the
Binary file not shown.
+79 -42
View File
@@ -1,4 +1,4 @@
import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "electrobun/bun"; import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "electrobun/main";
import { BRANDING } from "../shared/branding"; import { BRANDING } from "../shared/branding";
import type { EditorOpenContext } from "../shared/rpc"; import type { EditorOpenContext } from "../shared/rpc";
import { effectiveConcurrency, type AppSettings } from "../shared/types"; import { effectiveConcurrency, type AppSettings } from "../shared/types";
@@ -10,18 +10,13 @@ import { ManagerWindow } from "./manager-window";
import { setLocale as setBackendLocale } from "./i18n"; import { setLocale as setBackendLocale } from "./i18n";
import { applyApplicationMenu } from "./menu"; import { applyApplicationMenu } from "./menu";
import { createAppRPC } from "./rpc-handlers"; import { createAppRPC } from "./rpc-handlers";
import { SettingsWindow } from "./settings-window";
import { ConnectionStore } from "./store/connections"; import { ConnectionStore } from "./store/connections";
import { migrateLegacyDataDir } from "./store/paths"; import { migrateLegacyDataDir } from "./store/paths";
import { SettingsStore } from "./store/settings"; import { SettingsStore } from "./store/settings";
import { AppTray } from "./tray"; import { AppTray } from "./tray";
import { TransferEngine } from "./transfers/engine"; import { TransferEngine } from "./transfers/engine";
import { import { buildPreload, chromeOptions, showWhenReady } from "./windows";
buildPreload,
chromeOptions,
initSessionSkin,
showWhenReady,
trackMaximizeState,
} from "./windows";
const PROD_URL = "views://app/index.html"; const PROD_URL = "views://app/index.html";
@@ -58,21 +53,11 @@ const events = {
openEditor: (_ctx: EditorOpenContext) => {}, openEditor: (_ctx: EditorOpenContext) => {},
settingsChanged: (_settings: AppSettings) => {}, settingsChanged: (_settings: AppSettings) => {},
openConnectionManager: () => {}, openConnectionManager: () => {},
openSettings: () => {},
showAbout: () => {}, showAbout: () => {},
applyUpdate: async () => {},
}; };
// Late-bound for the same reason events is: `win` below takes this rpc in its const rpc = createAppRPC({ registry, engine, store, settings, edits, watcher, events });
// constructor, so it cannot exist yet. Only the win7 title bar reads it.
let mainWin: BrowserWindow | undefined;
const rpc = createAppRPC({
registry,
engine,
store,
settings,
edits,
watcher,
mainWindow: () => mainWin,
events,
});
// late-bound like events: a watched local dir changed -> tell the main webview // late-bound like events: a watched local dir changed -> tell the main webview
watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir }); watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir });
const url = await getMainViewUrl(); const url = await getMainViewUrl();
@@ -82,11 +67,6 @@ const url = await getMainViewUrl();
// fresh via the saveSettings flow below. // fresh via the saveSettings flow below.
let currentSettings = await settings.load(); let currentSettings = await settings.load();
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
// Freeze the skin for this process BEFORE any window is constructed. It picks
// a component tree and a native frame, neither of which can change on a live
// window, so the persisted value may only take effect at the next start — see
// initSessionSkin.
initSessionSkin(currentSettings.skin);
const win = new BrowserWindow({ const win = new BrowserWindow({
title: BRANDING.appName, title: BRANDING.appName,
@@ -97,8 +77,7 @@ const win = new BrowserWindow({
x: 120, x: 120,
y: 120, y: 120,
}, },
// Frame decision lives in chromeOptions — see windows.ts. Constructor-only, // frame decision lives in chromeOptions (windows.ts); constructor-only
// and read from the pinned session skin, not from currentSettings.
...chromeOptions(), ...chromeOptions(),
// lets the UI pick the fused layout + theme before first paint // lets the UI pick the fused layout + theme before first paint
preload: buildPreload("main", currentSettings), preload: buildPreload("main", currentSettings),
@@ -107,8 +86,6 @@ const win = new BrowserWindow({
hidden: true, hidden: true,
}); });
showWhenReady(win); showWhenReady(win);
mainWin = win;
trackMaximizeState(win, (maximized) => rpc.send.windowState({ maximized }));
const editors = new EditorWindows({ const editors = new EditorWindows({
registry, registry,
@@ -118,6 +95,7 @@ const editors = new EditorWindows({
currentSettings: () => currentSettings, currentSettings: () => currentSettings,
mainFrame: () => win.getFrame(), mainFrame: () => win.getFrame(),
notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) }, notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) },
onSettingsSaved: (s, key) => settingsSaved(s, { editor: key }),
}); });
const manager = new ManagerWindow({ const manager = new ManagerWindow({
store, store,
@@ -130,21 +108,48 @@ const manager = new ManagerWindow({
connectionsChanged: () => rpc.send.connectionsChanged({}), connectionsChanged: () => rpc.send.connectionsChanged({}),
menuAction: (info) => rpc.send.menuAction(info), menuAction: (info) => rpc.send.menuAction(info),
}, },
rebuildMenu: () => void applyApplicationMenu(store), 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); events.openEditor = (ctx) => editors.open(ctx);
events.settingsChanged = (s) => { /**
* Every settings save lands here, whichever window made it (editor and manager
* windows save too — the scale shortcuts — and the Settings window is nothing
* but saves): side effects run once, then every OTHER window follows. The
* sender is skipped — it already shows the new values, and an echo arriving
* behind a newer local change (a held Cmd+) would drag it backwards.
*/
function settingsSaved(
s: AppSettings,
sender: "main" | "manager" | "settings" | { editor: string },
): void {
engine.setConcurrency(effectiveConcurrency(s));
const localeChanged = s.locale !== currentSettings.locale; const localeChanged = s.locale !== currentSettings.locale;
const keysChanged = JSON.stringify(s.keybindings) !== JSON.stringify(currentSettings.keybindings);
currentSettings = s; // future windows' preloads bake the fresh theme currentSettings = s; // future windows' preloads bake the fresh theme
if (localeChanged) { if (localeChanged) {
setBackendLocale(s.locale); setBackendLocale(s.locale);
void applyApplicationMenu(store); // relabel the native menu in the new language settingsWin.retitle();
} }
editors.broadcastSettings(s); // relabel the native menu in the new language / give it the remapped keys
manager.broadcastSettings(s); 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.openConnectionManager = () => manager.open();
events.openSettings = () => settingsWin.open();
function showAbout(): void { function showAbout(): void {
const { build } = BRANDING; const { build } = BRANDING;
@@ -163,6 +168,7 @@ events.showAbout = showAbout;
// and the modal manager has nothing to guard // and the modal manager has nothing to guard
win.on("close", () => { win.on("close", () => {
manager.close(); manager.close();
settingsWin.close();
editors.closeAllForAppExit(); editors.closeAllForAppExit();
}); });
@@ -187,6 +193,7 @@ engine.onUpdate = (snapshot) => {
if ( if (
!windowFocused && !windowFocused &&
!editors.anyFocused() && !editors.anyFocused() &&
!settingsWin.isFocused() &&
(snapshot.state === "completed" || snapshot.state === "failed") (snapshot.state === "completed" || snapshot.state === "failed")
) { ) {
Utils.showNotification({ Utils.showNotification({
@@ -207,11 +214,24 @@ engine.onUpdate = (snapshot) => {
// don't quit out from under running transfers or unsaved editors // don't quit out from under running transfers or unsaved editors
let quitConfirmed = false; let quitConfirmed = false;
// Updater.applyUpdate() asks this guard first (2.x), so remember the quit is
// update-initiated: "Quit Anyway" must re-run the apply, or a plain quit would
// drop the downloaded update.
let applyingUpdate = false;
events.applyUpdate = async () => {
applyingUpdate = true;
try {
await Updater.applyUpdate();
} finally {
applyingUpdate = false;
}
};
Electrobun.events.on("before-quit", (event) => { Electrobun.events.on("before-quit", (event) => {
const transfers = engine.activeJobCount(); const transfers = engine.activeJobCount();
const dirtyEditors = editors.dirtyCount(); const dirtyEditors = editors.dirtyCount();
if (quitConfirmed || (transfers === 0 && dirtyEditors === 0)) return; if (quitConfirmed || (transfers === 0 && dirtyEditors === 0)) return;
(event as { response: { allow: boolean } }).response = { allow: false }; (event as { response: { allow: boolean } }).response = { allow: false };
const updating = applyingUpdate; // read now: it clears once the vetoed apply returns
void (async () => { void (async () => {
const parts = [ const parts = [
transfers > 0 && `${transfers} transfer${transfers === 1 ? " is" : "s are"} still running`, transfers > 0 && `${transfers} transfer${transfers === 1 ? " is" : "s are"} still running`,
@@ -229,7 +249,10 @@ Electrobun.events.on("before-quit", (event) => {
}); });
if (response === 0) { if (response === 0) {
quitConfirmed = true; quitConfirmed = true;
Utils.quit(); // the trailing quit is a no-op once the apply is quitting, and still
// quits if it bails (no prepared download)
if (updating) void Updater.applyUpdate().finally(() => Utils.quit());
else Utils.quit();
} }
})(); })();
}); });
@@ -237,17 +260,24 @@ events.sessionClosed = (info) => {
rpc.send.sessionClosed(info); rpc.send.sessionClosed(info);
editors.notifySessionClosed(info); editors.notifySessionClosed(info);
}; };
events.connectionsChanged = () => void applyApplicationMenu(store); events.connectionsChanged = () => void applyApplicationMenu(store, () => currentSettings.keybindings);
engine.setConcurrency(effectiveConcurrency(currentSettings)); 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) return; if (!action) return;
// ⌘W while an editor window is focused closes that editor (with its dirty // Their pages claim close-window (⌘W) themselves — macOS hands key
// prompt), not a main-window tab. Other menu actions deliberately still // equivalents to the page first — so a close-tab that still arrives while
// target the main window. // 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 (action === "close-tab") {
if (settingsWin.isFocused()) {
settingsWin.close();
return;
}
const focusedEditor = editors.focusedKey(); const focusedEditor = editors.focusedKey();
if (focusedEditor) { if (focusedEditor) {
editors.requestClose(focusedEditor); editors.requestClose(focusedEditor);
@@ -258,6 +288,10 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
manager.open(); manager.open();
return; return;
} }
if (action === "settings") {
settingsWin.open();
return;
}
if (action === "about") { if (action === "about") {
showAbout(); showAbout();
return; return;
@@ -270,6 +304,9 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
} }
// the manager is modal to the main window — don't drive main behind it // the manager is modal to the main window — don't drive main behind it
if (manager.isOpen()) return; if (manager.isOpen()) return;
// 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 }); rpc.send.menuAction({ action });
}); });
+4 -3
View File
@@ -1,4 +1,4 @@
import type { BrowserWindow } from "electrobun/bun"; import type { BrowserWindow } from "electrobun/main";
import type { AppSettings } from "../shared/types"; import type { AppSettings } from "../shared/types";
import { createManagerRPC } from "./rpc-handlers"; import { createManagerRPC } from "./rpc-handlers";
import type { ConnectionStore } from "./store/connections"; import type { ConnectionStore } from "./store/connections";
@@ -24,6 +24,8 @@ export interface ManagerWindowDeps {
}; };
/** Rebuilds the native app menu after store changes. */ /** Rebuilds the native app menu after store changes. */
rebuildMenu: () => void; rebuildMenu: () => void;
/** The manager saved settings — rebroadcast them to every other window. */
onSettingsSaved: (settings: AppSettings) => void;
} }
/** /**
@@ -70,8 +72,7 @@ export class ManagerWindow {
this.close(); this.close();
}, },
onClose: () => this.close(), onClose: () => this.close(),
// late-bound: the window takes this rpc in its constructor onSettingsSaved: (next) => this.deps.onSettingsSaved(next),
window: () => this.win ?? undefined,
}, },
); );
+35 -11
View File
@@ -1,5 +1,6 @@
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/bun"; import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/main";
import { BRANDING } from "../shared/branding"; import { BRANDING } from "../shared/branding";
import { effectiveKeymap, type ShortcutId, toAccelerator } from "../shared/keymap";
import type { SavedConnection } from "../shared/types"; import type { SavedConnection } from "../shared/types";
import { t } from "./i18n"; import { t } from "./i18n";
import type { ConnectionStore } from "./store/connections"; import type { ConnectionStore } from "./store/connections";
@@ -7,13 +8,30 @@ 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}`,
@@ -28,7 +46,11 @@ 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: t("menu.connectionManager"), action: "manage-connections" }, {
label: t("menu.connectionManager"),
action: "manage-connections",
accelerator: accelerator("connection-manager"),
},
]; ];
ApplicationMenu.setApplicationMenu([ ApplicationMenu.setApplicationMenu([
@@ -36,6 +58,8 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
submenu: [ submenu: [
{ label: t("menu.about", { app: BRANDING.appName }), action: "about" }, { label: t("menu.about", { app: BRANDING.appName }), action: "about" },
{ type: "separator" }, { type: "separator" },
{ label: t("menu.settings"), action: "settings", accelerator: accelerator("open-settings") },
{ type: "separator" },
{ label: t("menu.checkForUpdates"), action: "check-for-updates" }, { label: t("menu.checkForUpdates"), action: "check-for-updates" },
{ type: "separator" }, { type: "separator" },
{ role: "hide" }, { role: "hide" },
@@ -51,13 +75,13 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
{ {
label: t("menu.file"), label: t("menu.file"),
submenu: [ submenu: [
{ label: t("menu.newTab"), action: "new-tab", accelerator: "t" }, { label: t("menu.newTab"), action: "new-tab", accelerator: accelerator("new-tab") },
{ label: t("menu.closeTab"), action: "close-tab", accelerator: "w" }, { label: t("menu.closeTab"), action: "close-tab", accelerator: accelerator("close-tab") },
{ type: "separator" }, { type: "separator" },
{ label: t("menu.newFolder"), action: "new-folder" }, { label: t("menu.newFolder"), action: "new-folder", accelerator: accelerator("new-folder") },
{ label: t("menu.newFile"), action: "new-file" }, { label: t("menu.newFile"), action: "new-file", accelerator: accelerator("new-file") },
{ type: "separator" }, { type: "separator" },
{ label: t("menu.editFile"), action: "edit-file", accelerator: "e" }, { label: t("menu.editFile"), action: "edit-file", accelerator: accelerator("edit") },
{ type: "separator" }, { type: "separator" },
{ label: t("menu.connect"), submenu: connectSubmenu }, { label: t("menu.connect"), submenu: connectSubmenu },
], ],
@@ -79,7 +103,7 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
{ {
label: t("menu.view"), label: t("menu.view"),
submenu: [ submenu: [
{ label: t("menu.refresh"), action: "refresh-active", accelerator: "r" }, { label: t("menu.refresh"), action: "refresh-active", accelerator: accelerator("refresh") },
{ type: "separator" }, { type: "separator" },
{ role: "toggleFullScreen" }, { role: "toggleFullScreen" },
], ],
+91 -126
View File
@@ -1,6 +1,6 @@
import { BrowserView, type BrowserWindow, Updater, Utils } from "electrobun/bun"; import { BrowserView, Updater, Utils } from "electrobun/main";
import type { AppRPC, EditorOpenContext } from "../shared/rpc"; import type { AppRPC, EditorOpenContext } from "../shared/rpc";
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types"; import type { AppSettings, Result } from "../shared/types";
import { readFileText, writeFileText, type EditManager } from "./edits"; import { readFileText, writeFileText, type EditManager } from "./edits";
import { listLocalDrives } from "./fs/local"; import { listLocalDrives } from "./fs/local";
import type { SessionRegistry } from "./fs/registry"; import type { SessionRegistry } from "./fs/registry";
@@ -24,12 +24,6 @@ export interface AppDeps {
settings: SettingsStore; settings: SettingsStore;
edits: EditManager; edits: EditManager;
watcher: LocalDirWatcher; watcher: LocalDirWatcher;
/**
* The main BrowserWindow, for the win7 skin's custom title-bar controls.
* Lazy for the same reason events is late-bound: the window takes this rpc
* instance in its constructor, so it does not exist yet at createAppRPC time.
*/
mainWindow: () => BrowserWindow | undefined;
/** Assigned after the rpc object exists (it needs rpc.send). */ /** Assigned after the rpc object exists (it needs rpc.send). */
events: { events: {
sessionClosed: (info: { sessionId: string; reason: string }) => void; sessionClosed: (info: { sessionId: string; reason: string }) => void;
@@ -37,7 +31,10 @@ export interface AppDeps {
openEditor: (ctx: EditorOpenContext) => void; openEditor: (ctx: EditorOpenContext) => void;
settingsChanged: (settings: AppSettings) => void; settingsChanged: (settings: AppSettings) => void;
openConnectionManager: () => void; openConnectionManager: () => void;
openSettings: () => void;
showAbout: () => void; showAbout: () => void;
/** "Restart to apply" — lives next to the before-quit guard it must get past. */
applyUpdate: () => Promise<void>;
}; };
} }
@@ -49,78 +46,6 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
} }
} }
/**
* Window-control requests for the win7 skin's custom title bar. Shared by all
* three window kinds; each passes a getter for ITS OWN window.
*
* The getter is lazy because the main window's rpc instance is constructed
* before the BrowserWindow exists (the window takes the rpc in its
* constructor) — same late-binding reason as AppDeps.events. Under the modern
* skin nothing calls these; they are harmless no-ops if the window is gone.
*/
export function windowControlHandlers(
getWindow: () => BrowserWindow | undefined,
/**
* What the title bar's X does. Defaults to a raw `win.close()`. Editor
* windows MUST pass their confirmed-close hook instead — the native close
* is not cancellable, so a raw close there would skip the dirty prompt
* (see gotchas.md "Window `close` is not cancellable").
*/
onClose: (win: BrowserWindow | undefined) => void = (win) => win?.close(),
) {
return {
windowMinimize: () =>
wrap(() => {
getWindow()?.minimize();
return {};
}),
windowToggleMaximize: () =>
wrap(() => {
const win = getWindow();
if (!win) return { maximized: false };
// isMaximized is a synchronous FFI read, so this stays one round trip
const next = !win.isMaximized();
if (next) win.maximize();
else win.unmaximize();
return { maximized: next };
}),
windowClose: () =>
wrap(() => {
onClose(getWindow());
return {};
}),
windowIsMaximized: () => wrap(() => ({ maximized: getWindow()?.isMaximized() ?? false })),
// ---- STOPGAP: synthesized resize ----
// A frameless GTK window has no resize borders, and electrobun exposes no
// native resize gesture (it has startWindowMove and nothing else — the
// shipped libNativeWrapper does not even link gtk_window_begin_resize_drag).
// So the webview drags its own handles and moves the frame itself. Both are
// synchronous FFI reads/writes, like isMaximized above, so each drag tick
// stays one round trip. DELETE WITH THE REST OF THE HACK — the checklist is
// in ui/src/app/skins/7like/window-resize/window-resize.ts.
windowGetFrame: () =>
wrap(() => getWindow()?.getFrame() ?? { x: 0, y: 0, width: 0, height: 0 }),
// Params are annotated from the schema rather than inferred: this factory
// returns a bare object literal, so it gets none of the contextual typing
// the inline handler sets inside defineRPC<AppRPC> enjoy.
windowSetFrame: ({
x,
y,
width,
height,
}: AppRPC["bun"]["requests"]["windowSetFrame"]["params"]) =>
wrap(() => {
getWindow()?.setFrame(x, y, width, height);
return {};
}),
};
}
/** Connection-store requests, shared by the main and manager window rpcs. */ /** Connection-store requests, shared by the main and manager window rpcs. */
function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) { function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) {
return { return {
@@ -177,6 +102,22 @@ function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void)
}; };
} }
/**
* saveSettings for every window kind: persist, then hand the new object to the
* composition root (index.ts), which applies the side effects once and
* rebroadcasts to every OTHER window. Known ceiling: whole-object writes — two
* windows saving in the same instant, the later one wins. Upgrade path: send a
* patch and merge it bun-side.
*/
const saveSettingsHandler =
(store: SettingsStore, onSaved: (next: AppSettings) => void) =>
({ settings: next }: { settings: AppSettings }) =>
wrap(async () => {
await store.save(next);
onSaved(next);
return {};
});
const pickKeyFileHandler = () => const pickKeyFileHandler = () =>
wrap(async () => { wrap(async () => {
const { homedir } = await import("node:os"); const { homedir } = await import("node:os");
@@ -192,6 +133,28 @@ const pickKeyFileHandler = () =>
return { path }; return { path };
}); });
/** External-editor picker, shared by the main and settings window rpcs. */
const pickEditorAppHandler = () =>
wrap(async () => {
// PORTABILITY: apps live in different places per OS
const { homedir } = await import("node:os");
const [startingFolder, allowedFileTypes] =
process.platform === "darwin"
? ["/Applications", "app"]
: process.platform === "win32"
? [process.env.ProgramFiles ?? "C:\\Program Files", "exe"]
: [homedir(), "*"];
const picked = await Utils.openFileDialog({
startingFolder,
allowedFileTypes,
canChooseFiles: true,
canChooseDirectory: false,
allowsMultipleSelection: false,
});
const path = picked.find((p) => p.trim().length > 0) ?? null;
return { path };
});
export function createAppRPC({ export function createAppRPC({
registry, registry,
engine, engine,
@@ -199,7 +162,6 @@ export function createAppRPC({
settings, settings,
edits, edits,
watcher, watcher,
mainWindow,
events, events,
}: AppDeps) { }: AppDeps) {
return BrowserView.defineRPC<AppRPC>({ return BrowserView.defineRPC<AppRPC>({
@@ -297,22 +259,13 @@ export function createAppRPC({
return {}; return {};
}), }),
// ---- custom window chrome (win7 skin) ----
...windowControlHandlers(mainWindow),
// ---- connection store ---- // ---- connection store ----
...connectionStoreHandlers(store, () => events.connectionsChanged()), ...connectionStoreHandlers(store, () => events.connectionsChanged()),
// ---- settings ---- // ---- settings ----
getSettings: () => wrap(async () => ({ settings: await settings.load() })), getSettings: () => wrap(async () => ({ settings: await settings.load() })),
saveSettings: ({ settings: next }) => saveSettings: saveSettingsHandler(settings, (next) => events.settingsChanged(next)),
wrap(async () => {
await settings.save(next);
engine.setConcurrency(effectiveConcurrency(next));
events.settingsChanged(next);
return {};
}),
// ---- file editing ---- // ---- file editing ----
readFile: ({ sessionId, path }) => readFile: ({ sessionId, path }) =>
@@ -329,26 +282,7 @@ export function createAppRPC({
editExternal: ({ sessionId, path }) => editExternal: ({ sessionId, path }) =>
wrap(() => edits.editExternal(sessionId, path)), wrap(() => edits.editExternal(sessionId, path)),
pickEditorApp: () => pickEditorApp: pickEditorAppHandler,
wrap(async () => {
// PORTABILITY: apps live in different places per OS
const { homedir } = await import("node:os");
const [startingFolder, allowedFileTypes] =
process.platform === "darwin"
? ["/Applications", "app"]
: process.platform === "win32"
? [process.env.ProgramFiles ?? "C:\\Program Files", "exe"]
: [homedir(), "*"];
const picked = await Utils.openFileDialog({
startingFolder,
allowedFileTypes,
canChooseFiles: true,
canChooseDirectory: false,
allowsMultipleSelection: false,
});
const path = picked.find((p) => p.trim().length > 0) ?? null;
return { path };
}),
pickKeyFile: pickKeyFileHandler, pickKeyFile: pickKeyFileHandler,
@@ -390,6 +324,13 @@ export function createAppRPC({
return {}; return {};
}), }),
// ---- settings window ----
openSettings: () =>
wrap(async () => {
events.openSettings();
return {};
}),
showAbout: () => showAbout: () =>
wrap(async () => { wrap(async () => {
events.showAbout(); events.showAbout();
@@ -413,7 +354,7 @@ export function createAppRPC({
applyUpdate: () => applyUpdate: () =>
wrap(async () => { wrap(async () => {
await Updater.applyUpdate(); // relaunches + quits; may never resolve await events.applyUpdate(); // relaunches + quits; may never resolve
return {}; return {};
}), }),
}, },
@@ -429,8 +370,8 @@ export interface EditorRpcHooks {
onSaved: () => void; onSaved: () => void;
/** Webview resolved its dirty prompt: close without the native fallback. */ /** Webview resolved its dirty prompt: close without the native fallback. */
onCloseConfirmed: () => void; onCloseConfirmed: () => void;
/** This window, for the win7 title-bar controls (late-bound like the rest). */ /** This window saved settings (e.g. the scale shortcuts) — rebroadcast them. */
window: () => BrowserWindow | undefined; onSettingsSaved: (next: AppSettings) => void;
} }
/** /**
@@ -452,6 +393,7 @@ export function createEditorRPC(
})), })),
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })), getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
editorSave: ({ content }) => editorSave: ({ content }) =>
wrap(async () => { wrap(async () => {
@@ -466,12 +408,6 @@ export function createEditorRPC(
hooks.onCloseConfirmed(); hooks.onCloseConfirmed();
return {}; return {};
}), }),
// win7 skin: this window is frameless, so the webview draws the
// chrome. The X routes through onCloseConfirmed (suppressPrompt +
// close), exactly like editorClose — the editor UI runs its dirty
// prompt BEFORE calling this, same as the ⌘W path.
...windowControlHandlers(hooks.window, () => hooks.onCloseConfirmed()),
}, },
messages: { messages: {
editorState: (state) => hooks.onDraft(state), editorState: (state) => hooks.onDraft(state),
@@ -487,8 +423,8 @@ export interface ManagerRpcHooks {
onConnect: (id: string) => void; onConnect: (id: string) => void;
onOpenStack: (id: string) => void; onOpenStack: (id: string) => void;
onClose: () => void; onClose: () => void;
/** This window, for the win7 title-bar controls (late-bound like the rest). */ /** This window saved settings (e.g. the scale shortcuts) — rebroadcast them. */
window: () => BrowserWindow | undefined; onSettingsSaved: (next: AppSettings) => void;
} }
/** RPC for the single connection-manager window (per-window instance). */ /** RPC for the single connection-manager window (per-window instance). */
@@ -503,6 +439,7 @@ export function createManagerRPC(
...connectionStoreHandlers(deps.store, hooks.onChanged), ...connectionStoreHandlers(deps.store, hooks.onChanged),
pickKeyFile: pickKeyFileHandler, pickKeyFile: pickKeyFileHandler,
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })), getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
managerConnect: ({ id }) => managerConnect: ({ id }) =>
wrap(async () => { wrap(async () => {
@@ -521,10 +458,38 @@ export function createManagerRPC(
hooks.onClose(); hooks.onClose();
return {}; return {};
}), }),
},
// win7 skin: frameless window, webview-drawn chrome. The X reuses },
// the managerClose path so the modal bookkeeping still runs. });
...windowControlHandlers(hooks.window, () => hooks.onClose()), }
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 });
}
}
+4
View File
@@ -1,5 +1,6 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { sanitizeKeybindings } from "../../shared/keymap";
import { clampUiScale, DEFAULT_SETTINGS, type AppSettings } from "../../shared/types"; import { clampUiScale, DEFAULT_SETTINGS, type AppSettings } from "../../shared/types";
import { appDataDir } from "./paths"; import { appDataDir } from "./paths";
@@ -24,6 +25,9 @@ export class SettingsStore {
// into every window's root font-size, so a hand-edited settings.json // 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 // (uiScale: 100, "2", NaN) must not be able to render the UI unusable
settings.uiScale = clampUiScale(Number(settings.uiScale)); 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; return settings;
} catch { } catch {
return { ...DEFAULT_SETTINGS }; return { ...DEFAULT_SETTINGS };
+1 -1
View File
@@ -1,4 +1,4 @@
import { Tray, type BrowserWindow } from "electrobun/bun"; import { Tray, type BrowserWindow } from "electrobun/main";
/** /**
* Menu-bar / notification-area icon. Clicking it brings the app window to the * Menu-bar / notification-area icon. Clicking it brings the app window to the
+18 -93
View File
@@ -1,31 +1,9 @@
import { BrowserWindow } from "electrobun/bun"; import { BrowserWindow } from "electrobun/main";
import { DEFAULT_SETTINGS, type AppSettings } from "../shared/types"; import type { AppSettings } from "../shared/types";
/**
* The skin THIS PROCESS booted with, frozen at startup by `initSessionSkin`.
*
* Every window must be built from this, never from the live settings snapshot.
* The skin picks a whole component tree (bootstrapped from the preload-baked
* __APP_SKIN) and the native frame (constructor-only), so it cannot change
* without a restart — but the persisted setting CAN change mid-session. Reading
* the live value would boot a newly opened editor or manager into the pending
* skin while the main window still runs the old one: two windows, two trees,
* two frames.
*
* Pinning it in one place makes that impossible rather than making each call
* site remember. Everything else in the preload keeps using the live snapshot,
* because theme/tone/accent/text-size genuinely do apply live.
*/
let sessionSkin: AppSettings["skin"] = DEFAULT_SETTINGS.skin;
/** Call once, at startup, before any window is constructed. */
export function initSessionSkin(skin: AppSettings["skin"]): void {
sessionSkin = skin;
}
/** /**
* The preload globals every window gets. Single composition point: the UI * The preload globals every window gets. Single composition point: the UI
* reads them in ui/src/app/platform.ts before first paint (window chrome and * reads them in ui/src/app/core/platform.ts before first paint (window chrome and
* root-component selection both depend on them). * root-component selection both depend on them).
* *
* The persisted theme attributes are baked in here — mirroring the * The persisted theme attributes are baked in here — mirroring the
@@ -35,30 +13,18 @@ export function initSessionSkin(skin: AppSettings["skin"]): void {
* windows flash the wrong background. * windows flash the wrong background.
*/ */
export function buildPreload( export function buildPreload(
mode: "main" | "editor" | "manager", mode: "main" | "editor" | "manager" | "settings",
settings: AppSettings, settings: AppSettings,
ctx?: unknown, ctx?: unknown,
): string { ): string {
// sessionSkin, NOT settings.skin — a pending skin change must not leak into
// a window opened before the restart. See initSessionSkin.
let script = let script =
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` + `window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
`window.__APP_MODE=${JSON.stringify(mode)};` + `window.__APP_MODE=${JSON.stringify(mode)};`;
`window.__APP_SKIN=${JSON.stringify(sessionSkin)};`;
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`; if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
// A class, not a data-* attribute: 7.scoped.css is precompiled to require a
// literal `.win7` ancestor, so the name is not ours to choose. This is the
// ONLY place it is ever written — the UI must never toggle it live, because
// each skin's CSS is written against its own tree's markup.
if (sessionSkin === "win7") {
script += `document.documentElement.classList.add("win7");`;
}
const dataset: Record<string, string> = {}; const dataset: Record<string, string> = {};
if (settings.theme !== "system") dataset["theme"] = settings.theme; if (settings.theme !== "system") dataset["theme"] = settings.theme;
if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone; if (settings.darkTone !== "dark") dataset["darkTone"] = settings.darkTone;
if (settings.accent !== "graphite") dataset["accent"] = settings.accent;
// interface scale: mirrors SettingsService.applyUiScale so the FIRST paint // interface scale: mirrors SettingsService.applyUiScale so the FIRST paint
// is already scaled — without this every window opens at 100% and snaps. // 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. // Written as an inline style, not a data attribute: the scale is continuous.
@@ -72,34 +38,21 @@ export function buildPreload(
} }
/** /**
* Native chrome options for a window. The ONE place the frame decision is made — * Native chrome for every window — the ONE place the frame is decided; the main
* both the main window (src/bun/index.ts) and openSecondaryWindow spread this. * 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.
* *
* Both values are BrowserWindow CONSTRUCTOR options — there is no API to * electrobun 2.x adds trafficLightOffset to macOS's default button position
* re-decorate a live window — which is half of why the skin is a launch-time * (9/9 in the 32pt macOS 26 titlebar) and grows the titlebar to fit — 1.18.1
* choice. Takes no argument on purpose: it reads the pinned sessionSkin, so no * clamped the shift inside the stock titlebar instead. {12, 4} puts the 14pt
* call site can accidentally frame a window from a pending setting. * buttons at left 21 / top 13: vertically centred in the 40px h-10 header.
*/ */
export function chromeOptions() { export function chromeOptions() {
const skin = sessionSkin;
// win7 draws its own Aero title bar (drag region + our minimize/maximize/
// close), so the window is frameless on EVERY platform. `titleBarStyle:
// "hidden"` clears `Titled` from the Cocoa style mask, which is also what
// removes the macOS traffic lights — there is no separate
// hide-traffic-lights API. Linux honours it too: the GTK wrapper calls
// gtk_window_set_decorated(false).
//
// `transparent` is what lets the Aero frame be a real frame: .window's 6px
// radius is drawn by the webview OUTSIDE the window's own rectangle, so on
// an opaque surface the corners are simply clipped square.
// PORTABILITY: on Linux this needs a compositor granting an RGBA visual.
if (skin === "win7") return { titleBarStyle: "hidden" as const, transparent: true };
// modern: the native frame stays. On macOS it fuses with the app's own
// header row (hiddenInset), and the traffic lights overlay it — which is
// what IS_MAC_CHROME reserves pl-24 for in the shadlike tree.
return process.platform === "darwin" return process.platform === "darwin"
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } } ? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 4 } }
: {}; : {};
} }
@@ -123,36 +76,11 @@ export function showWhenReady(win: BrowserWindow): void {
}); });
} }
/**
* Push maximize-state changes to a window's webview so the win7 title bar can
* swap its maximize/restore glyph.
*
* electrobun emits NO maximize/unmaximize event (only close/resize/move/focus/
* blur/keyDown/keyUp), so the state is derived from `resize` — which is also
* what makes OS-initiated changes work: double-click zoom, Win+Up, Aero Snap
* all resize the window. `isMaximized()` is a synchronous FFI read, so polling
* it per resize is cheap; the last-value guard keeps the wire quiet during a
* drag-resize. The emitter has no off(), so this closure must not capture
* anything that needs to be garbage collected (see WindowRegistry.track).
*/
export function trackMaximizeState(
win: BrowserWindow,
push: (maximized: boolean) => void,
): void {
let last = win.isMaximized();
win.on("resize", () => {
const now = win.isMaximized();
if (now === last) return;
last = now;
push(now);
});
}
export interface SecondaryWindowOptions { export interface SecondaryWindowOptions {
title: string; title: string;
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */ /** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
url: string; url: string;
mode: "editor" | "manager"; mode: "editor" | "manager" | "settings";
/** Current persisted settings — baked into the preload for flash-free paint. */ /** Current persisted settings — baked into the preload for flash-free paint. */
settings: AppSettings; settings: AppSettings;
ctx?: unknown; ctx?: unknown;
@@ -162,10 +90,7 @@ export interface SecondaryWindowOptions {
rpc: NonNullable<ConstructorParameters<typeof BrowserWindow>[0]>["rpc"]; rpc: NonNullable<ConstructorParameters<typeof BrowserWindow>[0]>["rpc"];
} }
/** /** App-chromed secondary window; its frame comes from chromeOptions. */
* App-chromed secondary window. The frame decision is delegated to
* chromeOptions (the single frame decision).
*/
export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow { export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow {
const win = new BrowserWindow({ const win = new BrowserWindow({
title: opts.title, title: opts.title,
+151 -35
View File
@@ -21,38 +21,29 @@ export const de = {
tabBar: { tabBar: {
connections: "Verbindungen", connections: "Verbindungen",
manageConnections: "Verbindungen verwalten", manageConnections: "Verbindungen verwalten",
newTab: "Neuer Tab ({mod}T)", newTab: "Neuer Tab",
closeTab: "Tab schließen ({mod}W)", closeTab: "Tab schließen",
options: "Optionen", options: "Einstellungen",
update: "Update verfügbar", update: "Update verfügbar",
updating: "Aktualisiere", updating: "Aktualisiere",
restartToUpdate: "Zum Anwenden neu starten", restartToUpdate: "Zum Anwenden neu starten",
updateTooltip: "Update verfügbar: miniSCP {version}", updateTooltip: "Update verfügbar: miniSCP {version}",
}, },
options: { options: {
title: "Optionen", title: "Einstellungen",
sections: {
appearance: "Darstellung",
transfers: "Übertragungen",
editor: "Editor",
fileList: "Dateiliste",
keyboard: "Tastatur",
about: "Über",
},
columns: "Sichtbare Spalten",
language: "Sprache", language: "Sprache",
system: "System", system: "System",
skin: {
label: "Optik",
modern: "Modern",
win7: "Windows 7",
restartHint: "Wird beim nächsten Start von miniSCP übernommen",
},
theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" }, theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" },
tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" }, tone: { label: "Ton", dark: "Dunkel", darker: "AMOLED" },
accent: { label: "Akzent" },
accentNames: {
graphite: "Graphit",
blue: "Blau",
indigo: "Indigo",
purple: "Lila",
rose: "Rosa",
orange: "Orange",
green: "Grün",
teal: "Petrol",
},
fileIcons: { label: "Datei-Symbole", plain: "Einfach", multi: "Farbig" },
interface: { label: "Oberfläche", smaller: "Kleiner", larger: "Größer", reset: "Auf 100% zurücksetzen" }, interface: { label: "Oberfläche", smaller: "Kleiner", larger: "Größer", reset: "Auf 100% zurücksetzen" },
speedUnit: { label: "Geschwindigkeitseinheit" }, speedUnit: { label: "Geschwindigkeitseinheit" },
transfers: { label: "Übertragungen", single: "Einzeln", multi: "Mehrere" }, transfers: { label: "Übertragungen", single: "Einzeln", multi: "Mehrere" },
@@ -61,6 +52,7 @@ export const de = {
systemDefault: "Systemstandard", systemDefault: "Systemstandard",
resetDefault: "Auf Standard zurücksetzen", resetDefault: "Auf Standard zurücksetzen",
finishedTransfers: { label: "Abgeschlossene Übertragungen", autoClear: "Auto-löschen", keep: "Behalten" }, 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: { kinds: {
folder: "Ordner", folder: "Ordner",
@@ -85,17 +77,16 @@ export const de = {
panel: { panel: {
choosePanel: "Wählen, was dieses Panel anzeigt", choosePanel: "Wählen, was dieses Panel anzeigt",
copy: "Senden", copy: "Senden",
copyToRight: "An rechtes Panel senden (F5)", copyToRight: "An rechtes Panel senden",
copyToLeft: "An linkes Panel senden (F5)", copyToLeft: "An linkes Panel senden",
edit: "Bearbeiten", edit: "Bearbeiten",
editFile: "Datei bearbeiten (F4)", editFile: "Datei bearbeiten",
rename: "Umbenennen (F2)", rename: "Umbenennen",
newFolder: "Neuer Ordner (F7)", newFolder: "Neuer Ordner",
newFile: "Neue Datei", newFile: "Neue Datei",
permissions: "Berechtigungen", permissions: "Berechtigungen",
delete: "Löschen", delete: "Löschen",
deleteHint: "Löschen (F8 / Entf)", parentDir: "Übergeordneter Ordner",
parentDir: "Übergeordneter Ordner (Rücktaste)",
refresh: "Aktualisieren", refresh: "Aktualisieren",
clearFilter: "Filter löschen (Esc)", clearFilter: "Filter löschen (Esc)",
sshConfig: "~/.ssh/config", sshConfig: "~/.ssh/config",
@@ -154,16 +145,16 @@ export const de = {
noFileTitle: "Keine Datei zum Bearbeiten", noFileTitle: "Keine Datei zum Bearbeiten",
noFileBody: "Dieses Fenster wurde ohne Editor-Kontext geöffnet. Öffne Dateien aus der App.", noFileBody: "Dieses Fenster wurde ohne Editor-Kontext geöffnet. Öffne Dateien aus der App.",
wrap: "Umbruch", wrap: "Umbruch",
wrapHint: "Lange Zeilen umbrechen — blendet Zeilennummern aus ({alt}Z)", wrapHint: "Lange Zeilen umbrechen — blendet Zeilennummern aus",
findHint: "Suchen ({mod}F)", findHint: "Suchen",
findPlaceholder: "In Datei suchen", findPlaceholder: "In Datei suchen",
prevMatch: "Vorheriger Treffer (⇧↵)", prevMatch: "Vorheriger Treffer",
nextMatch: "Nächster Treffer (↵)", nextMatch: "Nächster Treffer",
closeHint: "Schließen (Esc)", closeHint: "Schließen (Esc)",
disconnected: "Verbindung zu {name} getrennt — Speichern schlägt fehl, aber dein Text bleibt in diesem Fenster erhalten.", disconnected: "Verbindung zu {name} getrennt — Speichern schlägt fehl, aber dein Text bleibt in diesem Fenster erhalten.",
cantOpen: "{name} kann nicht geöffnet werden", cantOpen: "{name} kann nicht geöffnet werden",
closeWindow: "Fenster schließen", closeWindow: "Fenster schließen",
shortcuts: "{mod}S speichern · {mod}F suchen · {mod}W schließen", verbs: { save: "speichern", find: "suchen", close: "schließen" },
lines: { one: "{count} Zeile", other: "{count} Zeilen" }, lines: { one: "{count} Zeile", other: "{count} Zeilen" },
close: "Schließen", close: "Schließen",
save: "Speichern", save: "Speichern",
@@ -219,6 +210,7 @@ export const de = {
}, },
toasts: { toasts: {
movedToTrash: '„{name}" in den Papierkorb verschoben', 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", editingExternal: "Bearbeite {name} — Speichern lädt automatisch hoch",
nothingToCopy: "Nichts zu kopieren (symbolische Links werden übersprungen)", nothingToCopy: "Nichts zu kopieren (symbolische Links werden übersprungen)",
nothingToCopyAllSkipped: "Nichts zu kopieren — alle Konflikte wurden übersprungen", nothingToCopyAllSkipped: "Nichts zu kopieren — alle Konflikte wurden übersprungen",
@@ -255,6 +247,7 @@ export const de = {
}, },
menu: { menu: {
about: "Über {app}", about: "Über {app}",
settings: "Einstellungen…",
checkForUpdates: "Nach Updates suchen…", checkForUpdates: "Nach Updates suchen…",
quit: "Beenden", quit: "Beenden",
file: "Datei", file: "Datei",
@@ -301,6 +294,129 @@ export const de = {
tabDisconnect: "Trennen", tabDisconnect: "Trennen",
trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar", trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar",
}, },
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: { errors: {
sshAgentUnavailable: "ssh-agent nicht verfügbar (SSH_AUTH_SOCK ist nicht gesetzt)", sshAgentUnavailable: "ssh-agent nicht verfügbar (SSH_AUTH_SOCK ist nicht gesetzt)",
identityFileNotFound: "Identitätsdatei nicht gefunden: {path}", identityFileNotFound: "Identitätsdatei nicht gefunden: {path}",
+156 -35
View File
@@ -25,38 +25,29 @@ export const en = {
tabBar: { tabBar: {
connections: "Connections", connections: "Connections",
manageConnections: "Manage connections", manageConnections: "Manage connections",
newTab: "New tab ({mod}T)", newTab: "New tab",
closeTab: "Close tab ({mod}W)", closeTab: "Close tab",
options: "Options", options: "Settings",
update: "Update available", update: "Update available",
updating: "Updating", updating: "Updating",
restartToUpdate: "Restart to apply", restartToUpdate: "Restart to apply",
updateTooltip: "Update available: miniSCP {version}", updateTooltip: "Update available: miniSCP {version}",
}, },
options: { options: {
title: "Options", title: "Settings",
sections: {
appearance: "Appearance",
transfers: "Transfers",
editor: "Editor",
fileList: "File list",
keyboard: "Keyboard",
about: "About",
},
columns: "Visible columns",
language: "Language", language: "Language",
system: "System", system: "System",
skin: {
label: "Skin",
modern: "Modern",
win7: "Windows 7",
restartHint: "Applies next time you start miniSCP",
},
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" }, theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
tone: { label: "Tone", dark: "Dark", darker: "Darker" }, tone: { label: "Tone", dark: "Dark", darker: "AMOLED" },
accent: { label: "Accent" },
accentNames: {
graphite: "Graphite",
blue: "Blue",
indigo: "Indigo",
purple: "Purple",
rose: "Rose",
orange: "Orange",
green: "Green",
teal: "Teal",
},
fileIcons: { label: "File icons", plain: "Plain", multi: "Multi" },
interface: { label: "Interface", smaller: "Smaller", larger: "Larger", reset: "Reset to 100%" }, interface: { label: "Interface", smaller: "Smaller", larger: "Larger", reset: "Reset to 100%" },
speedUnit: { label: "Speed unit" }, speedUnit: { label: "Speed unit" },
transfers: { label: "Transfers", single: "Single", multi: "Multi" }, transfers: { label: "Transfers", single: "Single", multi: "Multi" },
@@ -65,6 +56,7 @@ export const en = {
systemDefault: "System default", systemDefault: "System default",
resetDefault: "Reset to system default", resetDefault: "Reset to system default",
finishedTransfers: { label: "Finished transfers", autoClear: "Auto-clear", keep: "Keep" }, 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: { kinds: {
folder: "Folder", folder: "Folder",
@@ -89,17 +81,16 @@ export const en = {
panel: { panel: {
choosePanel: "Choose what this panel shows", choosePanel: "Choose what this panel shows",
copy: "Send", copy: "Send",
copyToRight: "Send to right panel (F5)", copyToRight: "Send to right panel",
copyToLeft: "Send to left panel (F5)", copyToLeft: "Send to left panel",
edit: "Edit", edit: "Edit",
editFile: "Edit file (F4)", editFile: "Edit file",
rename: "Rename (F2)", rename: "Rename",
newFolder: "New folder (F7)", newFolder: "New folder",
newFile: "New file", newFile: "New file",
permissions: "Permissions", permissions: "Permissions",
delete: "Delete", delete: "Delete",
deleteHint: "Delete (F8 / Del)", parentDir: "Parent directory",
parentDir: "Parent directory (Backspace)",
refresh: "Refresh", refresh: "Refresh",
clearFilter: "Clear filter (Esc)", clearFilter: "Clear filter (Esc)",
sshConfig: "~/.ssh/config", sshConfig: "~/.ssh/config",
@@ -158,16 +149,17 @@ export const en = {
noFileTitle: "No file to edit", noFileTitle: "No file to edit",
noFileBody: "This window was opened without an editor context. Launch files from the app.", noFileBody: "This window was opened without an editor context. Launch files from the app.",
wrap: "Wrap", wrap: "Wrap",
wrapHint: "Wrap long lines — hides line numbers ({alt}Z)", wrapHint: "Wrap long lines — hides line numbers",
findHint: "Find ({mod}F)", findHint: "Find",
findPlaceholder: "Find in file", findPlaceholder: "Find in file",
prevMatch: "Previous match (⇧↵)", prevMatch: "Previous match",
nextMatch: "Next match (↵)", nextMatch: "Next match",
closeHint: "Close (Esc)", closeHint: "Close (Esc)",
disconnected: "Disconnected from {name} — saving will fail, but your text is preserved in this window.", disconnected: "Disconnected from {name} — saving will fail, but your text is preserved in this window.",
cantOpen: "Can't open {name}", cantOpen: "Can't open {name}",
closeWindow: "Close Window", closeWindow: "Close Window",
shortcuts: "{mod}S save · {mod}F find · {mod}W close", // 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" }, lines: { one: "{count} line", other: "{count} lines" },
close: "Close", close: "Close",
save: "Save", save: "Save",
@@ -223,6 +215,7 @@ export const en = {
}, },
toasts: { toasts: {
movedToTrash: 'Moved "{name}" to Trash', movedToTrash: 'Moved "{name}" to Trash',
movedManyToTrash: { one: "Moved {count} item to Trash", other: "Moved {count} items to Trash" },
editingExternal: "Editing {name} — saves upload automatically", editingExternal: "Editing {name} — saves upload automatically",
nothingToCopy: "Nothing to copy (symlinks are skipped)", nothingToCopy: "Nothing to copy (symlinks are skipped)",
nothingToCopyAllSkipped: "Nothing to copy — all conflicting items were skipped", nothingToCopyAllSkipped: "Nothing to copy — all conflicting items were skipped",
@@ -259,6 +252,7 @@ export const en = {
}, },
menu: { menu: {
about: "About {app}", about: "About {app}",
settings: "Settings…",
checkForUpdates: "Check for Updates…", checkForUpdates: "Check for Updates…",
quit: "Quit", quit: "Quit",
file: "File", file: "File",
@@ -305,6 +299,133 @@ export const en = {
tabDisconnect: "Disconnect", tabDisconnect: "Disconnect",
trashTooltip: "Finder's Restore is not available for items trashed from here", trashTooltip: "Finder's Restore is not available for items trashed from here",
}, },
// 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: { errors: {
sshAgentUnavailable: "ssh-agent not available (SSH_AUTH_SOCK is not set)", sshAgentUnavailable: "ssh-agent not available (SSH_AUTH_SOCK is not set)",
identityFileNotFound: "identity file not found: {path}", identityFileNotFound: "identity file not found: {path}",
+151 -35
View File
@@ -21,38 +21,29 @@ export const es = {
tabBar: { tabBar: {
connections: "Conexiones", connections: "Conexiones",
manageConnections: "Gestionar conexiones", manageConnections: "Gestionar conexiones",
newTab: "Nueva pestaña ({mod}T)", newTab: "Nueva pestaña",
closeTab: "Cerrar pestaña ({mod}W)", closeTab: "Cerrar pestaña",
options: "Opciones", options: "Ajustes",
update: "Actualización disponible", update: "Actualización disponible",
updating: "Actualizando", updating: "Actualizando",
restartToUpdate: "Reiniciar para aplicar", restartToUpdate: "Reiniciar para aplicar",
updateTooltip: "Actualización disponible: miniSCP {version}", updateTooltip: "Actualización disponible: miniSCP {version}",
}, },
options: { options: {
title: "Opciones", title: "Ajustes",
sections: {
appearance: "Apariencia",
transfers: "Transferencias",
editor: "Editor",
fileList: "Lista de archivos",
keyboard: "Teclado",
about: "Acerca de",
},
columns: "Columnas visibles",
language: "Idioma", language: "Idioma",
system: "Sistema", system: "Sistema",
skin: {
label: "Aspecto",
modern: "Moderno",
win7: "Windows 7",
restartHint: "Se aplica la próxima vez que abras miniSCP",
},
theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" }, theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" }, tone: { label: "Tono", dark: "Oscuro", darker: "AMOLED" },
accent: { label: "Acento" },
accentNames: {
graphite: "Grafito",
blue: "Azul",
indigo: "Índigo",
purple: "Morado",
rose: "Rosa",
orange: "Naranja",
green: "Verde",
teal: "Turquesa",
},
fileIcons: { label: "Iconos", plain: "Simple", multi: "Color" },
interface: { label: "Interfaz", smaller: "Más pequeña", larger: "Más grande", reset: "Restablecer al 100%" }, interface: { label: "Interfaz", smaller: "Más pequeña", larger: "Más grande", reset: "Restablecer al 100%" },
speedUnit: { label: "Unidad de velocidad" }, speedUnit: { label: "Unidad de velocidad" },
transfers: { label: "Transferencias", single: "Una", multi: "Varias" }, transfers: { label: "Transferencias", single: "Una", multi: "Varias" },
@@ -61,6 +52,7 @@ export const es = {
systemDefault: "Predeterminado del sistema", systemDefault: "Predeterminado del sistema",
resetDefault: "Restablecer al predeterminado", resetDefault: "Restablecer al predeterminado",
finishedTransfers: { label: "Transferencias completadas", autoClear: "Auto-limpiar", keep: "Mantener" }, 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: { kinds: {
folder: "Carpeta", folder: "Carpeta",
@@ -85,17 +77,16 @@ export const es = {
panel: { panel: {
choosePanel: "Elige qué muestra este panel", choosePanel: "Elige qué muestra este panel",
copy: "Enviar", copy: "Enviar",
copyToRight: "Enviar al panel derecho (F5)", copyToRight: "Enviar al panel derecho",
copyToLeft: "Enviar al panel izquierdo (F5)", copyToLeft: "Enviar al panel izquierdo",
edit: "Editar", edit: "Editar",
editFile: "Editar archivo (F4)", editFile: "Editar archivo",
rename: "Renombrar (F2)", rename: "Renombrar",
newFolder: "Nueva carpeta (F7)", newFolder: "Nueva carpeta",
newFile: "Nuevo archivo", newFile: "Nuevo archivo",
permissions: "Permisos", permissions: "Permisos",
delete: "Eliminar", delete: "Eliminar",
deleteHint: "Eliminar (F8 / Supr)", parentDir: "Carpeta superior",
parentDir: "Carpeta superior (Retroceso)",
refresh: "Actualizar", refresh: "Actualizar",
clearFilter: "Borrar filtro (Esc)", clearFilter: "Borrar filtro (Esc)",
sshConfig: "~/.ssh/config", sshConfig: "~/.ssh/config",
@@ -154,16 +145,16 @@ export const es = {
noFileTitle: "Ningún archivo que editar", noFileTitle: "Ningún archivo que editar",
noFileBody: "Esta ventana se abrió sin contexto de editor. Abre archivos desde la aplicación.", noFileBody: "Esta ventana se abrió sin contexto de editor. Abre archivos desde la aplicación.",
wrap: "Ajustar", wrap: "Ajustar",
wrapHint: "Ajustar líneas largas — oculta los números de línea ({alt}Z)", wrapHint: "Ajustar líneas largas — oculta los números de línea",
findHint: "Buscar ({mod}F)", findHint: "Buscar",
findPlaceholder: "Buscar en el archivo", findPlaceholder: "Buscar en el archivo",
prevMatch: "Coincidencia anterior (⇧↵)", prevMatch: "Coincidencia anterior",
nextMatch: "Coincidencia siguiente (↵)", nextMatch: "Coincidencia siguiente",
closeHint: "Cerrar (Esc)", closeHint: "Cerrar (Esc)",
disconnected: "Desconectado de {name} — no se podrá guardar, pero tu texto se conserva en esta ventana.", disconnected: "Desconectado de {name} — no se podrá guardar, pero tu texto se conserva en esta ventana.",
cantOpen: "No se puede abrir {name}", cantOpen: "No se puede abrir {name}",
closeWindow: "Cerrar ventana", closeWindow: "Cerrar ventana",
shortcuts: "{mod}S guardar · {mod}F buscar · {mod}W cerrar", verbs: { save: "guardar", find: "buscar", close: "cerrar" },
lines: { one: "{count} línea", other: "{count} líneas" }, lines: { one: "{count} línea", other: "{count} líneas" },
close: "Cerrar", close: "Cerrar",
save: "Guardar", save: "Guardar",
@@ -219,6 +210,7 @@ export const es = {
}, },
toasts: { toasts: {
movedToTrash: 'Se movió "{name}" a la papelera', 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", editingExternal: "Editando {name} — los cambios se suben automáticamente",
nothingToCopy: "Nada que copiar (los enlaces simbólicos se omiten)", nothingToCopy: "Nada que copiar (los enlaces simbólicos se omiten)",
nothingToCopyAllSkipped: "Nada que copiar — se omitieron todos los elementos en conflicto", nothingToCopyAllSkipped: "Nada que copiar — se omitieron todos los elementos en conflicto",
@@ -255,6 +247,7 @@ export const es = {
}, },
menu: { menu: {
about: "Acerca de {app}", about: "Acerca de {app}",
settings: "Ajustes…",
checkForUpdates: "Buscar actualizaciones…", checkForUpdates: "Buscar actualizaciones…",
quit: "Salir", quit: "Salir",
file: "Archivo", file: "Archivo",
@@ -301,6 +294,129 @@ export const es = {
tabDisconnect: "Desconectar", tabDisconnect: "Desconectar",
trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí", trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí",
}, },
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: { errors: {
sshAgentUnavailable: "ssh-agent no disponible (SSH_AUTH_SOCK no está definido)", sshAgentUnavailable: "ssh-agent no disponible (SSH_AUTH_SOCK no está definido)",
identityFileNotFound: "archivo de identidad no encontrado: {path}", identityFileNotFound: "archivo de identidad no encontrado: {path}",
+151 -35
View File
@@ -23,38 +23,29 @@ export const eu = {
tabBar: { tabBar: {
connections: "Konexioak", connections: "Konexioak",
manageConnections: "Kudeatu konexioak", manageConnections: "Kudeatu konexioak",
newTab: "Fitxa berria ({mod}T)", newTab: "Fitxa berria",
closeTab: "Itxi fitxa ({mod}W)", closeTab: "Itxi fitxa",
options: "Aukerak", options: "Ezarpenak",
update: "Eguneratzea eskuragarri", update: "Eguneratzea eskuragarri",
updating: "Eguneratzen", updating: "Eguneratzen",
restartToUpdate: "Berrabiarazi aplikatzeko", restartToUpdate: "Berrabiarazi aplikatzeko",
updateTooltip: "Eguneratzea eskuragarri: miniSCP {version}", updateTooltip: "Eguneratzea eskuragarri: miniSCP {version}",
}, },
options: { options: {
title: "Aukerak", title: "Ezarpenak",
sections: {
appearance: "Itxura",
transfers: "Transferentziak",
editor: "Editorea",
fileList: "Fitxategi-zerrenda",
keyboard: "Teklatua",
about: "Honi buruz",
},
columns: "Zutabe ikusgaiak",
language: "Hizkuntza", language: "Hizkuntza",
system: "Sistema", system: "Sistema",
skin: {
label: "Itxura",
modern: "Modernoa",
win7: "Windows 7",
restartHint: "Hurrengoan miniSCP abiaraztean aplikatuko da",
},
theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" }, theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" },
tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" }, tone: { label: "Tonua", dark: "Iluna", darker: "AMOLED" },
accent: { label: "Azentua" },
accentNames: {
graphite: "Grafitoa",
blue: "Urdina",
indigo: "Indigoa",
purple: "Morea",
rose: "Arrosa",
orange: "Laranja",
green: "Berdea",
teal: "Turkesa",
},
fileIcons: { label: "Fitxategi-ikonoak", plain: "Soila", multi: "Koloretsua" },
interface: { label: "Interfazea", smaller: "Txikiagoa", larger: "Handiagoa", reset: "Berrezarri %100era" }, interface: { label: "Interfazea", smaller: "Txikiagoa", larger: "Handiagoa", reset: "Berrezarri %100era" },
speedUnit: { label: "Abiadura-unitatea" }, speedUnit: { label: "Abiadura-unitatea" },
transfers: { label: "Transferentziak", single: "Banaka", multi: "Anitz" }, transfers: { label: "Transferentziak", single: "Banaka", multi: "Anitz" },
@@ -63,6 +54,7 @@ export const eu = {
systemDefault: "Sistemaren lehenetsia", systemDefault: "Sistemaren lehenetsia",
resetDefault: "Berrezarri lehenetsira", resetDefault: "Berrezarri lehenetsira",
finishedTransfers: { label: "Amaitutako transferentziak", autoClear: "Garbitu", keep: "Mantendu" }, 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: { kinds: {
folder: "Karpeta", folder: "Karpeta",
@@ -87,17 +79,16 @@ export const eu = {
panel: { panel: {
choosePanel: "Aukeratu panel honek zer erakusten duen", choosePanel: "Aukeratu panel honek zer erakusten duen",
copy: "Bidali", copy: "Bidali",
copyToRight: "Bidali eskuineko panelera (F5)", copyToRight: "Bidali eskuineko panelera",
copyToLeft: "Bidali ezkerreko panelera (F5)", copyToLeft: "Bidali ezkerreko panelera",
edit: "Editatu", edit: "Editatu",
editFile: "Editatu fitxategia (F4)", editFile: "Editatu fitxategia",
rename: "Berrizendatu (F2)", rename: "Berrizendatu",
newFolder: "Karpeta berria (F7)", newFolder: "Karpeta berria",
newFile: "Fitxategi berria", newFile: "Fitxategi berria",
permissions: "Baimenak", permissions: "Baimenak",
delete: "Ezabatu", delete: "Ezabatu",
deleteHint: "Ezabatu (F8 / Del)", parentDir: "Karpeta gurasoa",
parentDir: "Karpeta gurasoa (Atzera-tekla)",
refresh: "Freskatu", refresh: "Freskatu",
clearFilter: "Garbitu iragazkia (Esc)", clearFilter: "Garbitu iragazkia (Esc)",
sshConfig: "~/.ssh/config", sshConfig: "~/.ssh/config",
@@ -156,16 +147,16 @@ export const eu = {
noFileTitle: "Ez dago editatzeko fitxategirik", noFileTitle: "Ez dago editatzeko fitxategirik",
noFileBody: "Leiho hau editore-testuingururik gabe ireki da. Ireki fitxategiak aplikaziotik.", noFileBody: "Leiho hau editore-testuingururik gabe ireki da. Ireki fitxategiak aplikaziotik.",
wrap: "Doitu", wrap: "Doitu",
wrapHint: "Doitu lerro luzeak — lerro-zenbakiak ezkutatzen ditu ({alt}Z)", wrapHint: "Doitu lerro luzeak — lerro-zenbakiak ezkutatzen ditu",
findHint: "Bilatu ({mod}F)", findHint: "Bilatu",
findPlaceholder: "Bilatu fitxategian", findPlaceholder: "Bilatu fitxategian",
prevMatch: "Aurreko bat-etortzea (⇧↵)", prevMatch: "Aurreko bat-etortzea",
nextMatch: "Hurrengo bat-etortzea (↵)", nextMatch: "Hurrengo bat-etortzea",
closeHint: "Itxi (Esc)", closeHint: "Itxi (Esc)",
disconnected: "{name}(e)tik deskonektatuta — gordetzeak huts egingo du, baina zure testua leiho honetan gordeta dago.", disconnected: "{name}(e)tik deskonektatuta — gordetzeak huts egingo du, baina zure testua leiho honetan gordeta dago.",
cantOpen: "Ezin da {name} ireki", cantOpen: "Ezin da {name} ireki",
closeWindow: "Itxi leihoa", closeWindow: "Itxi leihoa",
shortcuts: "{mod}S gorde · {mod}F bilatu · {mod}W itxi", verbs: { save: "gorde", find: "bilatu", close: "itxi" },
lines: { one: "lerro {count}", other: "{count} lerro" }, lines: { one: "lerro {count}", other: "{count} lerro" },
close: "Itxi", close: "Itxi",
save: "Gorde", save: "Gorde",
@@ -221,6 +212,7 @@ export const eu = {
}, },
toasts: { toasts: {
movedToTrash: '"{name}" zakarrontzira eraman da', 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", editingExternal: "{name} editatzen — gordetzean automatikoki igotzen da",
nothingToCopy: "Ez dago kopiatzeko ezer (esteka sinbolikoak saltatu egiten dira)", nothingToCopy: "Ez dago kopiatzeko ezer (esteka sinbolikoak saltatu egiten dira)",
nothingToCopyAllSkipped: "Ez dago kopiatzeko ezer — gatazkan zeuden elementu guztiak saltatu dira", nothingToCopyAllSkipped: "Ez dago kopiatzeko ezer — gatazkan zeuden elementu guztiak saltatu dira",
@@ -257,6 +249,7 @@ export const eu = {
}, },
menu: { menu: {
about: "{app} (r)i buruz", about: "{app} (r)i buruz",
settings: "Ezarpenak…",
checkForUpdates: "Bilatu eguneratzeak…", checkForUpdates: "Bilatu eguneratzeak…",
quit: "Irten", quit: "Irten",
file: "Fitxategia", file: "Fitxategia",
@@ -303,6 +296,129 @@ export const eu = {
tabDisconnect: "Deskonektatu", tabDisconnect: "Deskonektatu",
trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat", trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat",
}, },
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: { errors: {
sshAgentUnavailable: "ssh-agent ez dago erabilgarri (SSH_AUTH_SOCK ez dago ezarrita)", sshAgentUnavailable: "ssh-agent ez dago erabilgarri (SSH_AUTH_SOCK ez dago ezarrita)",
identityFileNotFound: "identitate-fitxategia ez da aurkitu: {path}", identityFileNotFound: "identitate-fitxategia ez da aurkitu: {path}",
+151 -35
View File
@@ -21,38 +21,29 @@ export const fr = {
tabBar: { tabBar: {
connections: "Connexions", connections: "Connexions",
manageConnections: "Gérer les connexions", manageConnections: "Gérer les connexions",
newTab: "Nouvel onglet ({mod}T)", newTab: "Nouvel onglet",
closeTab: "Fermer l'onglet ({mod}W)", closeTab: "Fermer l'onglet",
options: "Options", options: "Réglages",
update: "Mise à jour disponible", update: "Mise à jour disponible",
updating: "Mise à jour", updating: "Mise à jour",
restartToUpdate: "Redémarrer pour appliquer", restartToUpdate: "Redémarrer pour appliquer",
updateTooltip: "Mise à jour disponible : miniSCP {version}", updateTooltip: "Mise à jour disponible : miniSCP {version}",
}, },
options: { options: {
title: "Options", title: "Réglages",
sections: {
appearance: "Apparence",
transfers: "Transferts",
editor: "Éditeur",
fileList: "Liste des fichiers",
keyboard: "Clavier",
about: "À propos",
},
columns: "Colonnes visibles",
language: "Langue", language: "Langue",
system: "Système", system: "Système",
skin: {
label: "Apparence",
modern: "Moderne",
win7: "Windows 7",
restartHint: "Prend effet au prochain démarrage de miniSCP",
},
theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" }, theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" }, tone: { label: "Ton", dark: "Sombre", darker: "AMOLED" },
accent: { label: "Accent" },
accentNames: {
graphite: "Graphite",
blue: "Bleu",
indigo: "Indigo",
purple: "Violet",
rose: "Rose",
orange: "Orange",
green: "Vert",
teal: "Sarcelle",
},
fileIcons: { label: "Icônes", plain: "Simple", multi: "Couleur" },
interface: { label: "Interface", smaller: "Plus petite", larger: "Plus grande", reset: "Réinitialiser à 100%" }, interface: { label: "Interface", smaller: "Plus petite", larger: "Plus grande", reset: "Réinitialiser à 100%" },
speedUnit: { label: "Unité de vitesse" }, speedUnit: { label: "Unité de vitesse" },
transfers: { label: "Transferts", single: "Un", multi: "Plusieurs" }, transfers: { label: "Transferts", single: "Un", multi: "Plusieurs" },
@@ -61,6 +52,7 @@ export const fr = {
systemDefault: "Valeur par défaut du système", systemDefault: "Valeur par défaut du système",
resetDefault: "Réinitialiser par défaut", resetDefault: "Réinitialiser par défaut",
finishedTransfers: { label: "Transferts terminés", autoClear: "Effacer auto.", keep: "Garder" }, 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: { kinds: {
folder: "Dossier", folder: "Dossier",
@@ -85,17 +77,16 @@ export const fr = {
panel: { panel: {
choosePanel: "Choisir ce qu'affiche ce panneau", choosePanel: "Choisir ce qu'affiche ce panneau",
copy: "Envoyer", copy: "Envoyer",
copyToRight: "Envoyer vers le panneau droit (F5)", copyToRight: "Envoyer vers le panneau droit",
copyToLeft: "Envoyer vers le panneau gauche (F5)", copyToLeft: "Envoyer vers le panneau gauche",
edit: "Éditer", edit: "Éditer",
editFile: "Éditer le fichier (F4)", editFile: "Éditer le fichier",
rename: "Renommer (F2)", rename: "Renommer",
newFolder: "Nouveau dossier (F7)", newFolder: "Nouveau dossier",
newFile: "Nouveau fichier", newFile: "Nouveau fichier",
permissions: "Autorisations", permissions: "Autorisations",
delete: "Supprimer", delete: "Supprimer",
deleteHint: "Supprimer (F8 / Suppr)", parentDir: "Dossier parent",
parentDir: "Dossier parent (Retour arrière)",
refresh: "Actualiser", refresh: "Actualiser",
clearFilter: "Effacer le filtre (Échap)", clearFilter: "Effacer le filtre (Échap)",
sshConfig: "~/.ssh/config", sshConfig: "~/.ssh/config",
@@ -154,16 +145,16 @@ export const fr = {
noFileTitle: "Aucun fichier à éditer", noFileTitle: "Aucun fichier à éditer",
noFileBody: "Cette fenêtre a été ouverte sans contexte d'éditeur. Ouvrez des fichiers depuis l'application.", noFileBody: "Cette fenêtre a été ouverte sans contexte d'éditeur. Ouvrez des fichiers depuis l'application.",
wrap: "Retour", wrap: "Retour",
wrapHint: "Retour à la ligne — masque les numéros de ligne ({alt}Z)", wrapHint: "Retour à la ligne — masque les numéros de ligne",
findHint: "Rechercher ({mod}F)", findHint: "Rechercher",
findPlaceholder: "Rechercher dans le fichier", findPlaceholder: "Rechercher dans le fichier",
prevMatch: "Résultat précédent (⇧↵)", prevMatch: "Résultat précédent",
nextMatch: "Résultat suivant (↵)", nextMatch: "Résultat suivant",
closeHint: "Fermer (Échap)", closeHint: "Fermer (Échap)",
disconnected: "Déconnecté de {name} — l'enregistrement échouera, mais votre texte est conservé dans cette fenêtre.", disconnected: "Déconnecté de {name} — l'enregistrement échouera, mais votre texte est conservé dans cette fenêtre.",
cantOpen: "Impossible d'ouvrir {name}", cantOpen: "Impossible d'ouvrir {name}",
closeWindow: "Fermer la fenêtre", closeWindow: "Fermer la fenêtre",
shortcuts: "{mod}S enregistrer · {mod}F rechercher · {mod}W fermer", verbs: { save: "enregistrer", find: "rechercher", close: "fermer" },
lines: { one: "{count} ligne", other: "{count} lignes" }, lines: { one: "{count} ligne", other: "{count} lignes" },
close: "Fermer", close: "Fermer",
save: "Enregistrer", save: "Enregistrer",
@@ -219,6 +210,7 @@ export const fr = {
}, },
toasts: { toasts: {
movedToTrash: '« {name} » mis à la corbeille', 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", editingExternal: "Édition de {name} — les enregistrements sont envoyés automatiquement",
nothingToCopy: "Rien à copier (les liens symboliques sont ignorés)", nothingToCopy: "Rien à copier (les liens symboliques sont ignorés)",
nothingToCopyAllSkipped: "Rien à copier — tous les éléments en conflit ont été ignorés", nothingToCopyAllSkipped: "Rien à copier — tous les éléments en conflit ont été ignorés",
@@ -255,6 +247,7 @@ export const fr = {
}, },
menu: { menu: {
about: "À propos de {app}", about: "À propos de {app}",
settings: "Réglages…",
checkForUpdates: "Rechercher des mises à jour…", checkForUpdates: "Rechercher des mises à jour…",
quit: "Quitter", quit: "Quitter",
file: "Fichier", file: "Fichier",
@@ -301,6 +294,129 @@ export const fr = {
tabDisconnect: "Déconnecter", tabDisconnect: "Déconnecter",
trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici", trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici",
}, },
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: { errors: {
sshAgentUnavailable: "ssh-agent indisponible (SSH_AUTH_SOCK non défini)", sshAgentUnavailable: "ssh-agent indisponible (SSH_AUTH_SOCK non défini)",
identityFileNotFound: "fichier d'identité introuvable : {path}", identityFileNotFound: "fichier d'identité introuvable : {path}",
+151 -35
View File
@@ -22,38 +22,29 @@ export const ja = {
tabBar: { tabBar: {
connections: "接続", connections: "接続",
manageConnections: "接続を管理", manageConnections: "接続を管理",
newTab: "新しいタブ ({mod}T)", newTab: "新しいタブ",
closeTab: "タブを閉じる ({mod}W)", closeTab: "タブを閉じる",
options: "オプション", options: "設定",
update: "アップデートあり", update: "アップデートあり",
updating: "更新中", updating: "更新中",
restartToUpdate: "再起動して適用", restartToUpdate: "再起動して適用",
updateTooltip: "アップデートがあります: miniSCP {version}", updateTooltip: "アップデートがあります: miniSCP {version}",
}, },
options: { options: {
title: "オプション", title: "設定",
sections: {
appearance: "外観",
transfers: "転送",
editor: "エディタ",
fileList: "ファイル一覧",
keyboard: "キーボード",
about: "情報",
},
columns: "表示する列",
language: "言語", language: "言語",
system: "システム", system: "システム",
skin: {
label: "外観",
modern: "モダン",
win7: "Windows 7",
restartHint: "次回 miniSCP を起動したときに適用されます",
},
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" }, theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
tone: { label: "トーン", dark: "ダーク", darker: "より暗く" }, tone: { label: "トーン", dark: "ダーク", darker: "AMOLED" },
accent: { label: "アクセント" },
accentNames: {
graphite: "グラファイト",
blue: "ブルー",
indigo: "インディゴ",
purple: "パープル",
rose: "ローズ",
orange: "オレンジ",
green: "グリーン",
teal: "ティール",
},
fileIcons: { label: "ファイルアイコン", plain: "シンプル", multi: "カラー" },
interface: { label: "インターフェース", smaller: "縮小", larger: "拡大", reset: "100%に戻す" }, interface: { label: "インターフェース", smaller: "縮小", larger: "拡大", reset: "100%に戻す" },
speedUnit: { label: "速度の単位" }, speedUnit: { label: "速度の単位" },
transfers: { label: "転送", single: "1件ずつ", multi: "複数" }, transfers: { label: "転送", single: "1件ずつ", multi: "複数" },
@@ -62,6 +53,7 @@ export const ja = {
systemDefault: "システムの既定", systemDefault: "システムの既定",
resetDefault: "既定に戻す", resetDefault: "既定に戻す",
finishedTransfers: { label: "完了した転送", autoClear: "自動クリア", keep: "保持" }, finishedTransfers: { label: "完了した転送", autoClear: "自動クリア", keep: "保持" },
openFiles: { label: "Enter / ファイルをダブルクリック", nothing: "何もしない", open: "開く", hint: "ローカルファイルは既定のアプリで開きます。フォルダは常に開きます。" },
}, },
kinds: { kinds: {
folder: "フォルダ", folder: "フォルダ",
@@ -86,17 +78,16 @@ export const ja = {
panel: { panel: {
choosePanel: "このパネルに表示する内容を選択", choosePanel: "このパネルに表示する内容を選択",
copy: "送信", copy: "送信",
copyToRight: "右のパネルへ送信 (F5)", copyToRight: "右のパネルへ送信",
copyToLeft: "左のパネルへ送信 (F5)", copyToLeft: "左のパネルへ送信",
edit: "編集", edit: "編集",
editFile: "ファイルを編集 (F4)", editFile: "ファイルを編集",
rename: "名前を変更 (F2)", rename: "名前を変更",
newFolder: "新規フォルダ (F7)", newFolder: "新規フォルダ",
newFile: "新規ファイル", newFile: "新規ファイル",
permissions: "アクセス権", permissions: "アクセス権",
delete: "削除", delete: "削除",
deleteHint: "削除 (F8 / Del)", parentDir: "親フォルダ",
parentDir: "親フォルダ (Backspace)",
refresh: "更新", refresh: "更新",
clearFilter: "フィルターをクリア (Esc)", clearFilter: "フィルターをクリア (Esc)",
sshConfig: "~/.ssh/config", sshConfig: "~/.ssh/config",
@@ -155,16 +146,16 @@ export const ja = {
noFileTitle: "編集するファイルがありません", noFileTitle: "編集するファイルがありません",
noFileBody: "このウィンドウはエディタコンテキストなしで開かれました。アプリからファイルを開いてください。", noFileBody: "このウィンドウはエディタコンテキストなしで開かれました。アプリからファイルを開いてください。",
wrap: "折り返し", wrap: "折り返し",
wrapHint: "長い行を折り返す — 行番号を非表示 ({alt}Z)", wrapHint: "長い行を折り返す — 行番号を非表示",
findHint: "検索 ({mod}F)", findHint: "検索",
findPlaceholder: "ファイル内を検索", findPlaceholder: "ファイル内を検索",
prevMatch: "前の一致 (⇧↵)", prevMatch: "前の一致",
nextMatch: "次の一致 (↵)", nextMatch: "次の一致",
closeHint: "閉じる (Esc)", closeHint: "閉じる (Esc)",
disconnected: "{name} から切断されました — 保存は失敗しますが、テキストはこのウィンドウに保持されます。", disconnected: "{name} から切断されました — 保存は失敗しますが、テキストはこのウィンドウに保持されます。",
cantOpen: "{name} を開けません", cantOpen: "{name} を開けません",
closeWindow: "ウィンドウを閉じる", closeWindow: "ウィンドウを閉じる",
shortcuts: "{mod}S 保存 · {mod}F 検索 · {mod}W 閉じる", verbs: { save: "保存", find: "検索", close: "閉じる" },
lines: { other: "{count} 行" }, lines: { other: "{count} 行" },
close: "閉じる", close: "閉じる",
save: "保存", save: "保存",
@@ -220,6 +211,7 @@ export const ja = {
}, },
toasts: { toasts: {
movedToTrash: '"{name}" をゴミ箱に移動しました', movedToTrash: '"{name}" をゴミ箱に移動しました',
movedManyToTrash: { other: "{count} 個の項目をゴミ箱に移動しました" },
editingExternal: "{name} を編集中 — 保存すると自動でアップロードされます", editingExternal: "{name} を編集中 — 保存すると自動でアップロードされます",
nothingToCopy: "コピーする項目がありません(シンボリックリンクはスキップされます)", nothingToCopy: "コピーする項目がありません(シンボリックリンクはスキップされます)",
nothingToCopyAllSkipped: "コピーする項目がありません — 競合する項目はすべてスキップされました", nothingToCopyAllSkipped: "コピーする項目がありません — 競合する項目はすべてスキップされました",
@@ -255,6 +247,7 @@ export const ja = {
}, },
menu: { menu: {
about: "{app} について", about: "{app} について",
settings: "設定…",
checkForUpdates: "アップデートを確認…", checkForUpdates: "アップデートを確認…",
quit: "終了", quit: "終了",
file: "ファイル", file: "ファイル",
@@ -301,6 +294,129 @@ export const ja = {
tabDisconnect: "切断", tabDisconnect: "切断",
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません", 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: { errors: {
sshAgentUnavailable: "ssh-agent が利用できません(SSH_AUTH_SOCK が未設定)", sshAgentUnavailable: "ssh-agent が利用できません(SSH_AUTH_SOCK が未設定)",
identityFileNotFound: "認証鍵ファイルが見つかりません: {path}", identityFileNotFound: "認証鍵ファイルが見つかりません: {path}",
+151 -35
View File
@@ -22,38 +22,29 @@ export const zh = {
tabBar: { tabBar: {
connections: "连接", connections: "连接",
manageConnections: "管理连接", manageConnections: "管理连接",
newTab: "新建标签页 ({mod}T)", newTab: "新建标签页",
closeTab: "关闭标签页 ({mod}W)", closeTab: "关闭标签页",
options: "选项", options: "设置",
update: "有可用更新", update: "有可用更新",
updating: "正在更新", updating: "正在更新",
restartToUpdate: "重启以应用", restartToUpdate: "重启以应用",
updateTooltip: "有可用更新:miniSCP {version}", updateTooltip: "有可用更新:miniSCP {version}",
}, },
options: { options: {
title: "选项", title: "设置",
sections: {
appearance: "外观",
transfers: "传输",
editor: "编辑器",
fileList: "文件列表",
keyboard: "键盘",
about: "关于",
},
columns: "显示的列",
language: "语言", language: "语言",
system: "系统", system: "系统",
skin: {
label: "外观",
modern: "现代",
win7: "Windows 7",
restartHint: "下次启动 miniSCP 时生效",
},
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" }, theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
tone: { label: "色调", dark: "深色", darker: "更深" }, tone: { label: "色调", dark: "深色", darker: "AMOLED" },
accent: { label: "强调色" },
accentNames: {
graphite: "石墨",
blue: "蓝色",
indigo: "靛蓝",
purple: "紫色",
rose: "玫红",
orange: "橙色",
green: "绿色",
teal: "青色",
},
fileIcons: { label: "文件图标", plain: "简约", multi: "彩色" },
interface: { label: "界面", smaller: "缩小", larger: "放大", reset: "重置为100%" }, interface: { label: "界面", smaller: "缩小", larger: "放大", reset: "重置为100%" },
speedUnit: { label: "速度单位" }, speedUnit: { label: "速度单位" },
transfers: { label: "传输", single: "单个", multi: "多个" }, transfers: { label: "传输", single: "单个", multi: "多个" },
@@ -62,6 +53,7 @@ export const zh = {
systemDefault: "系统默认", systemDefault: "系统默认",
resetDefault: "恢复为系统默认", resetDefault: "恢复为系统默认",
finishedTransfers: { label: "已完成传输", autoClear: "自动清除", keep: "保留" }, finishedTransfers: { label: "已完成传输", autoClear: "自动清除", keep: "保留" },
openFiles: { label: "回车 / 双击文件", nothing: "不执行操作", open: "打开", hint: "本地文件用默认应用打开;文件夹始终打开。" },
}, },
kinds: { kinds: {
folder: "文件夹", folder: "文件夹",
@@ -86,17 +78,16 @@ export const zh = {
panel: { panel: {
choosePanel: "选择此面板显示的内容", choosePanel: "选择此面板显示的内容",
copy: "发送", copy: "发送",
copyToRight: "发送到右侧面板 (F5)", copyToRight: "发送到右侧面板",
copyToLeft: "发送到左侧面板 (F5)", copyToLeft: "发送到左侧面板",
edit: "编辑", edit: "编辑",
editFile: "编辑文件 (F4)", editFile: "编辑文件",
rename: "重命名 (F2)", rename: "重命名",
newFolder: "新建文件夹 (F7)", newFolder: "新建文件夹",
newFile: "新建文件", newFile: "新建文件",
permissions: "权限", permissions: "权限",
delete: "删除", delete: "删除",
deleteHint: "删除 (F8 / Del)", parentDir: "上级目录",
parentDir: "上级目录 (退格)",
refresh: "刷新", refresh: "刷新",
clearFilter: "清除筛选 (Esc)", clearFilter: "清除筛选 (Esc)",
sshConfig: "~/.ssh/config", sshConfig: "~/.ssh/config",
@@ -155,16 +146,16 @@ export const zh = {
noFileTitle: "没有要编辑的文件", noFileTitle: "没有要编辑的文件",
noFileBody: "此窗口在没有编辑器上下文的情况下打开。请从应用中打开文件。", noFileBody: "此窗口在没有编辑器上下文的情况下打开。请从应用中打开文件。",
wrap: "换行", wrap: "换行",
wrapHint: "长行自动换行 — 隐藏行号 ({alt}Z)", wrapHint: "长行自动换行 — 隐藏行号",
findHint: "查找 ({mod}F)", findHint: "查找",
findPlaceholder: "在文件中查找", findPlaceholder: "在文件中查找",
prevMatch: "上一个匹配 (⇧↵)", prevMatch: "上一个匹配",
nextMatch: "下一个匹配 (↵)", nextMatch: "下一个匹配",
closeHint: "关闭 (Esc)", closeHint: "关闭 (Esc)",
disconnected: "已从 {name} 断开 — 保存将失败,但你的文本会保留在此窗口中。", disconnected: "已从 {name} 断开 — 保存将失败,但你的文本会保留在此窗口中。",
cantOpen: "无法打开 {name}", cantOpen: "无法打开 {name}",
closeWindow: "关闭窗口", closeWindow: "关闭窗口",
shortcuts: "{mod}S 保存 · {mod}F 查找 · {mod}W 关闭", verbs: { save: "保存", find: "查找", close: "关闭" },
lines: { other: "{count} 行" }, lines: { other: "{count} 行" },
close: "关闭", close: "关闭",
save: "保存", save: "保存",
@@ -220,6 +211,7 @@ export const zh = {
}, },
toasts: { toasts: {
movedToTrash: '已将 "{name}" 移到废纸篓', movedToTrash: '已将 "{name}" 移到废纸篓',
movedManyToTrash: { other: "已将 {count} 个项目移到废纸篓" },
editingExternal: "正在编辑 {name} — 保存后会自动上传", editingExternal: "正在编辑 {name} — 保存后会自动上传",
nothingToCopy: "没有可复制的内容(符号链接将被跳过)", nothingToCopy: "没有可复制的内容(符号链接将被跳过)",
nothingToCopyAllSkipped: "没有可复制的内容 — 所有冲突项都被跳过", nothingToCopyAllSkipped: "没有可复制的内容 — 所有冲突项都被跳过",
@@ -255,6 +247,7 @@ export const zh = {
}, },
menu: { menu: {
about: "关于 {app}", about: "关于 {app}",
settings: "设置…",
checkForUpdates: "检查更新…", checkForUpdates: "检查更新…",
quit: "退出", quit: "退出",
file: "文件", file: "文件",
@@ -301,6 +294,129 @@ export const zh = {
tabDisconnect: "断开连接", tabDisconnect: "断开连接",
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能", 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: { errors: {
sshAgentUnavailable: "ssh-agent 不可用(未设置 SSH_AUTH_SOCK)", sshAgentUnavailable: "ssh-agent 不可用(未设置 SSH_AUTH_SOCK)",
identityFileNotFound: "找不到身份文件:{path}", identityFileNotFound: "找不到身份文件:{path}",
+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("+");
}
+9 -40
View File
@@ -26,9 +26,9 @@ export interface EditorOpenContext {
// bun.requests = calls the webview makes, handled in src/bun/rpc-handlers.ts // bun.requests = calls the webview makes, handled in src/bun/rpc-handlers.ts
// webview.messages = push events from bun, handled in ui rpc.service.ts // webview.messages = push events from bun, handled in ui rpc.service.ts
// //
// ONE schema serves both window kinds (main + editor): electrobun allows // ONE schema serves every window kind (main, editor, manager, settings):
// partial handler sets, and messages nobody listens to are silently dropped, // electrobun allows partial handler sets, and messages nobody listens to are
// so each side only implements/hears what applies to it. // silently dropped, so each side only implements/hears what applies to it.
export type AppRPC = { export type AppRPC = {
bun: { bun: {
requests: { requests: {
@@ -128,7 +128,7 @@ export type AppRPC = {
// local shell integration // local shell integration
/** /**
* Shell actions the context menu triggers on a LOCAL path. The menu * Shell actions the context menu triggers on a LOCAL path. The menu
* itself is HTML (ui/src/app/context-menu.ts) — electrobun's native * itself is HTML (ui/src/app/core/context-menu.service.ts) — electrobun's native
* context menu is a no-op on Linux — but these three still need the * context menu is a no-op on Linux — but these three still need the
* bun side's Utils. * bun side's Utils.
*/ */
@@ -154,35 +154,11 @@ export type AppRPC = {
managerConnect: { params: { id: string }; response: Result }; managerConnect: { params: { id: string }; response: Result };
managerOpenStack: { params: { id: string }; response: Result }; managerOpenStack: { params: { id: string }; response: Result };
managerClose: { params: Record<string, never>; response: Result }; managerClose: { params: Record<string, never>; response: Result };
// custom window chrome (win7 skin — the window is frameless, so the // settings window
// webview's title bar owns the controls). Served by every window kind. /** Main window: open (or focus) the single Settings window. */
windowMinimize: { params: Record<string, never>; response: Result }; openSettings: { params: Record<string, never>; response: Result };
/** Maximize/restore in one call; responds with the resulting state. */ /** Settings window only: its close-window key (Ctrl+W off macOS, ⌘W). */
windowToggleMaximize: { settingsClose: { params: Record<string, never>; response: Result };
params: Record<string, never>;
response: Result<{ maximized: boolean }>;
};
windowClose: { params: Record<string, never>; response: Result };
/** First paint: the title bar needs the state before any resize fires. */
windowIsMaximized: {
params: Record<string, never>;
response: Result<{ maximized: boolean }>;
};
/**
* STOPGAP — the two halves of the synthesized resize. A frameless GTK
* window has no resize borders and electrobun exposes no native resize
* gesture, so the webview drags its own handles and moves the frame
* itself. Delete both with the rest of the hack; see the removal
* checklist in ui/src/app/skins/7like/window-resize/window-resize.ts.
*/
windowGetFrame: {
params: Record<string, never>;
response: Result<{ x: number; y: number; width: number; height: number }>;
};
windowSetFrame: {
params: { x: number; y: number; width: number; height: number };
response: Result;
};
// auto-updater (electrobun Updater) // auto-updater (electrobun Updater)
/** Main window: re-run the update check on demand (menu item). */ /** Main window: re-run the update check on demand (menu item). */
checkForUpdate: { checkForUpdate: {
@@ -213,13 +189,6 @@ export type AppRPC = {
editorCloseRequest: Record<string, never>; editorCloseRequest: Record<string, never>;
/** bun → main: the modal connection manager opened/closed. */ /** bun → main: the modal connection manager opened/closed. */
managerState: { open: boolean }; managerState: { open: boolean };
/**
* bun → any window: maximize state changed, so the custom title bar can
* swap its maximize/restore glyph. electrobun has no maximize event —
* this is derived from the window's `resize` event (see windows.ts
* trackMaximizeState), which also covers OS-initiated Aero Snap.
*/
windowState: { maximized: boolean };
/** bun → main: the manager window changed the connection store. */ /** bun → main: the manager window changed the connection store. */
connectionsChanged: Record<string, never>; connectionsChanged: Record<string, never>;
/** /**
+11 -30
View File
@@ -65,45 +65,19 @@ export interface ConnectionsFile {
/** UI languages the app ships translations for. */ /** UI languages the app ships translations for. */
export type SupportedLocale = "en" | "es" | "eu" | "fr" | "de" | "zh" | "ja"; export type SupportedLocale = "en" | "es" | "eu" | "fr" | "de" | "zh" | "ja";
export type AccentTone =
| "graphite"
| "blue"
| "indigo"
| "purple"
| "rose"
| "orange"
| "green"
| "teal";
export interface AppSettings { export interface AppSettings {
/** UI language; "system" resolves to the OS locale (fallback English). */ /** UI language; "system" resolves to the OS locale (fallback English). */
locale: "system" | SupportedLocale; locale: "system" | SupportedLocale;
/**
* Visual skin. "modern" is the shadcn-style look; "win7" swaps in 7.css
* (Windows 7 Aero) and switches the window to custom frameless chrome.
* Each skin is a whole component tree under ui/src/app/skins/ — "modern" is
* shadlike/, "win7" is 7like/ — selected at bootstrap in ui/src/main.ts.
* The value is NOT named after its folder on purpose: it is persisted in
* user settings, and "win7" is also the literal class 7.scoped.css is
* precompiled to require, so neither can be renamed freely.
* Changing this needs a window reopen to take effect on the frame itself —
* the CSS half applies live.
*/
skin: "modern" | "win7";
theme: "system" | "light" | "dark"; theme: "system" | "light" | "dark";
/** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */ /** Dark appearance: grey ("dark") or near-black AMOLED ("darker"). */
darkTone: "dark" | "darker"; darkTone: "dark" | "darker";
/** Accent tone for chrome and selection tint; graphite = neutral zinc. */
accent: AccentTone;
/** File-list icons: plain neutral glyphs, or per-category glyphs and colors. */
iconColors: "plain" | "multi";
/** Visible optional file-list columns (both panels; Name always shows). */ /** Visible optional file-list columns (both panels; Name always shows). */
columns: ColumnKey[]; columns: ColumnKey[];
speedUnit: "MBps" | "Mbps"; speedUnit: "MBps" | "Mbps";
/** /**
* Interface scale factor, 0.5–4 in 0.1 steps (shown as 50%–400%). Works by * 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 * setting the root font-size — every size in the UI is rem, so one lever
* scales BOTH skins whole. Replaced the 3-tier `textSize` * scales the whole UI. Replaced the 3-tier `textSize`
* (compact|default|large); SettingsStore.load migrates old files. * (compact|default|large); SettingsStore.load migrates old files.
*/ */
uiScale: number; uiScale: number;
@@ -117,6 +91,14 @@ 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 // Auto-refresh the active tab's panels (local watched, remote polled). Always
// on for now; uncomment (+ the settings.service and tab-bar breadcrumbs, and // on for now; uncomment (+ the settings.service and tab-bar breadcrumbs, and
// gate the poll/watch in panels.service) to expose a toggle later. // gate the poll/watch in panels.service) to expose a toggle later.
@@ -125,11 +107,8 @@ export interface AppSettings {
export const DEFAULT_SETTINGS: AppSettings = { export const DEFAULT_SETTINGS: AppSettings = {
locale: "system", locale: "system",
skin: "modern",
theme: "system", theme: "system",
darkTone: "dark", darkTone: "dark",
accent: "blue",
iconColors: "multi",
columns: ["size", "mtime"], columns: ["size", "mtime"],
speedUnit: "MBps", speedUnit: "MBps",
uiScale: 1, uiScale: 1,
@@ -138,6 +117,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
autoClearCompleted: true, autoClearCompleted: true,
editor: "builtin", editor: "builtin",
editorApp: "", editorApp: "",
keybindings: {},
openFilesOnEnter: false,
// autoRefresh: true, // autoRefresh: true,
}; };
-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"]
} }
-292
View File
@@ -1,292 +0,0 @@
<!doctype html>
<!--
Skin harness — a static gallery rendered headlessly by scripts/shot.ts so the
Win7 skin can be LOOKED AT during development instead of inferred from a
clean build log. Not shipped: it lives outside ui/src, so the Angular build
never sees it.
Each section pairs 7.css's own documented markup ("reference") with what the
app currently renders ("ours"). Where those two disagree visually, 7.css is
not reaching our component — that mismatch is the whole point of the page.
Layout here uses INLINE STYLES on purpose: Tailwind v4 only emits utilities it
found while scanning ui/src, so utility classes used only here would not exist
in the built stylesheet. {{STYLES}} is replaced by scripts/shot.ts with the
real hashed build output.
-->
<html class="win7" lang="en">
<head>
<meta charset="utf-8" />
<title>7.css harness</title>
<link rel="stylesheet" href="{{STYLES}}" />
<style>
body { margin: 0; padding: 20px; }
h2 { font-size: 13px; margin: 22px 0 6px; opacity: 0.75; }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }
.cell { min-width: 0; }
.cell > figcaption {
font-size: 11px; opacity: 0.6; margin-bottom: 5px; text-transform: uppercase;
letter-spacing: 0.04em;
}
.stage { padding: 14px; background: #6ba5d7; border-radius: 4px; }
</style>
</head>
<body>
<h2>1 — Window frame, glass, drop shadow, active colour</h2>
<div class="pair">
<figure class="cell" style="margin:0">
<figcaption>reference — .window.glass.active</figcaption>
<div class="stage">
<div class="window glass active" style="width: 320px">
<div class="title-bar">
<div class="title-bar-text">miniSCP</div>
<div class="title-bar-controls">
<button aria-label="Minimize"></button>
<button aria-label="Maximize"></button>
<button aria-label="Close"></button>
</div>
</div>
<div class="window-body"><p>Glass frame, 6px radius, drop shadow.</p></div>
</div>
</div>
</figure>
<figure class="cell" style="margin:0">
<figcaption>ours — app.html shell (active above, inactive below)</figcaption>
<div class="stage">
<div class="window glass active" style="width: 320px">
<div class="title-bar">
<div class="title-bar-text">miniSCP</div>
<div class="title-bar-controls">
<button aria-label="Minimize"></button>
<button aria-label="Maximize"></button>
<button aria-label="Close"></button>
</div>
</div>
<div class="window-body"><p style="margin:0">Focused window.</p></div>
</div>
<div class="window glass" style="width: 320px; margin-top: 12px">
<div class="title-bar">
<div class="title-bar-text">miniSCP</div>
<div class="title-bar-controls">
<button aria-label="Minimize"></button>
<button aria-label="Maximize"></button>
<button aria-label="Close"></button>
</div>
</div>
<div class="window-body"><p style="margin:0">Unfocused — chrome desaturates.</p></div>
</div>
</div>
</figure>
</div>
<h2>2 — ListView (file panel)</h2>
<div class="pair">
<figure class="cell" style="margin:0">
<figcaption>reference — &lt;table&gt;</figcaption>
<table class="has-shadow" style="width:100%">
<thead>
<tr>
<th>Name</th>
<th class="highlighted indicator">Size</th>
<th>Modified</th>
</tr>
</thead>
<tbody>
<tr><td>..</td><td style="text-align:right">—</td><td>—</td></tr>
<tr><td>Documents</td><td style="text-align:right">—</td><td>3/29/2007 12:00 AM</td></tr>
<tr class="highlighted"><td>report.pdf</td><td style="text-align:right">27 KB</td><td>3/23/2007 12:00 AM</td></tr>
<tr><td>notes.txt</td><td style="text-align:right">4 KB</td><td>3/13/2007 12:00 AM</td></tr>
</tbody>
</table>
</figure>
<figure class="cell" style="margin:0">
<figcaption>ours — focused pane (top) vs unfocused pane (bottom)</figcaption>
<!-- Focused pane: strong band on selected rows AND the cursor row;
the cursor row additionally gets the dotted focus rect. -->
<table class="is-active">
<colgroup>
<col />
<col style="width: 5.5rem" />
<col style="width: 8.5rem" />
</colgroup>
<thead>
<tr>
<th>Name</th>
<th class="highlighted indicator up" style="text-align:right">Size</th>
<th>Modified</th>
</tr>
</thead>
<tbody>
<tr><td>..</td><td style="text-align:right">—</td><td>—</td></tr>
<tr class="highlighted"><td>Documents</td><td style="text-align:right">—</td><td>3/29/2007 12:00 AM</td></tr>
<tr class="highlighted"><td>report.pdf</td><td style="text-align:right">27 KB</td><td>3/23/2007 12:00 AM</td></tr>
<tr class="highlighted is-cursor"><td>notes.txt</td><td style="text-align:right">4 KB</td><td>3/13/2007 12:00 AM</td></tr>
</tbody>
</table>
<!-- Unfocused pane: same rows, faint inactive wash, no dots. -->
<table style="margin-top:10px">
<colgroup>
<col />
<col style="width: 5.5rem" />
<col style="width: 8.5rem" />
</colgroup>
<thead>
<tr>
<th>Name</th>
<th style="text-align:right">Size</th>
<th>Modified</th>
</tr>
</thead>
<tbody>
<tr><td>..</td><td style="text-align:right">—</td><td>—</td></tr>
<tr class="highlighted"><td>Documents</td><td style="text-align:right">—</td><td>3/29/2007 12:00 AM</td></tr>
<tr><td>report.pdf</td><td style="text-align:right">27 KB</td><td>3/23/2007 12:00 AM</td></tr>
<tr class="highlighted"><td>notes.txt</td><td style="text-align:right">4 KB</td><td>3/13/2007 12:00 AM</td></tr>
</tbody>
</table>
</figure>
</div>
<h2>2b — Selection geometry (rows must NOT shift when selected)</h2>
<div class="pair">
<figure class="cell" style="margin:0">
<figcaption>no selection</figcaption>
<table class="is-active">
<colgroup><col /><col style="width:6rem" /></colgroup>
<thead><tr><th>Name</th><th>Size</th></tr></thead>
<tbody>
<tr><td>aaa.txt</td><td>1 KB</td></tr>
<tr><td>bbb.txt</td><td>2 KB</td></tr>
<tr><td>ccc.txt</td><td>3 KB</td></tr>
<tr><td>ddd.txt</td><td>4 KB</td></tr>
</tbody>
</table>
</figure>
<figure class="cell" style="margin:0">
<figcaption>middle row selected — baselines must still line up</figcaption>
<table class="is-active">
<colgroup><col /><col style="width:6rem" /></colgroup>
<thead><tr><th>Name</th><th>Size</th></tr></thead>
<tbody>
<tr><td>aaa.txt</td><td>1 KB</td></tr>
<tr class="highlighted"><td>bbb.txt</td><td>2 KB</td></tr>
<tr><td>ccc.txt</td><td>3 KB</td></tr>
<tr><td>ddd.txt</td><td>4 KB</td></tr>
</tbody>
</table>
</figure>
</div>
<h2>3 — Context menu</h2>
<div class="pair">
<figure class="cell" style="margin:0">
<figcaption>reference — ul[role=menu]</figcaption>
<ul role="menu" style="width:200px">
<li role="menuitem"><a href="#m">Send to Other Panel</a></li>
<li role="menuitem"><a href="#m">Open</a></li>
<li role="menuitem" class="has-divider"><a href="#m">Rename</a></li>
<li role="menuitem"><a href="#m">Copy Path</a></li>
<li role="menuitem" aria-disabled="true"><a href="#m">Permissions</a></li>
<li role="menuitem" class="has-divider"><a href="#m">Delete</a></li>
<li role="menuitem">
<input type="checkbox" id="h-col-size" checked />
<label for="h-col-size">Size</label>
</li>
<li role="menuitem">
<input type="checkbox" id="h-col-kind" />
<label for="h-col-kind">Kind</label>
</li>
</ul>
</figure>
<figure class="cell" style="margin:0">
<figcaption>ours — context-menu.ts output (buttons, divider, accelerator, checkables)</figcaption>
<!-- w7-ctx is the app's marker class (flat highlight, no Aero afterglow).
It also carries a position:fixed restatement, which the app wants and a
review page in normal flow does not — hence the inline static. -->
<ul role="menu" class="w7-ctx" style="width:200px;position:static">
<li role="menuitem"><button type="button">Send to Other Panel</button></li>
<li role="menuitem"><button type="button">Open</button></li>
<li role="menuitem"><button type="button">Edit <span>F4</span></button></li>
<li role="menuitem" class="has-divider"><button type="button">Rename</button></li>
<li role="menuitem"><button type="button">Copy Path</button></li>
<li role="menuitem" class="has-divider"><button type="button">Delete</button></li>
<li role="menuitem">
<input type="checkbox" id="ours-col-size" checked tabindex="-1" />
<label for="ours-col-size">Size</label>
</li>
<li role="menuitem">
<input type="checkbox" id="ours-col-kind" tabindex="-1" />
<label for="ours-col-kind">Kind</label>
</li>
</ul>
</figure>
</div>
<h2>4 — Controls 7.css already owns (sanity check)</h2>
<div class="pair">
<figure class="cell" style="margin:0">
<figcaption>buttons / textbox / progress</figcaption>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="display:flex;gap:6px"><button>OK</button><button>Cancel</button><button disabled>Disabled</button></div>
<input type="text" value="/home/supongo/code" style="width:100%" />
<div role="progressbar" style="width:100%"><div style="width:42%"></div></div>
<div role="progressbar" class="marquee" style="width:100%"></div>
</div>
</figure>
<figure class="cell" style="margin:0">
<figcaption>reference — menu[role=tablist]</figcaption>
<menu role="tablist">
<button role="tab" aria-selected="true">local</button>
<button role="tab">server-a</button>
<button role="tab">server-b</button>
</menu>
<div role="tabpanel" style="padding:10px">Tab body</div>
</figure>
</div>
<h2>5 — Workspace tab strip (ours, as tab-bar.ts renders it)</h2>
<!--
The live app only ever has ONE workspace at startup and there is no way to
click "+" from a headless capture, so multi-tab state can only be checked
here. This mirrors tab-bar.ts exactly: the tabs sit in a menu[role=tablist]
inside a .w7-toolbar row, and the "+" / right-hand tools sit OUTSIDE the
menu so 7.css's descendant selector does not style them as tabs.
-->
<div class="pair">
<figure class="cell" style="margin:0">
<figcaption>ours — 3 workspaces, 2nd active</figcaption>
<div class="w7-toolbar relative flex h-9 items-center gap-0.5 border-b border-border bg-chrome pt-1 pr-1.5 pl-1.5">
<menu role="tablist" class="flex min-w-0 items-center overflow-x-auto">
<button role="tab" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
<span class="truncate">Local</span>
</button>
<button role="tab" aria-selected="true" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
<span class="truncate">build-server</span>
</button>
<button role="tab" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
<span class="truncate">a-very-long-workspace-name-that-truncates</span>
</button>
</menu>
<button class="ml-0.5 grid size-6 place-items-center rounded-md">+</button>
<div class="flex-1"></div>
<button class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px]">Connections</button>
</div>
<div style="height:60px;background:var(--w7-tab-bg,#fff);border:1px solid #8e8f8f;border-top:0"></div>
</figure>
<figure class="cell" style="margin:0">
<figcaption>ours — single workspace (the live startup state)</figcaption>
<div class="w7-toolbar relative flex h-9 items-center gap-0.5 border-b border-border bg-chrome pt-1 pr-1.5 pl-1.5">
<menu role="tablist" class="flex min-w-0 items-center overflow-x-auto">
<button role="tab" aria-selected="true" class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5">
<span class="truncate">Local</span>
</button>
</menu>
<button class="ml-0.5 grid size-6 place-items-center rounded-md">+</button>
<div class="flex-1"></div>
<button class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px]">Connections</button>
</div>
<div style="height:60px;background:var(--w7-tab-bg,#fff);border:1px solid #8e8f8f;border-top:0"></div>
</figure>
</div>
</body>
</html>
+1 -2
View File
@@ -9,13 +9,12 @@
"private": true, "private": true,
"packageManager": "bun@1.3.13", "packageManager": "bun@1.3.13",
"dependencies": { "dependencies": {
"7.css": "^0.21.1",
"@angular/common": "^22.0.0", "@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0", "@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0", "@angular/core": "^22.0.0",
"@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"
}, },
+89 -43
View File
@@ -1,30 +1,51 @@
import { Injectable, inject, signal } from '@angular/core'; import { Injectable, inject, signal } from '@angular/core';
import type { ColumnKey, EntryType } from '@shared/types'; 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 { I18nService } from './i18n.service';
import { IS_MAC, IS_WINDOWS, MOD_LABEL } from './platform'; import { stepIndex } from './menu-nav';
import type { Side } from './panels.service';
import { IS_MAC, IS_WINDOWS } from './platform';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
import type { ShortcutId } from './shortcuts.service';
/** Optional list columns, in display order (Name is fixed). */ /** Optional list columns, in display order (Name is fixed). */
const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group']; export const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group'];
export interface MenuItem { export interface MenuItem {
type?: 'separator'; /** separator: a rule; header: a non-pickable section title (the source picker). */
type?: 'separator' | 'header';
label?: string; label?: string;
/** Emitted verbatim to the action dispatcher (`ctx-*` / `col:*` / `tab:*`). */ /** Handed to the request's onPick, else emitted to the action dispatcher
* (`col:*` / `tab:*`). */
action?: string; action?: string;
checked?: boolean; checked?: boolean;
tooltip?: string; tooltip?: string;
/** Shortcut hint, rendered right-aligned. */ /** The keymap command the item runs: its first key shows as the soft hint,
accelerator?: string; * and pressing that key while the menu is open picks the item. */
/** Greyed and unclickable. Win7 menus disable rather than hide, so the set shortcut?: ShortcutId;
* of commands stays in the same place from one right-click to the next. */ /** 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; 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 { export interface MenuRequest {
x: number; x: number;
y: number; y: number;
items: MenuItem[]; 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 { export interface TabMenuContext {
@@ -38,25 +59,27 @@ export interface TabMenuContext {
} }
export interface RowMenuContext { export interface RowMenuContext {
path: string; side: Side;
name: string; name: string;
type: EntryType; type: EntryType;
isLocal: boolean; isLocal: boolean;
hasEntry: boolean; // false = right-click on empty panel space 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) // PORTABILITY: shell-integration wording per OS (resolved at build time for locale)
const revealKey = (): string => const revealKey = (): string =>
IS_MAC ? 'ctx.revealFinder' : IS_WINDOWS ? 'ctx.revealExplorer' : 'ctx.revealFileManager'; IS_MAC ? 'ctx.revealFinder' : IS_WINDOWS ? 'ctx.revealExplorer' : 'ctx.revealFileManager';
const trashKey = (): string => (IS_WINDOWS ? 'ctx.moveToRecycleBin' : 'ctx.moveToTrash'); const trashKey = (): string => (IS_WINDOWS ? 'ctx.moveToRecycleBin' : 'ctx.moveToTrash');
/** /**
* Builds and opens the panel right-click menus. This used to be a native * Builds and opens the HTML context menus. This used to be a native
* electrobun ContextMenu on the bun side, but `showContextMenu` is a documented * electrobun ContextMenu on the bun side, but `showContextMenu` is a documented
* no-op on Linux (electrobun's own dist/api/bun/proc/linux.md), which left the * no-op on Linux (electrobun's own .hutch/devkit/api/sdks/main/proc/linux.md),
* whole right-click surface dead there. The menu is plain HTML now — one code * which left the whole right-click surface dead there. The menu is plain HTML
* path on every platform — and still emits the exact same action strings, so * now — one code path on every platform.
* the dispatcher in app.ts is unchanged.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ContextMenuService { export class ContextMenuService {
@@ -65,76 +88,91 @@ export class ContextMenuService {
readonly request = signal<MenuRequest | null>(null); readonly request = signal<MenuRequest | null>(null);
open(event: MouseEvent, items: MenuItem[]): void { open(
this.request.set({ x: event.clientX, y: event.clientY, items }); 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 { close(): void {
this.request.set(null); this.request.set(null);
} }
/** A picked item goes through the same dispatcher the app menubar uses. */ /** A pick without its own handler goes through the dispatcher the app
onPick(action: string): void { * menubar uses. */
emit(action: string): void {
this.rpc.emitMenuAction(action); this.rpc.emitMenuAction(action);
} }
/** Row / empty-space menu for a file panel. */ /**
* 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[] { rowItems(ctx: RowMenuContext): MenuItem[] {
const t = (key: string) => this.i18n.t(key); 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[] = []; const items: MenuItem[] = [];
if (!ctx.hasEntry || ctx.name === '..') { if (!ctx.hasEntry || ctx.name === '..') {
// empty space (or the parent row): folder-level actions // empty space (or the parent row): folder-level actions
items.push({ label: t('ctx.newFolder'), action: 'ctx-new-folder' }); items.push(cmd('new-folder', t('ctx.newFolder'), { icon: 'folderAdd14' }));
items.push({ label: t('ctx.newFile'), action: 'ctx-new-file' }); items.push(cmd('new-file', t('ctx.newFile')));
items.push({ type: 'separator' }); items.push({ type: 'separator' });
items.push({ label: t('ctx.refresh'), action: 'ctx-refresh' }); items.push(cmd('refresh', t('ctx.refresh')));
if (ctx.isLocal) { if (ctx.isLocal) {
items.push({ type: 'separator' }); items.push({ type: 'separator' });
items.push({ label: t(revealKey()), action: 'ctx-reveal' }); items.push(cmd('reveal', t(revealKey())));
} }
return items; return items;
} }
items.push({ label: t('ctx.copyToOther'), action: 'ctx-copy' }); // 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') { if (ctx.type === 'dir') {
items.push({ label: t('ctx.open'), action: 'ctx-open' }); items.push(cmd('open', t('ctx.open')));
} else { } else {
if (ctx.isLocal) { if (ctx.isLocal) {
items.push({ label: t('ctx.open'), action: 'ctx-open-default', tooltip: t('ctx.openDefault') }); items.push(cmd('open-default', t('ctx.open'), { tooltip: t('ctx.openDefault') }));
} }
items.push({ label: t('ctx.edit'), action: 'ctx-edit', accelerator: 'F4' }); items.push(cmd('edit', t('ctx.edit'), { icon: 'fileText14' }));
} }
items.push({ label: t('ctx.rename'), action: 'ctx-rename' }); items.push(cmd('rename', t('ctx.rename'), { icon: 'edit14' }));
// chmod on Windows local files only flips the read-only bit — hide the lie // chmod on Windows local files only flips the read-only bit — hide the lie
if (!(ctx.isLocal && IS_WINDOWS)) { if (!(ctx.isLocal && IS_WINDOWS)) {
items.push({ label: t('ctx.permissions'), action: 'ctx-chmod' }); items.push(cmd('chmod', t('ctx.permissions')));
} }
items.push({ type: 'separator' }); items.push({ type: 'separator' });
items.push({ label: t('ctx.copyPath'), action: 'ctx-copy-path' }); items.push(cmd('copy-path', t('ctx.copyPath')));
if (ctx.isLocal) { if (ctx.isLocal) {
items.push({ label: t(revealKey()), action: 'ctx-reveal' }); items.push(cmd('reveal', t(revealKey())));
} }
items.push({ type: 'separator' }); items.push({ type: 'separator' });
if (ctx.isLocal) { if (ctx.isLocal) {
items.push({ items.push(cmd('trash', t(trashKey()), IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}));
label: t(trashKey()),
action: 'ctx-trash',
...(IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}),
});
} }
items.push({ label: t('ctx.delete'), action: 'ctx-delete' }); items.push(cmd('delete', t('ctx.delete'), { icon: 'delete14' }));
return items; return items;
} }
/** /**
* The workspace-tab menu. Actions carry the target tab's id, because the * 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 * right-clicked tab is not necessarily the active one — nothing here may
* assume `activeWorkspace`. * 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 * Everything illegal is DISABLED, not omitted: a menu whose items move
* between right-clicks is a Win7 sin, and "Close Tab" greyed out explains * between right-clicks is worse than a dead row, and "Close Tab" greyed out
* why the last tab cannot be closed better than its absence does. * explains why the last tab cannot be closed better than its absence does.
* *
* Duplicate/Rename/Save-session are deliberately absent until tabs have an * Duplicate/Rename/Save-session are deliberately absent until tabs have an
* identity to duplicate (a stored connection id) — the sessions round. * identity to duplicate (a stored connection id) — the sessions round.
@@ -143,24 +181,32 @@ export class ContextMenuService {
const t = (key: string) => this.i18n.t(key); const t = (key: string) => this.i18n.t(key);
const last = ctx.tabCount <= 1; const last = ctx.tabCount <= 1;
return [ return [
{ label: t('ctx.tabNew'), action: 'tab:new', accelerator: `${MOD_LABEL}T` }, { label: t('ctx.tabNew'), action: 'tab:new', shortcut: 'new-tab', icon: 'add14' },
{ type: 'separator' }, { type: 'separator' },
{ {
label: t('ctx.tabClose'), label: t('ctx.tabClose'),
action: `tab:close:${ctx.id}`, action: `tab:close:${ctx.id}`,
accelerator: `${MOD_LABEL}W`, shortcut: 'close-tab',
icon: 'close14',
disabled: last,
},
{
label: t('ctx.tabCloseOthers'),
action: `tab:close-others:${ctx.id}`,
shortcut: 'close-other-tabs',
disabled: last, disabled: last,
}, },
{ label: t('ctx.tabCloseOthers'), action: `tab:close-others:${ctx.id}`, disabled: last },
{ {
label: t('ctx.tabCloseRight'), label: t('ctx.tabCloseRight'),
action: `tab:close-right:${ctx.id}`, action: `tab:close-right:${ctx.id}`,
shortcut: 'close-tabs-right',
disabled: ctx.index >= ctx.tabCount - 1, disabled: ctx.index >= ctx.tabCount - 1,
}, },
{ type: 'separator' }, { type: 'separator' },
{ {
label: t('ctx.tabDisconnect'), label: t('ctx.tabDisconnect'),
action: `tab:disconnect:${ctx.id}`, action: `tab:disconnect:${ctx.id}`,
shortcut: 'disconnect-tab',
disabled: !ctx.isRemote, disabled: !ctx.isRemote,
}, },
]; ];
+3 -1
View File
@@ -47,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 {
+114 -17
View File
@@ -1,25 +1,50 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import type { FileEntry } from '@shared/types'; import type { FileEntry } from '@shared/types';
import { ContextMenuService } from './context-menu.service'; import { ContextMenuService, type MenuPoint } from './context-menu.service';
import { DialogsService } from './dialogs.service'; import { DialogsService } from './dialogs.service';
import { joinPath } from './format'; import { joinPath } from './format';
import { type PanelState, type Side } from './panels.service'; import { type PanelState, type Side, type SortKey } from './panels.service';
import { IS_WINDOWS } from './platform'; import { IS_WINDOWS } from './platform';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
import { SettingsService } from './settings.service'; import { SettingsService } from './settings.service';
import type { ShortcutId } from './shortcuts.service';
import { I18nService } from './i18n.service'; import { I18nService } from './i18n.service';
import { ToastService } from './toast.service'; import { ToastService } from './toast.service';
import { TransfersService } from './transfers.service';
/** The row (or empty space) the user last right-clicked; ctx-* actions apply here. */ /** What a file command acts on: a panel, and the row it was invoked on (the
export interface MenuTarget { * cursor row from the keyboard, the right-clicked row from a menu; null =
* the folder itself). */
export interface FileTarget {
side: Side; side: Side;
panel: PanelState; panel: PanelState;
entry: FileEntry | null; 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 invoked from a panel's toolbar / keyboard. They act on the * File operations, always against an explicit panel: on its selection if there
* GIVEN panel: on the selection if there is one, else on the cursor row. * is one, else on the target row.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class FileActionsService { export class FileActionsService {
@@ -29,29 +54,101 @@ export class FileActionsService {
private readonly dialogs = inject(DialogsService); private readonly dialogs = inject(DialogsService);
private readonly menu = inject(ContextMenuService); private readonly menu = inject(ContextMenuService);
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly transfers = inject(TransfersService);
menuTarget: MenuTarget | null = null; /** 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);
}
}
/** Right-click entry point: remember the target, then open the menu on it. */ /** Open the row / empty-space menu on `target`; its picks run here. */
openContextMenu(event: MouseEvent, target: MenuTarget): void { openContextMenu(point: MenuPoint, target: FileTarget, keyboard = false): void {
this.menuTarget = target; const { side, panel, entry } = target;
const { panel, entry } = target;
this.menu.open( this.menu.open(
event, point,
this.menu.rowItems({ this.menu.rowItems({
path: entry?.path ?? panel.cwd(), side,
name: entry?.name ?? '', name: entry?.name ?? '',
type: entry?.type ?? 'dir', type: entry?.type ?? 'dir',
isLocal: panel.sessionId() === 'local', isLocal: panel.sessionId() === 'local',
hasEntry: !!entry, hasEntry: !!entry,
}), }),
{ keyboard, onPick: (action) => this.run(action as FileCommand, target) },
); );
} }
async trash(panel: PanelState, entry: FileEntry): Promise<void> { /** Local shell integration — only bun can reach the OS shell / clipboard. */
const res = await this.rpc.request.moveToTrash({ path: entry.path }); private shell(action: 'open' | 'reveal' | 'copyPath', path: string): void {
if (!res.ok) this.toast.error(res.error); void this.rpc.request.shellItemAction({ action, path });
else this.toast.info(this.i18n.t('toasts.movedToTrash', { name: entry.name })); }
/** 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(); await panel.refresh();
} }
+15 -18
View File
@@ -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(' '),
}, },
@@ -85,18 +85,15 @@ export function fileKindKey(entry: FileEntry): string {
return category ? KIND_KEYS[category] : 'file'; return category ? KIND_KEYS[category] : 'file';
} }
export function fileGlyph(entry: FileEntry, multi: boolean): string { export function fileGlyph(entry: FileEntry): Icon16 {
if (entry.name === '..') return 'mynaArrowUp'; if (entry.name === '..') return 'arrowUp16';
if (entry.type === 'dir') return 'mynaFolder'; if (entry.type === 'dir') return 'folder16';
if (!multi) return 'mynaFile'; if (entry.type === 'symlink') return 'link16';
if (entry.type === 'symlink') return 'mynaLink';
const category = categoryOf(entry.name); const category = categoryOf(entry.name);
return category ? CATEGORIES[category].glyph : 'mynaFile'; return category ? CATEGORIES[category].glyph : 'file16';
} }
export function fileIconClass(entry: FileEntry, multi: boolean): string { export function fileIconClass(entry: FileEntry): string {
// plain mode: exactly the pre-color look ('..' has type 'dir' -> primary)
if (!multi) return entry.type === 'dir' ? PRIMARY : MUTED;
if (entry.name === '..') return MUTED; if (entry.name === '..') return MUTED;
if (entry.type === 'dir') return FOLDER; if (entry.type === 'dir') return FOLDER;
if (entry.type === 'symlink') return LINK; if (entry.type === 'symlink') return LINK;
+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;
}
+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([]);
});
+15 -72
View File
@@ -1,76 +1,19 @@
// Single icon registry for the whole app, registered once in app.config.ts. // Single icon registry for the whole app, registered once in app.config.ts.
// NOTE: @ng-icons/mynaui's package root is empty — always import from /outline // A name is <icon><px>: the pixel grid the glyph is drawn for, which must match
// (or /solid, where names get a `Solid` suffix). // the size token where it renders (pixel-icons.ts). Templates can't type-check
import { // names and an unregistered one renders nothing (a console warning only), so
mynaArchive, // icons.spec.ts checks every template name; TS code takes the Icon14/Icon16 types.
mynaArrowLeft, import { tdesignCloseCircle, tdesignLoading } from "@ng-icons/tdesign-icons";
mynaArrowLeftRight, import { PIXEL_ICONS } from "./pixel-icons";
mynaArrowRight,
mynaArrowUp,
mynaCheck,
mynaCheckCircle,
mynaChevronDown,
mynaChevronRight,
mynaChevronUp,
mynaCode,
mynaCog,
mynaDots,
mynaDownload,
mynaEdit,
mynaFile,
mynaFilePlus,
mynaFileText,
mynaFolder,
mynaFolderPlus,
mynaHardDrive,
mynaImage,
mynaLink,
mynaLock,
mynaMusic,
mynaPlus,
mynaRefresh,
mynaSearch,
mynaServers,
mynaSpinner,
mynaTrash,
mynaVideo,
mynaX,
mynaXCircle,
} from "@ng-icons/mynaui/outline";
export const APP_ICONS = { export const APP_ICONS = {
mynaArchive, ...PIXEL_ICONS,
mynaArrowLeft, // TDesign's own 24px grid: as sharp as a circle and its diagonals can be
mynaArrowLeftRight, closeCircle24: tdesignCloseCircle,
mynaArrowRight, // spins, so no pixel grid holds; any size
mynaArrowUp, tdesignLoading,
mynaCheck,
mynaCheckCircle,
mynaChevronDown,
mynaChevronRight,
mynaChevronUp,
mynaCode,
mynaCog,
mynaDots,
mynaDownload,
mynaEdit,
mynaFile,
mynaFilePlus,
mynaFileText,
mynaFolder,
mynaFolderPlus,
mynaHardDrive,
mynaImage,
mynaLink,
mynaLock,
mynaMusic,
mynaPlus,
mynaRefresh,
mynaSearch,
mynaServers,
mynaSpinner,
mynaTrash,
mynaVideo,
mynaX,
mynaXCircle,
}; };
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;
}
+13
View File
@@ -352,6 +352,19 @@ export class PanelsService {
this.focus.bump(); 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 ----
newWorkspace(): void { newWorkspace(): void {
+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')),
};
+14 -26
View File
@@ -7,10 +7,7 @@ declare global {
interface Window { interface Window {
__APP_PLATFORM?: string; __APP_PLATFORM?: string;
/** Which root component this window boots. */ /** Which root component this window boots. */
__APP_MODE?: 'main' | 'editor' | 'manager'; __APP_MODE?: 'main' | 'editor' | 'manager' | 'settings';
/** Which skin tree this window boots. Baked into the preload because
* settings resolve too late to decide layout without a flash. */
__APP_SKIN?: 'modern' | 'win7';
/** Window-kind specific context (EditorOpenContext for editor windows). */ /** Window-kind specific context (EditorOpenContext for editor windows). */
__APP_CTX?: unknown; __APP_CTX?: unknown;
} }
@@ -24,36 +21,27 @@ export const IS_WINDOWS = PLATFORM === 'win32';
export const IS_LINUX = PLATFORM === 'linux'; export const IS_LINUX = PLATFORM === 'linux';
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. Keyed on the real export const APP_MODE: 'main' | 'editor' | 'manager' | 'settings' =
* platform — the keyboard does not change with the window chrome. */
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
export const APP_MODE: 'main' | 'editor' | 'manager' =
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main'; typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
export const EDITOR_CONTEXT = export const EDITOR_CONTEXT =
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined; typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
/** /** True when macOS traffic lights overlay the app's own header row (the fused
* Which skin tree this window booted. Read from the preload rather than from * hiddenInset layout, see chromeOptions), which reserves `pl-24` for them. */
* SettingsService because both are needed before first paint — main.ts picks a export const IS_MAC_CHROME = IS_MAC;
* root component from it, and it must not change under a running window (the
* native frame is fixed at BrowserWindow construction).
*/
export const APP_SKIN: 'modern' | 'win7' =
typeof window !== 'undefined' ? (window.__APP_SKIN ?? 'modern') : 'modern';
export const IS_WIN7_CHROME = APP_SKIN === 'win7';
/** /**
* True when macOS traffic lights overlay the app's own header row — the fused * WebView2 (Windows) reloads the whole page on F5 / Ctrl+R / Ctrl+F5 /
* `hiddenInset` layout, which reserves `pl-24` for them. False under the win7 * Ctrl+Shift+R unless the page claims the key — dropping every session and any
* skin even on macOS: `titleBarStyle: 'hidden'` removes the traffic lights * unsaved editor text. Every window root calls this for keys none of its
* altogether, so reserving space would leave a gap. Only the shadlike tree * bindings took. Known ceiling: unverified whether Electrobun already turns
* reads this; 7like draws its own title bar and never defers to native chrome. * WebView2's browser keys off; harmless if it does.
*/ */
export const IS_MAC_CHROME = IS_MAC && !IS_WIN7_CHROME; 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…) * Kill the webview's default right-click menu (Reload, Inspect Element…)
+4 -13
View File
@@ -32,7 +32,6 @@ export class RpcService {
private managerStateListeners = new Set<(open: boolean) => void>(); private managerStateListeners = new Set<(open: boolean) => void>();
private connectionsListeners = new Set<() => void>(); private connectionsListeners = new Set<() => void>();
private updateStatusListeners = new Set<(status: UpdateStatus) => void>(); private updateStatusListeners = new Set<(status: UpdateStatus) => void>();
private windowStateListeners = new Set<(maximized: boolean) => void>();
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined; private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
private ready: Promise<void> = Promise.resolve(); private ready: Promise<void> = Promise.resolve();
@@ -72,17 +71,15 @@ export class RpcService {
updateStatus: (status) => { updateStatus: (status) => {
for (const listener of this.updateStatusListeners) listener(status); for (const listener of this.updateStatusListeners) listener(status);
}, },
windowState: ({ maximized }) => {
for (const listener of this.windowStateListeners) listener(maximized);
},
}, },
}, },
}); });
const electroview = new Electroview({ rpc: this.rpc }); const electroview = new Electroview({ rpc: this.rpc });
// Requests fired before the RPC WebSocket finishes connecting are lost, so // Gate everything below on the RPC WebSocket being open (services fire calls
// everything below gates on it being open (services fire calls at startup). // at startup). v1 silently lost pre-open sends; 2.x Electroview queues them
const socket = electroview.bunSocket; // and falls back to the native bridge, so this is now just belt-and-braces.
const socket = electroview.hostSocket;
if (socket && socket.readyState !== WebSocket.OPEN) { if (socket && socket.readyState !== WebSocket.OPEN) {
this.ready = new Promise((resolve) => { this.ready = new Promise((resolve) => {
socket.addEventListener('open', () => resolve(), { once: true }); socket.addEventListener('open', () => resolve(), { once: true });
@@ -136,12 +133,6 @@ export class RpcService {
this.updateStatusListeners.add(listener); this.updateStatusListeners.add(listener);
} }
/** win7 skin: the frameless window's maximize state changed (derived from
* the native resize event, so Aero Snap and Win+Up land here too). */
onWindowState(listener: (maximized: boolean) => void): void {
this.windowStateListeners.add(listener);
}
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */ /** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
get request() { get request() {
const rpc = this.rpc; const rpc = this.rpc;
+23 -66
View File
@@ -7,11 +7,8 @@ import { RpcService } from './rpc.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SettingsService { export class SettingsService {
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale); readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
readonly skin = signal<AppSettings['skin']>(DEFAULT_SETTINGS.skin);
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme); readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone); readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
readonly iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors);
readonly columns = signal<ColumnKey[]>(DEFAULT_SETTINGS.columns); readonly columns = signal<ColumnKey[]>(DEFAULT_SETTINGS.columns);
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit); readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
readonly uiScale = signal<number>(DEFAULT_SETTINGS.uiScale); readonly uiScale = signal<number>(DEFAULT_SETTINGS.uiScale);
@@ -20,6 +17,9 @@ export class SettingsService {
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted); readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
readonly editor = signal<AppSettings['editor']>(DEFAULT_SETTINGS.editor); readonly editor = signal<AppSettings['editor']>(DEFAULT_SETTINGS.editor);
readonly editorApp = signal(DEFAULT_SETTINGS.editorApp); readonly editorApp = signal(DEFAULT_SETTINGS.editorApp);
/** 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 // Auto-refresh is always on for now (see panels.service). To make it
// user-controllable: uncomment the field in types.ts, then here add // user-controllable: uncomment the field in types.ts, then here add
// readonly autoRefresh = signal(DEFAULT_SETTINGS.autoRefresh); // readonly autoRefresh = signal(DEFAULT_SETTINGS.autoRefresh);
@@ -28,6 +28,10 @@ export class SettingsService {
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
private readonly i18n = inject(I18nService); 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). */ /** Whether the app is actually rendering dark right now (drives the Tone row). */
readonly effectiveDark = computed( readonly effectiveDark = computed(
@@ -39,8 +43,6 @@ export class SettingsService {
window window
.matchMedia('(prefers-color-scheme: dark)') .matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => this.systemDark.set(e.matches)); .addEventListener('change', (e) => this.systemDark.set(e.matches));
// the default accent is not the CSS default — apply before first paint
this.applyAccent();
if (!this.rpc.available) return; if (!this.rpc.available) return;
void this.rpc.request.getSettings({}).then((res) => { void this.rpc.request.getSettings({}).then((res) => {
if (res.ok) this.applyAll(res.settings); if (res.ok) this.applyAll(res.settings);
@@ -50,12 +52,10 @@ export class SettingsService {
} }
private applyAll(settings: AppSettings): void { private applyAll(settings: AppSettings): void {
this.loaded = true;
this.locale.set(settings.locale); this.locale.set(settings.locale);
this.skin.set(settings.skin);
this.theme.set(settings.theme); this.theme.set(settings.theme);
this.darkTone.set(settings.darkTone); this.darkTone.set(settings.darkTone);
this.accent.set(settings.accent);
this.iconColors.set(settings.iconColors);
this.columns.set(settings.columns); this.columns.set(settings.columns);
this.speedUnit.set(settings.speedUnit); this.speedUnit.set(settings.speedUnit);
this.uiScale.set(settings.uiScale); this.uiScale.set(settings.uiScale);
@@ -64,12 +64,10 @@ export class SettingsService {
this.autoClearCompleted.set(settings.autoClearCompleted); this.autoClearCompleted.set(settings.autoClearCompleted);
this.editor.set(settings.editor); this.editor.set(settings.editor);
this.editorApp.set(settings.editorApp); this.editorApp.set(settings.editorApp);
// NO applySkin here, deliberately — see setSkin. applyAll also runs on the this.keybindings.set(settings.keybindings);
// settingsChanged push, so flipping the class here would break every OPEN this.openFilesOnEnter.set(settings.openFilesOnEnter);
// editor and manager window too, not just the one that changed the setting.
this.applyTheme(); this.applyTheme();
this.applyDarkTone(); this.applyDarkTone();
this.applyAccent();
this.applyUiScale(); this.applyUiScale();
this.applyLocale(); this.applyLocale();
} }
@@ -80,30 +78,6 @@ export class SettingsService {
void this.persist(); void this.persist();
} }
/**
* Saves the preference and NOTHING else — the skin is a launch-time choice.
*
* It deliberately does not touch the `.win7` class, which is the whole bug
* this replaced: each skin's CSS is written against its OWN component tree's
* markup, and the tree is fixed at bootstrap from the preload-baked
* __APP_SKIN. Flipping the class live therefore painted 7.css onto shadlike
* markup that has no .window.glass, no .title-bar, no menu[role=tablist] and
* no <table> ListView — a broken window, not a reskinned one.
*
* A reload would not have helped either: the preload string is frozen at
* webview construction (a persistent WebKitUserScript), and titleBarStyle /
* transparent have no setters, so the native frame cannot change on a live
* window. win7 -> modern without a reframe is the dangerous direction — the
* modern tree draws no title bar, so it would land in a frameless window
* with no way to move, minimize or close it.
*
* The options row shows the saved value as pending until the next start.
*/
setSkin(skin: AppSettings['skin']): void {
this.skin.set(skin);
void this.persist();
}
setTheme(theme: AppSettings['theme']): void { setTheme(theme: AppSettings['theme']): void {
this.theme.set(theme); this.theme.set(theme);
this.applyTheme(); this.applyTheme();
@@ -116,18 +90,6 @@ export class SettingsService {
void this.persist(); void this.persist();
} }
setAccent(tone: AppSettings['accent']): void {
this.accent.set(tone);
this.applyAccent();
void this.persist();
}
setIconColors(mode: AppSettings['iconColors']): void {
// no apply step — the file panels read the signal reactively
this.iconColors.set(mode);
void this.persist();
}
toggleColumn(key: ColumnKey): void { toggleColumn(key: ColumnKey): void {
this.columns.update((list) => this.columns.update((list) =>
list.includes(key) ? list.filter((c) => c !== key) : [...list, key], list.includes(key) ? list.filter((c) => c !== key) : [...list, key],
@@ -171,11 +133,15 @@ export class SettingsService {
void this.persist(); void this.persist();
} }
// There is deliberately no applySkin(). The `.win7` class is written once by setKeybindings(keybindings: AppSettings['keybindings']): void {
// buildPreload at DOCUMENT_START for the skin this window actually booted, this.keybindings.set(keybindings);
// and must never change afterwards — see setSkin for why. Every other apply* void this.persist();
// below is safe to run live because it only re-points tokens, and both skins }
// consume the same token system.
setOpenFilesOnEnter(open: boolean): void {
this.openFilesOnEnter.set(open);
void this.persist();
}
private applyTheme(): void { private applyTheme(): void {
const theme = this.theme(); const theme = this.theme();
@@ -195,17 +161,8 @@ export class SettingsService {
} }
} }
private applyAccent(): void {
const tone = this.accent();
if (tone === 'graphite') {
delete document.documentElement.dataset['accent'];
} else {
document.documentElement.dataset['accent'] = tone;
}
}
/** The whole interface scale is ONE declaration: every size in the app is /** The whole interface scale is ONE declaration: every size in the app is
* rem, so moving the root font-size moves both skins in lockstep. 1 = the * rem, so moving the root font-size moves the whole UI. 1 = the
* browser-default 16px root; delete-when-default keeps the pristine state * browser-default 16px root; delete-when-default keeps the pristine state
* attribute-free like the other axes. buildPreload mirrors this for a * attribute-free like the other axes. buildPreload mirrors this for a
* flash-free first paint. */ * flash-free first paint. */
@@ -225,11 +182,8 @@ export class SettingsService {
protected settings(): AppSettings { protected settings(): AppSettings {
return { return {
locale: this.locale(), locale: this.locale(),
skin: this.skin(),
theme: this.theme(), theme: this.theme(),
darkTone: this.darkTone(), darkTone: this.darkTone(),
accent: this.accent(),
iconColors: this.iconColors(),
columns: this.columns(), columns: this.columns(),
speedUnit: this.speedUnit(), speedUnit: this.speedUnit(),
uiScale: this.uiScale(), uiScale: this.uiScale(),
@@ -238,10 +192,13 @@ export class SettingsService {
autoClearCompleted: this.autoClearCompleted(), autoClearCompleted: this.autoClearCompleted(),
editor: this.editor(), editor: this.editor(),
editorApp: this.editorApp(), editorApp: this.editorApp(),
keybindings: this.keybindings(),
openFilesOnEnter: this.openFilesOnEnter(),
}; };
} }
private async persist(): Promise<void> { private async persist(): Promise<void> {
if (!this.loaded) return;
await this.rpc.request.saveSettings({ settings: this.settings() }); await this.rpc.request.saveSettings({ settings: this.settings() });
} }
} }
+148 -93
View File
@@ -1,111 +1,166 @@
import { Injectable } from '@angular/core'; 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 };
/** /**
* Every window-level shortcut the app answers to, by name. Components switch * The keyboard layer over the shared keymap (src/shared/keymap.ts): resolves
* on these ids instead of key-matching inline, so the whole keymap is ONE * keydowns against the EFFECTIVE keymap — defaults plus the Settings → Keyboard
* table — the precondition for the vim-style bindings planned next, which * remaps, live in every window — and formats the hints menus and tooltips show.
* will need a mode/leader state machine wrapped around exactly this point.
*/
export type ShortcutId =
// main window (workspace/tab management)
| 'new-tab'
| 'close-tab'
| 'cycle-tab'
| 'cycle-tab-back'
| 'go-to-tab' // 1-9; the digit rides in ShortcutMatch.digit
// editor window
| 'save'
| 'find'
| 'close-window'
| 'toggle-wrap'
// any window
| 'scale-up'
| 'scale-down'
| 'scale-reset';
export interface ShortcutMatch {
id: ShortcutId;
/** Only for go-to-tab: the digit pressed, 1–9. */
digit?: number;
}
/** Which keymap a window consults. 'manager' answers only to the any-window
* shortcuts (scale). Escape/Tab stay component-local: their meaning is
* contextual state (find bar open? close prompt up? which pane?), not a
* global binding. */
export type ShortcutScope = 'main' | 'editor' | 'manager';
/**
* The keyboard layer: modifier helpers + one declarative keymap.
* *
* House rules encoded here rather than re-derived per component (they were * - Matching is exact: ⌃F is not ⌘F, Ctrl+Tab is not Tab. `Mod` is ⌘ on
* previously copy-pasted inline in six places, drifting apart): * macOS and Ctrl elsewhere.
* - `mod` is metaKey OR ctrlKey — ⌘ on macOS, Ctrl elsewhere. The keyboard * - On macOS the PAGE sees ⌘-keys first: WKWebView hands key equivalents to the
* does not change with the window chrome (platform.ts says the same about * page before the app menu (WebKit's WebViewImpl::performKeyEquivalent), so a
* MOD_LABEL). * key a window claims here (claim() → preventDefault) never reaches the menu
* - Layout-dependent keys match on `event.code`, not `event.key`: digits * bar, whose accelerators bun builds from the same table. Keys nobody claims
* (AZERTY's digit row shifts), +/- (key varies wildly across layouts), and * fall through to it.
* letter chords with Alt (macOS Option produces Ω for ⌥Z). Plain letter * - From a text field only firesInTextField() combos count (⌘S yes, Delete no).
* chords match on `key` so they follow the user's layout. * - Escape, the arrows inside menus and dialog Enter stay component-local:
* - isTypingTarget guards shortcuts that would be hostile mid-typing. It is * they are fixed, so nobody can remap themselves out of a dialog.
* deliberately NOT applied inside resolve(): the existing bindings rely on
* the path bar's stopPropagation and the editor WANTS Mod+S while typing.
* Callers opt in per shortcut (the scale keys do).
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ShortcutsService { export class ShortcutsService {
/** The platform command modifier: ⌘ on macOS, Ctrl elsewhere. Mouse events private readonly settings = inject(SettingsService);
* qualify too — Mod+click toggle-select in the panels uses the same rule. */ private readonly i18n = inject(I18nService);
isMod(event: KeyboardEvent | MouseEvent): boolean {
return event.metaKey || event.ctrlKey;
}
/** True when the event originates from something the user types into. */ readonly keymap = computed(() => effectiveKeymap(this.settings.keybindings(), IS_MAC));
isTypingTarget(event: KeyboardEvent): boolean { private readonly byId = computed(() => new Map(this.keymap().map((b) => [b.id, b])));
const target = event.target as HTMLElement | null; /** Localized key names for the non-mac spelling ("Strg+Entf"); reading t()
return !!target?.closest('input, textarea, select, [contenteditable="true"]'); * 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;
} }
/** /**
* Match an event against the scope's keymap. Returns null for anything the * resolve() + take the key: a match is preventDefault-ed, so the macOS menu
* table does not claim — callers must then leave the event alone. * never runs it a second time. A held key repeats only movement-like
* commands; any other repeat is swallowed (holding ⌘W closes one tab).
*/ */
resolve(event: KeyboardEvent, scope: ShortcutScope): ShortcutMatch | null { claim(event: KeyboardEvent, scope: Scope): ShortcutId | null {
const mod = this.isMod(event); const id = this.resolve(event, scope);
const key = event.key.toLowerCase(); if (!id) return null;
event.preventDefault();
return event.repeat && !isRepeatable(id) ? null : id;
}
// ---- any window: interface scale ---- /** Whether a keydown is one of `id`'s combos (an open menu matching its items). */
// event.code, not key: '+' needs Shift on many layouts and '-' moves matches(id: ShortcutId, event: KeyEventLike): boolean {
// around; the physical Equal/Minus keys (and the numpad pair) are stable. return !!this.byId().get(id)?.combos.some((c) => matchesEvent(c, event, IS_MAC));
if (mod && !event.altKey) { }
if (event.code === 'Equal' || event.code === 'NumpadAdd') return { id: 'scale-up' };
if (event.code === 'Minus' || event.code === 'NumpadSubtract') return { id: 'scale-down' }; binding(id: ShortcutId): ResolvedBinding | undefined {
if (event.code === 'Digit0' || event.code === 'Numpad0') return { id: 'scale-reset' }; 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);
}
if (scope === 'manager') return null; // any-window shortcuts only resetAll(): void {
this.settings.setKeybindings({});
}
if (scope === 'main') { /** The platform command modifier: ⌘ on macOS, Ctrl elsewhere — exactly one.
if (mod && key === 't') return { id: 'new-tab' }; * Mouse events too: Mod+click toggle-selects in the panels. */
if (mod && key === 'w') return { id: 'close-tab' }; isMod(event: KeyboardEvent | MouseEvent): boolean {
// ctrlKey only, never meta: ⌘Tab is the macOS app switcher return IS_MAC ? event.metaKey : event.ctrlKey;
if (event.ctrlKey && event.key === 'Tab') { }
return { id: event.shiftKey ? 'cycle-tab-back' : 'cycle-tab' };
}
if (mod && !event.shiftKey && !event.altKey) {
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1];
if (digit) return { id: 'go-to-tab', digit: Number(digit) };
}
return null;
}
// scope === 'editor' /** True when the event comes from something the user types into. */
if (mod && key === 's') return { id: 'save' }; isTypingTarget(event: { target?: EventTarget | null }): boolean {
if (mod && key === 'f') return { id: 'find' }; const target = event.target as HTMLElement | null;
// on macOS the app menu intercepts ⌘W before this ever fires; this arm is return !!target?.closest?.(
// the Windows/Linux path 'textarea, select, [contenteditable="true"], input:not([type=checkbox]):not([type=radio]):not([type=button])',
if (mod && key === 'w') return { id: 'close-window' }; );
if (event.altKey && event.code === 'KeyZ') return { id: 'toggle-wrap' };
return null;
} }
} }
+8
View File
@@ -45,6 +45,14 @@ export class UpdaterService {
void this.rpc.request.applyUpdate({}); // quits + relaunches void this.rpc.request.applyUpdate({}); // quits + relaunches
} }
/** The pill's one action, also the "Install update" key: download when
* available, apply (relaunch) once ready; nothing otherwise. */
advance(): void {
const state = this.state();
if (state === 'available') void this.download();
else if (state === 'ready') this.apply();
}
/** Manual re-check (menu item). */ /** Manual re-check (menu item). */
async check(): Promise<void> { async check(): Promise<void> {
if (!this.rpc.available) return; if (!this.rpc.available) return;
-840
View File
@@ -1,840 +0,0 @@
/* The SCOPED 7.css build: postcss-prefix-selector rewrote every selector to
`.win7` (root rules) or `.win7 <sel>` (the rest), so the whole framework is
inert until the `.win7` class is on <html> — written once by buildPreload
for the skin this window booted, and never changed after. All 61
icons are data URIs, so nothing needs copying into assets — and NOT the
`7.inline.css` build, which flattens away the --w7-* vars the dark palette
below depends on. The deep path is required: 7.css ships no `exports` map
and Tailwind's resolver only reads `style`/`mainFields`, so a bare
`@import "7.css"` finds no entry. */
@import "7.css/dist/7.scoped.css" layer(skin);
/* ==========================================================================
Windows 7 skin — buildPreload puts `.win7` on <html> at first paint
==========================================================================
Three tiers, and the split matters:
- `@layer skin` (the 7.css import at the top of this file) owns the
CONTROLS: buttons, inputs, tabs, progress bars, tree views, the Aero
title bar. It sits ABOVE `utilities`, so a bare `button` rule in 7.css
beats `.rounded-md` on that same button. That inversion is the whole
trick — it restyles ~1900 inline utility classes with no template edits.
- This unlayered section owns the BRIDGE: it re-points the app's own
semantic tokens at Win7 values, so utilities Tailwind still controls
(bg-card, border-border, text-muted-foreground) also come out
Win7-correct. It adds NO new tokens on purpose — a new colour token
would need a matching `--color-*` line in `@theme inline`.
- `@layer skinfix` (last, above `skin`) hands individual properties BACK to
the utilities layer with `revert-layer`, for the places where 7.css's
Win7-dialog metrics are wrong for this app.
WARNING for future edits: because `skin` outranks `utilities`, any Tailwind
utility added later to a <button>, <input>, <select>, <textarea> or <table>
silently loses under this skin. `skinfix` is the only place to fix that.
Specificity notes — the blocks below must beat the token blocks above:
:root.win7 (0,2,0) beats base :root (0,1,0)
:root.win7[data-theme='dark'] (0,3,0) beats :root[data-theme=…] (0,2,0)
and ties the darker-tone block (0,3,0),
winning on source order (it is last).
The accent formula blocks are deliberately NOT overridden: they only set
--primary/--ring/--cursor-tint, and letting them through is what makes the
accent picker recolour the Aero chrome — authentic, Win7 shipped exactly
that ("Window Color" personalization). */
/* Segoe UI is not on macOS/Linux and is not redistributable. Noto Sans is the
substitute 7.css itself names in its --w7-font fallback stack. Vendored
(OFL-1.1) from @fontsource/noto-sans@5.3.0, files/noto-sans-latin-{400,700}
-normal.woff2 -> ./fonts/, i.e. INSIDE this skin: the font exists only for
the Win7 look, and keeping it here means the url() below stays relative to
this file wherever the skin folder moves. (It used to sit at ui/src/fonts/
next to the old single stylesheet — splitting this file out rebased the
url() three directories deeper and would have silently 404'd the font.)
Vendored rather than @imported from node_modules because Tailwind inlines
imported CSS, which would leave @fontsource's relative url() rebased against
the wrong directory. They live under src/ (not public/) so esbuild emits them
as hashed assets and rewrites the url() — a public/ path is not resolvable at
build time. */
@font-face {
font-family: 'Noto Sans App';
font-style: normal;
font-weight: 400;
font-display: block;
src: url('./fonts/noto-sans-latin-400-normal.woff2') format('woff2');
}
@font-face {
font-family: 'Noto Sans App';
font-style: normal;
font-weight: 700;
font-display: block;
src: url('./fonts/noto-sans-latin-700-normal.woff2') format('woff2');
}
:root.win7 {
/* Real Segoe first (Windows), bundled substitute everywhere else. */
--w7-family: 'Segoe UI', 'SegoeUI', 'Noto Sans App', system-ui, sans-serif;
/* ~7.css's 9pt, in REM so it follows the interface scale (the root
font-size lever) like every other size in the app — 0.75rem = 12px at the
16px root. Restating the `font` SHORTHAND (not just the family) is
deliberate: 7.css applies `font: var(--w7-font)` on 15 component rules,
and the shorthand also resets weight and line-height — pinning 400/1.4
here stops those rules from flattening the app's `font-medium` / leading. */
--w7-fs: 0.75rem;
--w7-font: 400 var(--w7-fs) / 1.4 var(--w7-family);
/* Surface ramp, Win7 Explorer. This INVERTS the modern ramp's direction:
there --chrome (frame strips) sits below --background (content panes);
here the content pane is the brightest thing on screen and the frame is
the #f0f0f0 window surface. Deliberate — that is what Explorer was. */
--background: #ffffff;
--chrome: #f0f0f0;
--card: #f0f0f0;
--foreground: #000000;
--card-foreground: #000000;
--muted-foreground: #6d6d6d;
--secondary: #e8e8e8;
--secondary-foreground: #000000;
--muted: #e8e8e8;
/* Win7's hover is a pale blue wash, not a grey — matches --w7-el-grad-h */
--accent: #eaf6fd;
--accent-foreground: #000000;
--border: #d5d5d5;
--input: #abadb3; /* 7.css's own text-input border */
--destructive: #c62828;
--destructive-foreground: #ffffff;
--success: #06b025; /* the Win7 progress-bar green */
/* 0.3125rem = 5px at the 16px root — NOT 3px: `rounded-md` is 74 of the 112
radius utilities in the templates and compiles to
calc(var(--radius) - 0.125rem), so 5 lands it exactly on --w7-el-bdr (3px).
--radius itself feeds `rounded-lg` (12 uses) at 5px, near the 6px window
radius. Bare `rounded` (26 uses) is a Tailwind literal 0.25rem. Rem so the
corners follow the interface scale with everything else. */
--radius: 0.3125rem;
}
/* Light values for every 7.css literal the skinfix rules below restate through
vars (7.css hardcodes these outside its var system). The values are 7.css's
own, verbatim — light must render exactly as stock. Dark overrides live in
the duplicated dark blocks above, which is the house pattern: rules are
written ONCE and theme through tokens. (The previous attribute-keyed dark
rules silently missed SYSTEM-dark — the media-query path sets no attribute.) */
:root.win7 {
/* ListView (listview.css) */
--w7-lv-bg: #fff;
--w7-lv-bd: #c0c1cd;
--w7-lv-c: #000;
--w7-lv-head-grad: linear-gradient(180deg, #fff 45%, #fafafa 0, #f0f0f0);
--w7-lv-head-bd: #d7d7d7;
--w7-lv-cell-bd: #eee;
/* Selected row. Explorer painted a FLAT blue, not a gradient — 7.css reuses
its Aero element-hover gradient for the band, which is what made the list
look glossy. These are dedicated tokens on purpose: --w7-el-grad-h is
consumed by buttons and tabs and must keep its gradient.
`-idle` is the unfocused pane's band. Explorer greyed the selection out
when the list lost focus rather than dropping it, which is exactly the
commander convention this app already follows (see the pane-focus model
below): the band never disappears, only its strength changes. */
--w7-sel-bg: #cbe8f6;
--w7-sel-bg-idle: #dedede;
/* Tabs (tabs.css). 7.css hardcodes `color: #222` on every tablist button,
outside its own var system, which is unreadable on the dark surface. */
--w7-tab-c: #222;
/* Menu (menu.css) */
--w7-mn-c: #000;
--w7-mn-bd: #0006;
--w7-mn-stripe: inset 1px 0 #00000026, inset -1px 0 #fff;
--w7-mn-divider: inset 0 1px #00000026, inset 0 -1px #fff;
--w7-mn-check-ring: #b3d3f9;
--w7-mn-check-c: #0c12a1;
/* Glass caption streaks (window.css .window.glass>.title-bar): a literal.
The two #fff5 corner shines wash a DARK caption silver, so dark subdues
every white in the stack while keeping the streak structure. */
--w7-glass-title: linear-gradient(135deg, #fff5 70px, transparent 100px), linear-gradient(225deg, #fff5 70px, transparent 100px), linear-gradient(54deg, #0002 0 4%, #6661 6% 6%, #0002 8% 10%, #0002 15% 16%, #aaa1 17% 18%, #0002 23% 24%, #bbb2 25% 26%, #0002 31% 33%, #0002 34% 34.5%, #bbb2 36% 40%, #0002 41% 41.5%, #bbb2 44% 45%, #bbb2 46% 47%, #0002 48% 49%, #0002 50% 50.5%, #0002 56% 56.5%, #bbb2 57% 63%, #0002 67% 69%, #bbb2 69.5% 70%, #0002 73.5% 74%, #bbb2 74.5% 79%, #0002 80% 84%, #aaa2 85% 86%, #0002 87%, #bbb1 90%) 0 / 100vw 100vh no-repeat fixed;
/* Caption text (window.css .title-bar-text: black + 8-fold white halo) */
--w7-cap-c: #000;
--w7-cap-glow: 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff,
0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff;
}
/* Hand-authored dark Win7. Microsoft never shipped one — this exists so the
theme and tone settings stay live under the skin. Only the colour-carrying
--w7-* vars are listed: --w7-el-grad and --w7-w-grad are DERIVED from the
values below and recolour for free, while --w7-el-grad-h/-a and
--w7-li-bg-hl are literal hex in 7.css and so must be restated. The ~25
geometry vars and ~20 base64 icon vars are theme-independent.
DUPLICATED for forced-dark and system-dark, per the rule the token blocks
above set: every dark value goes in BOTH. */
:root.win7[data-theme='dark'] {
--w7-surface: #2b2b2b;
--w7-el-bg: #3a3a3a;
--w7-el-bg-d: #333333;
--w7-el-bg-s-1: #333333;
--w7-el-bg-s-2: #262626;
--w7-el-bd: #565656;
--w7-el-bd-h: #4a8cbe;
--w7-el-bd-a: #3f6c8c;
--w7-el-bd-d: #3f3f3f;
--w7-el-c: #f0f0f0;
--w7-el-c-d: #6f6f6f;
--w7-el-sd: inset 0 0 0 1px #ffffff14;
--w7-el-grad-h: linear-gradient(#3c5a6b 45%, #315062 45%, #2b4557);
--w7-el-grad-a: linear-gradient(#2c4657, #24384a 30% 50%, #1d2f3f 50%, #16242f);
--w7-li-bd-hl: #3f6c8c;
--w7-li-bg-hl: linear-gradient(#ffffff14, #4a6b8433 90%, #ffffff1a);
--w7-w-bd: #000000e6;
/* Ares-style black glass: near-black ground; 7.css's own alpha streaks and
highlight gradients become the gloss. Blue lives in accents (selection,
active tab), not in the glass itself. */
--w7-w-bg: #101013;
--w7-tab-bg: #1c1c1e;
--w7-title-c: #f5f5f5;
--w7-link-c: #6fb3ff;
/* literal restatements consumed by the skinfix rules (see :root.win7) */
--w7-lv-bg: var(--w7-el-bg-s-2);
--w7-lv-bd: var(--w7-el-bd-d);
--w7-lv-c: var(--w7-el-c);
--w7-lv-head-grad: linear-gradient(180deg, var(--w7-el-bg) 45%, var(--w7-el-bg-d) 0, var(--w7-el-bg-s-1));
--w7-lv-head-bd: var(--w7-el-bd-d);
--w7-lv-cell-bd: var(--w7-el-bd-d);
--w7-sel-bg: #2a4a63;
--w7-sel-bg-idle: #3a3a3a;
--w7-tab-c: var(--w7-el-c);
--w7-mn-c: var(--w7-el-c);
--w7-mn-bd: var(--w7-el-bd-d);
--w7-mn-stripe: inset 1px 0 #00000059, inset -1px 0 #ffffff14;
--w7-mn-divider: inset 0 1px #00000059, inset 0 -1px #ffffff14;
--w7-mn-check-ring: var(--w7-li-bd-hl);
--w7-mn-check-c: var(--w7-el-c);
--w7-glass-title: linear-gradient(135deg, #ffffff1a 70px, transparent 100px), linear-gradient(225deg, #ffffff1a 70px, transparent 100px), linear-gradient(54deg, #0003 0 4%, #ffffff08 6% 6%, #0003 8% 10%, #0003 15% 16%, #ffffff0d 17% 18%, #0003 23% 24%, #ffffff10 25% 26%, #0003 31% 33%, #0003 34% 34.5%, #ffffff10 36% 40%, #0003 41% 41.5%, #ffffff10 44% 45%, #ffffff10 46% 47%, #0003 48% 49%, #0003 50% 50.5%, #0003 56% 56.5%, #ffffff10 57% 63%, #0003 67% 69%, #ffffff10 69.5% 70%, #0003 73.5% 74%, #ffffff10 74.5% 79%, #0003 80% 84%, #ffffff0d 85% 86%, #0003 87%, #ffffff0a 90%) 0 / 100vw 100vh no-repeat fixed;
/* Ares: white caption on black glass, dark halo instead of white */
--w7-cap-c: var(--w7-title-c);
--w7-cap-glow: 0 0 8px #000c, 0 0 8px #000c, 0 1px 2px #000;
--w7-link-c-h: #9acbff;
--background: #1f1f1f;
--chrome: #2b2b2b;
--card: #2b2b2b;
--foreground: #f0f0f0;
--card-foreground: #f0f0f0;
--muted-foreground: #a0a0a0;
--secondary: #383838;
--secondary-foreground: #f0f0f0;
--muted: #383838;
--accent: #2f4a5c;
--accent-foreground: #f0f0f0;
--border: #3c3c3c;
--input: #55585c;
}
@media (prefers-color-scheme: dark) {
:root.win7:not([data-theme='light']) {
--w7-surface: #2b2b2b;
--w7-el-bg: #3a3a3a;
--w7-el-bg-d: #333333;
--w7-el-bg-s-1: #333333;
--w7-el-bg-s-2: #262626;
--w7-el-bd: #565656;
--w7-el-bd-h: #4a8cbe;
--w7-el-bd-a: #3f6c8c;
--w7-el-bd-d: #3f3f3f;
--w7-el-c: #f0f0f0;
--w7-el-c-d: #6f6f6f;
--w7-el-sd: inset 0 0 0 1px #ffffff14;
--w7-el-grad-h: linear-gradient(#3c5a6b 45%, #315062 45%, #2b4557);
--w7-el-grad-a: linear-gradient(#2c4657, #24384a 30% 50%, #1d2f3f 50%, #16242f);
--w7-li-bd-hl: #3f6c8c;
--w7-li-bg-hl: linear-gradient(#ffffff14, #4a6b8433 90%, #ffffff1a);
--w7-w-bd: #000000e6;
--w7-w-bg: #101013;
--w7-tab-bg: #1c1c1e;
--w7-title-c: #f5f5f5;
--w7-link-c: #6fb3ff;
/* literal restatements consumed by the skinfix rules (see :root.win7) */
--w7-lv-bg: var(--w7-el-bg-s-2);
--w7-lv-bd: var(--w7-el-bd-d);
--w7-lv-c: var(--w7-el-c);
--w7-lv-head-grad: linear-gradient(180deg, var(--w7-el-bg) 45%, var(--w7-el-bg-d) 0, var(--w7-el-bg-s-1));
--w7-lv-head-bd: var(--w7-el-bd-d);
--w7-lv-cell-bd: var(--w7-el-bd-d);
--w7-sel-bg: #2a4a63;
--w7-sel-bg-idle: #3a3a3a;
--w7-tab-c: var(--w7-el-c);
--w7-mn-c: var(--w7-el-c);
--w7-mn-bd: var(--w7-el-bd-d);
--w7-mn-stripe: inset 1px 0 #00000059, inset -1px 0 #ffffff14;
--w7-mn-divider: inset 0 1px #00000059, inset 0 -1px #ffffff14;
--w7-mn-check-ring: var(--w7-li-bd-hl);
--w7-mn-check-c: var(--w7-el-c);
--w7-glass-title: linear-gradient(135deg, #ffffff1a 70px, transparent 100px), linear-gradient(225deg, #ffffff1a 70px, transparent 100px), linear-gradient(54deg, #0003 0 4%, #ffffff08 6% 6%, #0003 8% 10%, #0003 15% 16%, #ffffff0d 17% 18%, #0003 23% 24%, #ffffff10 25% 26%, #0003 31% 33%, #0003 34% 34.5%, #ffffff10 36% 40%, #0003 41% 41.5%, #ffffff10 44% 45%, #ffffff10 46% 47%, #0003 48% 49%, #0003 50% 50.5%, #0003 56% 56.5%, #ffffff10 57% 63%, #0003 67% 69%, #ffffff10 69.5% 70%, #0003 73.5% 74%, #ffffff10 74.5% 79%, #0003 80% 84%, #ffffff0d 85% 86%, #0003 87%, #ffffff0a 90%) 0 / 100vw 100vh no-repeat fixed;
/* Ares: white caption on black glass, dark halo instead of white */
--w7-cap-c: var(--w7-title-c);
--w7-cap-glow: 0 0 8px #000c, 0 0 8px #000c, 0 1px 2px #000;
--w7-link-c-h: #9acbff;
--background: #1f1f1f;
--chrome: #2b2b2b;
--card: #2b2b2b;
--foreground: #f0f0f0;
--card-foreground: #f0f0f0;
--muted-foreground: #a0a0a0;
--secondary: #383838;
--secondary-foreground: #f0f0f0;
--muted: #383838;
--accent: #2f4a5c;
--accent-foreground: #f0f0f0;
--border: #3c3c3c;
--input: #55585c;
}
}
/* Dark tone "darker": Aero has no meaningful true black, so rather than leave
the Tone row a dead control the skin just goes a step deeper. Duplicated
forced + system like everything else. */
:root.win7[data-theme='dark'][data-dark-tone='darker'] {
--w7-surface: #1a1a1a;
--w7-el-bg: #262626;
--w7-w-bg: #060607;
--background: #121212;
--chrome: #1a1a1a;
--card: #1a1a1a;
}
@media (prefers-color-scheme: dark) {
:root.win7:not([data-theme='light'])[data-dark-tone='darker'] {
--w7-surface: #1a1a1a;
--w7-el-bg: #262626;
--w7-w-bg: #060607;
--background: #121212;
--chrome: #1a1a1a;
--card: #1a1a1a;
}
}
/* Accent -> Aero window colour, so the existing accent picker keeps meaning
under the skin. --w7-w-bg is the one colour 7.css documents as settable.
LIGHT ONLY: in dark the glass is Ares black regardless of accent (the accent
keeps tinting selection etc. through --primary). These arms out-rank the
accent rule below on specificity — without them it wins the tie on source
order and dark glass turns accent-coloured. */
:root.win7[data-accent] {
--w7-w-bg: var(--primary);
}
:root.win7[data-theme='dark'][data-accent] {
--w7-w-bg: #101013;
}
:root.win7[data-theme='dark'][data-dark-tone='darker'][data-accent] {
--w7-w-bg: #060607;
}
@media (prefers-color-scheme: dark) {
:root.win7:not([data-theme='light'])[data-accent] {
--w7-w-bg: #101013;
}
:root.win7:not([data-theme='light'])[data-dark-tone='darker'][data-accent] {
--w7-w-bg: #060607;
}
}
/* Unlayered on purpose: the html/body rule above sets font-family unlayered,
so a layered override would lose. Family only — NOT font-size: the app's
rem-based utilities are root-relative, and shifting the root would move
every line box for no gain. */
.win7 body {
font-family: var(--w7-family);
}
/* The win7 window is a FRAMELESS, natively transparent window whose only
opaque surface is meant to be `.window` — that is the whole premise of the
6px rounded frame, since the area outside the radius must let the desktop
through. The unlayered `html, body { background: var(--background) }` above
broke that premise silently: it painted an opaque rectangle behind the
frame, so the rounded corners had an opaque ground filling them in and the
window read as a rectangle with a drawn-on curve.
Unlayered on purpose, same reason as the font-family rule: the html/body
background it overrides is itself unlayered, and unlayered declarations beat
every @layer regardless of specificity — a `skinfix` rule could not touch
it. Scoped to .win7 so the modern skin keeps its opaque ground. */
html.win7,
html.win7 body {
background: transparent;
}
/* Neutralizers live in `skin`, NOT `skinfix`, and this is load-bearing:
`revert-layer` rolls a property back to the previous LAYER, so from inside
`skin` it lands on `utilities` (the Tailwind classes already in the
templates) — which is the whole point. From `skinfix` it would land back on
7.css and do nothing. Within `skin` these beat the imported rules on equal
specificity by source order, since the import is at the top of this file. */
@layer skin {
/* 7.css sizes every <button> like a Win7 dialog push button
(min-width 75px, min-height 23px, padding 0 12px). Most of this app's 84
buttons are ~24px icon squares sized by utilities, so without this the tab
bar wraps and the options popover overflows. Hand the BOX MODEL back to
the utilities layer and keep 7.css's paint. The title bar's own controls
are matched by a MORE specific 7.css selector (.title-bar-controls button)
and so keep their Aero glyph metrics untouched. */
.win7 button {
min-width: 0;
min-height: 0;
padding: revert-layer;
text-align: revert-layer;
color: revert-layer;
}
/* 7.css hardcodes #fff / #abadb3 / #222 on form controls and tables, outside
its own var system. Reverting routes them back through the bridged tokens
above, which is what makes dark mode nearly free. */
.win7 input[type='text'],
.win7 input[type='password'],
.win7 input[type='number'],
.win7 input[type='search'],
.win7 textarea {
background-color: revert-layer;
border-color: revert-layer;
}
.win7 table {
background-color: revert-layer;
color: revert-layer;
}
/* Win7 toolbar buttons are FLAT with a hover glow, not Aero slabs —
Explorer's command bar worked exactly that way. `w7-toolbar` marks the
three header rows; a plain `.title-bar` sibling is deliberately NOT one of
them, so its controls keep the real Aero treatment. `w7-flat` is the
per-button escape hatch. Both classes are inert under the modern skin.
`:not([role='tab'])` is load-bearing: the workspace strip IS a w7-toolbar,
so without the exemption this rule would strip the border, gradient and
both pseudo-elements off every tab and leave 7.css's tab component drawing
nothing. Tabs want the full Aero slab; the +/Connections/options buttons
beside them want the flat treatment.
Only `background` stays here — it is the one property that needs to land
on `utilities` via revert-layer. The literals that suppress the Aero slab
moved to `skinfix`, because 7.css restates border-color at
`.win7 button:not(:disabled):not(:hover)` = (0,3,1), which outranks
`.win7 .w7-flat` = (0,2,0). The toolbar arm only ever won that fight by
tying on specificity and landing later in the same layer — an accident,
and one w7-flat did not share: it rendered a bordered slab regardless. */
.win7 .w7-toolbar button:not([role='tab']),
.win7 .w7-flat {
background: revert-layer;
}
.win7 .w7-toolbar button:not([role='tab'])::before,
.win7 .w7-toolbar button:not([role='tab'])::after,
.win7 .w7-flat::before,
.win7 .w7-flat::after {
content: none;
}
}
/* Above `skin`, for rules that must state a value rather than defer to a
neighbouring layer. */
@layer skinfix {
/* ---- Scale bridge: re-express 7.css's px metrics in rem ----
7.css sizes everything in px (370 of them, zero rem) and its --w7-font
shorthand reaches only 15 rules' TYPOGRAPHY — the boxes stay frozen. Under
the interface scale (root font-size lever) win7 text would grow inside
23px buttons, 14px table rows and a 15px-line-height caption. Every value
below is 7.css's own, converted at the 16px root (23px -> 1.4375rem …),
so scale 1.0 renders pixel-identical to stock and every other scale keeps
the boxes proportional to the text.
Deliberately NOT converted: 1px hairlines and focus rects (the tr border
that stops selection shifting rows, .is-cursor's 1px dotted outline, menu
bevels, the frame's inset glass ring) — those stay crisp at any scale. */
/* 7.css's own size vars — defined on .win7 in px; rem lands on the same
pixels at scale 1. Covers checkbox/radio/slider/tooltip/treeview/menu
gutters and both window metrics in one shot. */
.win7 {
--w7-cb-size: 0.875rem; /* 14px */
--w7-rd-size: 0.875rem;
--w7-rdd-size: 0.5rem; /* 8px */
--w7-rdl-space: 0.375rem; /* 6px */
--w7-sdt-w: 0.625rem; /* 10px */
--w7-sdt-h: 1.125rem; /* 18px */
--w7-blt-size: 1.125rem;
--w7-tvb-size: 0.5rem;
--w7-tv-left: 1.25rem; /* 20px */
--w7-clc-size: 0.3125rem; /* 5px */
--w7-lvc-size: 0.3125rem;
--w7-mn-left: 1.75rem; /* 28px */
--w7-el-bdr: 0.1875rem; /* 3px */
--w7-w-space: 0.375rem; /* 6px */
--w7-w-bdr: 0.375rem;
}
/* The hardcoded box metrics the vars do not reach, restated in rem; skinfix
outranks skin by layer, so these win over 7.css.
NO blanket `.win7 button` rule here, deliberately: the `skin`-layer
neutralizer strips 7.css's 75x23 metrics off every button (most are icon
squares sized by utilities), and a skinfix restatement would outrank that
neutralizer by LAYER and re-inflate all of them. The only buttons that
legitimately wear Win7 push-button metrics are .w7-btn (opted in below)
and the title-bar controls (which beat the neutralizer on specificity
inside `skin` and are restated here). */
.win7 input[type='email'],
.win7 input[type='number'],
.win7 input[type='password'],
.win7 input[type='search'],
.win7 input[type='text'],
.win7 input[type='url'] {
height: 1.4375rem;
}
.win7 input[type='email'],
.win7 input[type='number'],
.win7 input[type='password'],
.win7 input[type='search'],
.win7 input[type='text'],
.win7 input[type='url'],
.win7 textarea {
padding: 0.1875rem 0.25rem 0.3125rem; /* 3px 4px 5px */
}
.win7 table > thead > tr > * {
height: 1.375rem; /* 22px */
padding: 0 0.5rem; /* 0 8px */
}
.win7 table > tbody > tr > * {
height: 0.875rem; /* 14px */
padding: 0.125rem 0.5rem; /* 2px 8px */
}
.win7 .title-bar-text {
line-height: 0.9375rem; /* 15px */
}
.win7 .title-bar-controls button {
min-height: 1.1875rem; /* 19px */
min-width: 1.8125rem; /* 29px */
padding: 0;
}
/* BOTH of 7.css's wide-close selectors, not just .is-close: the app's title
bars key the buttons on aria-label, and the base rule above outranks the
`skin`-layer variant BY LAYER — restating only one arm shrank the close
button from 48px to 29px (caught by the harness pixel diff). */
.win7 .title-bar-controls button.is-close,
.win7 .title-bar-controls button[aria-label='Close'] {
min-width: 3rem; /* 48px */
}
.win7 ul[role='menu'] {
min-width: 9.375rem; /* 150px */
padding: 0.125rem; /* 2px */
}
.win7 ul[role='menu'] > [role='menuitem'] > a,
.win7 ul[role='menu'] > [role='menuitem'] > button,
.win7 ul[role='menu'] > [role='menuitem'] > label {
padding: 0.25rem 0.625rem 0.25rem 2rem; /* 4px 10px 4px 32px */
}
.win7 menu[role='tablist'] button {
padding: 0.125rem 0.375rem; /* 2px 6px */
}
/* The raised active tab: 7.css gives it padding-bottom 4px at (0,2,2) inside
`skin`, but the shorthand above outranks that BY LAYER and would flatten
the raise — so the variant is restated here too. */
.win7 menu[role='tablist'] button[aria-selected='true'] {
padding-bottom: 0.25rem; /* 4px */
}
/* The 10pt checkmark glyph in menu checkables — one of five pt sizes in
7.css that bypass --w7-font entirely. 10pt = 13.333px ≈ 0.8333rem. */
.win7 ul [role='menuitem'] > input[type='checkbox']:checked + label:after {
font-size: 0.8333rem;
}
/* Opt back IN to real Win7 push-button metrics, for the dialog and options
buttons where they are right. Same rem values as the button rule above —
this class exists to undo the `skin`-layer neutralizer, not to differ. */
.win7 .w7-btn {
min-width: 4.6875rem;
min-height: 1.4375rem;
padding: 0 0.75rem;
}
/* ---- ListView (file panels) ----
7.css's listview.css hardcodes light literals (#fff, #c0c1cd, #eee and the
white header gradient) instead of --w7-* vars, so unlike the rest of the
skin it does NOT follow the hand-authored dark palette — the header labels
came out invisible, dark on dark. These restate the same rules in dark
terms; light keeps 7.css's own values untouched.
CRITICAL: the base surface goes on `table`, NEVER on `tbody > tr > *`.
7.css paints the selection band on the ROW, and a cell background paints
over a row background — setting one erased the selection highlight
entirely in dark mode. */
.win7 table {
background-color: var(--w7-lv-bg);
border-color: var(--w7-lv-bd);
color: var(--w7-lv-c);
}
.win7 table > thead > tr > * {
background: var(--w7-lv-head-grad);
border-color: var(--w7-lv-head-bd);
color: var(--w7-lv-c);
}
.win7 table > tbody > tr > :not(:last-child) {
border-right-color: var(--w7-lv-cell-bd);
}
/* 7.css drops the cell separators inside a selected row; the rule above is
more specific than its, so restore that behaviour explicitly. */
.win7 table > tbody > tr.highlighted > :not(:last-child) {
border-right: none;
}
.win7 table > tbody > tr.highlighted {
background: var(--w7-sel-bg-idle);
border-color: transparent;
}
/* ---- Window frame ----
The native window is transparent (chromeOptions in src/bun/windows.ts), and
nothing but .window may paint an opaque surface — that is what lets the 6px
corner radius actually be transparent at the corners. That invariant is NOT
free: html/body paint an opaque background unlayered, and had to be reset
to transparent for .win7 (see the rule above @layer skin). Anything else
that paints full-bleed will fill the corners the same way.
NO drop shadow, by request: 7.css's `2px 2px 10px 1px #000000b3` needed a
margin gutter to fall into and still read heavy against a real desktop, so
the frame keeps only the inset 1px glass highlight from that shorthand
(dropping the whole box-shadow would lose the highlight too — it lives in
the same declaration). No shadow -> no gutter -> full-bleed 100vh.
Keyed on `w7-frame` — the app hook the three window ROOTS carry — never on
bare .window: on any other .window (dialogs, demos) this sizing is
nonsense and 7.css's own shadow is fine. */
.win7 .window.w7-frame {
margin: 0;
height: 100vh;
box-shadow: inset 0 0 0 1px #fffa;
}
/* A maximized Win7 window has SQUARE corners — the radius only exists to let
the desktop show past a floating frame, and a rounded maximized window
leaves four notches of desktop in the screen corners.
Read off the title bar's own `is-maximized` (title-bar.ts publishes it on
its host) with :has(), so the frame reacts without a second windowState
subscription. .title-bar is a DIRECT child of .window — app-title-bar
carries the class on its host element, which is why `>` holds here.
The radius is declared three times in 7.css — on .window and on both the
::before tint and the ::after glass layer — so all three must be zeroed or
a rounded pseudo-element still cuts the corner off the square frame. */
.win7 .window.w7-frame:has(> .title-bar.is-maximized),
.win7 .window.w7-frame:has(> .title-bar.is-maximized)::before,
.win7 .window.w7-frame:has(> .title-bar.is-maximized)::after {
border-radius: 0;
}
/* STOPGAP — the synthesized resize handles (window-resize/window-resize.ts,
which carries the removal checklist). A maximized window has no edges to
grab, so the overlay goes away entirely while it is maximized, and comes
back on restore.
Read off the title bar's own `is-maximized` with :has(), exactly like the
corner-squaring rule above — that is what keeps this component free of a
second `windowState` subscription (RpcService's push registries have no
off(), so every subscription is permanent). */
.win7 .window.w7-frame:has(> .title-bar.is-maximized) .w7-resize {
display: none;
}
/* Hovering the close button paints 7.css's red glow ::before
(box-shadow: 0 0 7px 3px #e68e75), and box-shadows of CHILDREN are not
clipped by the parent's border or radius — the caption buttons sit flush
against the window's top edge (border-top: 0), so the glow spilled ABOVE
the frame: a red tint across the top border and a red halo floating on the
desktop past it (scripts/hover-probe.ts renders the forced-hover state and
pixel-scans for exactly this; it caught 100 red pixels above the edge).
Clip the title bar's children to its own box on top/left/right — the glow
now dies at the frame border, which is where the real Win7 DWM stopped it
too — while the bottom stays open (-1rem) so the glow's bleed toward the
caption below survives; that part is authentic.
clip-path also clips HIT-TESTING, but only for content outside the region:
the title-bar's own box (the drag region) is entirely inside it.
Scoped to w7-frame: demo/harness windows keep stock 7.css behaviour. */
.win7 .window.w7-frame > .title-bar {
clip-path: inset(0 0 -1rem 0);
}
/* 7.css's glass layer is `backdrop-filter: blur(4px)` on .window::after —
but CSS cannot sample the DESKTOP behind a transparent native window
(there is no backdrop to read), and WebKitGTK paints the unsampleable
layer BLACK: every see-through part of the glass — the frame ring, the
area around the title-bar buttons — composited over black instead of over
the desktop. With it off, the real desktop shows through the glass tint,
which is closer to Aero than a blur we cannot have. (True blur-behind
needs compositor support; GNOME offers none to X11/XWayland clients.) */
.win7 .window.glass::after {
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
/* Constant row geometry. 7.css gives ONLY tr.highlighted a 1px border, and
the table is border-collapse: collapse — so a selected row physically grew
1px at top and bottom and shoved every row below it down by 2px as the
cursor moved. Every row reserves that border instead, transparent when not
selected, so selecting changes colour and nothing else.
The border stays TRANSPARENT even when selected: it exists purely to
reserve space. 7.css outlines the selected row (#aaddfa) on top of its
fill, which stacked a second edge inside the band and, with the cursor
row's dotted rect, drew three concentric outlines around one row. The fill
paints under the transparent border (background-clip defaults to
border-box), so the band still covers the full row. */
.win7 table > tbody > tr {
border: 1px solid transparent;
}
/* Pane-focus model (commander convention, matching Win7 Explorer):
the CURSOR ROW always carries the selection band in BOTH panels —
tr.highlighted covers "selected via Space" AND "cursor row" (the template
ORs them). What changes with pane focus is the band's strength, never
whether it exists:
- focused pane -> the flat blue selection (below)
- unfocused pane -> the grey idle band, which is what Explorer drew
when the list lost focus — faint by design, not a bug.
The dotted focus rectangle is NOT the focus signal; it is layered on top
of the band in the focused pane only, the way Win7 drew the keyboard
focus rect over the selected item.
FLAT, and only a fill: 7.css bands the row with --w7-el-grad-h, its Aero
element-hover ramp, which reads as a glossy button rather than a file
selection, and outlines it as well. One solid colour, no edge — the
dotted cursor rect is then the only line on the row, which is what makes
it legible as the cursor. */
.win7 table.is-active > tbody > tr.highlighted {
background: var(--w7-sel-bg);
}
/* One dotted rectangle around the ROW; per cell (tr > *) draws a dotted box
around every cell instead — visibly wrong. */
.win7 table > tbody > tr.is-cursor {
outline: 1px dotted var(--w7-el-c);
outline-offset: -1px;
}
/* ---- Menu (context menu) ----
The menu itself is pure 7.css — ul[role=menu] > li[role=menuitem], see
ui/src/app/context-menu.ts for the exact contract. (A previous iteration
claimed "7.css has no menu component" and hand-built a lookalike here;
false — the markup was one level off and its selectors never matched.)
Only menu.css's hardcoded LIGHT literals need restating for the dark
palette, same as the ListView above: the black `color:initial`, the
white bevel halves of the gutter stripe and dividers, and the checked-box
ring (#b3d3f9). */
.win7 ul[role='menu'] {
color: var(--w7-mn-c);
border-color: var(--w7-mn-bd);
}
/* THE POSITIONING ONE — this is why right-click looked dead under the skin.
menu.css declares `position: relative` on ul[role=menu] (it needs it for
the gutter stripe's ::before and for submenu anchoring), and `skin`
outranks `utilities`, so Tailwind's `.fixed` on the app's menu lost. Its
[style.left/top.px] then became FLOW offsets from the menu's static
position — the last child of an h-screen flex column — and every menu
rendered below the viewport, unreachable behind `overflow: hidden`. The
backdrop is a plain <div>, which 7.css does not match, so it stayed truly
fixed and swallowed the next click: "right click does nothing, then the
app ignores me once".
Scoped to the app menu's own marker class, never blanket: 7.css's own
relative positioning must survive for any other ul[role=menu].
Regression test: scripts/menu-probe.ts (a resting pixel diff cannot see a
positioning bug — nothing about the idle window changes). */
.win7 ul[role='menu'].w7-ctx {
position: fixed;
}
.win7 ul[role='menu']:before {
box-shadow: var(--w7-mn-stripe);
}
.win7 ul [role='menuitem'].has-divider:after {
box-shadow: var(--w7-mn-divider);
}
.win7 ul [role='menuitem'] > input[type] + label:before {
box-shadow: 0 0 0 1px var(--w7-mn-check-ring);
}
.win7 ul [role='menuitem'] > input[type='checkbox']:checked + label:after {
color: var(--w7-mn-check-c);
}
/* Menu items: flat highlight, and no Aero afterglow trailing the pointer.
Menu items ARE <button>s, so button.css reaches them with three effects
that do not belong on a menu:
(1) the highlight is 7.css's translucent wash
`linear-gradient(#fff9, #e6ecf5cc 90%, #fffc)` + an #aaddfa edge — the
same gradient the ListView selection above just dropped;
(2) `button::before` is a blue glow overlay whose opacity fades out over
a FULL SECOND (`button:not(:hover):before { transition: opacity 1s
linear }`). menu.css sets `content: none` on the :HOVER arm only, so
the pseudo reappears the instant you leave an item and then fades 1→0
— a ghost dragging behind the pointer. Killing `content` in EVERY
state is what removes it; suppressing the transition alone would not,
because the glow itself is legitimate on real push buttons;
(3) `button:focus` runs `pulse-anim` infinitely, and this menu moves REAL
focus between items for keyboard nav — so a focused item would breathe.
Scoped to the app's own menu (w7-ctx): 7.css's demo menus keep stock Aero. */
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label {
transition: none;
animation: none;
}
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button::before,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button::after {
content: none;
}
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:hover,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:focus,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > button:focus-visible,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label:hover,
.win7 ul[role='menu'].w7-ctx > li[role='menuitem'] > label:focus-visible {
background: var(--w7-sel-bg);
border-color: transparent;
}
/* ---- Flat buttons (toolbars + the w7-flat escape hatch) ----
The paint-suppression half of the rule in `skin` (see there for the split).
It lives in `skinfix` because 7.css restates border-color at (0,3,1) via
`.win7 button:not(:disabled):not(:hover)` and again on :hover, which no
reasonable `.w7-flat` selector outranks — layer order settles it instead
of a specificity arms race. */
.win7 .w7-toolbar button:not([role='tab']),
.win7 .w7-flat {
border-color: transparent;
box-shadow: none;
}
/* ---- Tabs (workspace strip) ----
7.css owns the tab CHROME; these restore the tab CONTENT layout it breaks.
`menu[role=tablist] button` sets `display: block` at (0,2,1), which
outranks Tailwind's `.flex` in `utilities` — so the icon and the label
rendered jammed together with no gap and no truncation. Restate the flex
box here (skinfix outranks skin) and leave every painted property alone.
The gap matches the gap-1.5 the template asks for. */
.win7 menu[role='tablist'] button[role='tab'] {
display: flex;
align-items: center;
gap: 0.375rem;
}
/* tabs.css hardcodes `color: #222` on every tablist button — a light-mode
literal outside 7.css's var system, which left the labels near-invisible
on the dark surface. Same failure and same remedy as the ListView and menu
literals above: route it through a token that both dark blocks override. */
.win7 menu[role='tablist'] button {
color: var(--w7-tab-c);
}
/* The workspace strip scrolls horizontally when tabs overflow. Setting
`overflow-x: auto` forces the computed `overflow-y` from visible to auto
as well (CSS will not pair visible with a non-visible axis), so the strip
clips vertically — and 7.css raises the ACTIVE tab with `margin-top: -2px`,
which put its top border outside the scroll box and silently deleted it.
Reserve those 2px as padding so the pulled-up tab lands inside the box.
7.css already reserves the matching 3px on the left (padding-left: 3px)
for the same tab's -3px left margin; it just never anticipated a scroller.
Verified by pixel scan: the active tab's top edge must be #8e8f8f, not a
straight jump from the toolbar grey to the tab fill. */
.win7 .w7-toolbar menu[role='tablist'] {
padding-top: 2px;
}
.win7 .window.glass > .title-bar {
background: var(--w7-glass-title);
background-attachment: fixed;
}
/* Caption text: 7.css hardcodes black + a white halo; in dark that becomes
the Ares treatment (white caption, dark halo) through the same vars. */
.win7 .title-bar-text {
color: var(--w7-cap-c);
text-shadow: var(--w7-cap-glow);
}
}
-73
View File
@@ -1,73 +0,0 @@
<!-- .window.glass is 7.css's Aero frame (radius, 1px border, inset highlight,
drop shadow, backdrop blur); .active is what un-greys the title bar and its
control glyphs — every .title-bar-controls rule is keyed on it. -->
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
@if (!rpc.available) {
<div class="flex flex-1 items-center justify-center">
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ i18n.t('app.notConnectedBody') }}
</p>
</div>
</div>
} @else {
<!-- the window is frameless, so this IS the chrome -->
<app-title-bar [title]="appName" (closeRequested)="closeWindow()" />
<!-- 7.css's .window-body: the recessed inner panel, with the 6px inset and
its own border that give a Win7 window its depth. -->
<div class="window-body flex min-h-0 flex-1 flex-col overflow-hidden">
<app-tab-bar />
<main class="flex min-h-0 flex-1 divide-x divide-border">
<app-file-panel side="left" />
<app-file-panel side="right" />
</main>
<app-transfer-bar />
<app-status-bar />
</div>
}
<!-- modal block: the connection-manager window owns the app until closed -->
@if (managerOpen()) {
<div
class="fixed inset-0 z-60 flex items-center justify-center bg-black/40"
(mousedown)="focusManager()"
>
<div class="rounded-lg border border-border bg-card px-5 py-4 text-center shadow-xl">
<ng-icon name="mynaServers" size="1.25rem" class="text-muted-foreground" />
<p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p>
<button
class="mt-3 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{{ i18n.t('app.showConnections') }}
</button>
</div>
</div>
}
<!-- dialogs -->
@if (dialogs.prompt(); as req) {
<app-prompt-dialog [request]="req" />
}
@if (dialogs.confirm(); as req) {
<app-confirm-dialog [request]="req" />
}
@if (dialogs.chmod(); as req) {
<app-chmod-dialog [request]="req" />
}
@if (dialogs.overwrite(); as req) {
<app-overwrite-dialog [request]="req" />
}
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
own handles. Delete with the rest of the hack — checklist in
window-resize/window-resize.ts. -->
<app-window-resize />
<app-context-menu />
<app-toasts />
</div>
-307
View File
@@ -1,307 +0,0 @@
import { Component, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import { BRANDING } from '@shared/branding';
import type { ColumnKey } from '@shared/types';
import { ConnectionsService } from '@core/connections.service';
import { ContextMenu } from './context-menu';
import { ChmodDialog } from './dialogs/chmod-dialog';
import { ConfirmDialog } from './dialogs/confirm-dialog';
import { OverwriteDialog } from './dialogs/overwrite-dialog';
import { PromptDialog } from './dialogs/prompt-dialog';
import { DialogsService } from '@core/dialogs.service';
import { FileActionsService } from '@core/file-actions.service';
import { FilePanel } from './file-panel/file-panel';
import { PanelsService } from '@core/panels.service';
import { blockNativeContextMenu } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { StatusBar } from './status-bar/status-bar';
import { TabBar } from './tab-bar/tab-bar';
import { TitleBar } from './title-bar/title-bar';
import { ToastService } from '@core/toast.service';
import { Toasts } from './toasts/toasts';
import { TransferBar } from './transfer-bar/transfer-bar';
import { WindowResize } from './window-resize/window-resize';
import { TransfersService } from '@core/transfers.service';
import { UpdaterService } from '@core/updater.service';
@Component({
selector: 'app-root',
imports: [
NgIcon,
FilePanel,
TabBar,
StatusBar,
PromptDialog,
ConfirmDialog,
ChmodDialog,
OverwriteDialog,
Toasts,
TransferBar,
TitleBar,
ContextMenu,
WindowResize,
],
templateUrl: './app.html',
host: {
'(window:keydown)': 'onWindowKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)',
// Aero greys an inactive window's title bar and controls. The webview gets
// DOM focus/blur when the native window's focus changes, so this needs no
// RPC — see the `active` binding on .window in app.html.
'(window:focus)': 'windowFocused.set(true)',
'(window:blur)': 'windowFocused.set(false)',
},
})
export class App {
readonly i18n = inject(I18nService);
readonly rpc = inject(RpcService);
readonly toast = inject(ToastService);
readonly dialogs = inject(DialogsService);
readonly panels = inject(PanelsService);
readonly actions = inject(FileActionsService);
readonly connections = inject(ConnectionsService);
private readonly transfers = inject(TransfersService);
private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
private readonly updater = inject(UpdaterService);
/** The connection-manager window is open — this window is modal-blocked. */
readonly managerOpen = signal(false);
/** Drives `.window.active`: Aero desaturates an unfocused window's chrome. */
readonly windowFocused = signal(document.hasFocus());
/** Shown in the win7 title bar (no native titlebar to carry it). */
readonly appName = BRANDING.appName;
/**
* win7 title bar's X. Closing the main window quits, and the app-level
* before-quit guard (dirty editors + running transfers) still runs — it is
* the one close path electrobun lets us cancel.
*/
closeWindow(): void {
void this.rpc.request.windowClose({});
}
constructor() {
document.title = BRANDING.appName;
if (this.rpc.available) {
this.rpc.onMenuAction((action) => void this.handleMenuAction(action));
this.rpc.onManagerState((open) => this.managerOpen.set(open));
void this.promptForEditLeftovers();
}
}
focusManager(): void {
void this.rpc.request.openConnectionManager({});
}
blockContextMenu(event: Event): void {
blockNativeContextMenu(event);
}
/** Temp files from external edits in a previous run (crash / quit mid-edit). */
private async promptForEditLeftovers(): Promise<void> {
const res = await this.rpc.request.getEditLeftovers({});
if (!res.ok || !res.names.length) return;
const shown = res.names.slice(0, 5).join(', ');
const more =
res.names.length > 5 ? this.i18n.t('toasts.leftoverMore', { count: res.names.length - 5 }) : '';
this.dialogs.confirm.set({
title: this.i18n.t('toasts.leftoverTitle'),
body: this.i18n.t('toasts.leftoverBody', { count: res.names.length, names: shown + more }),
confirmLabel: this.i18n.t('toasts.leftoverClear'),
destructive: true,
onConfirm: () => void this.rpc.request.clearEditLeftovers({}),
});
}
/** A connection by id; reloads once if unseen (a manager-window save can
* race the connectionsChanged push). */
private async findConnection(id: string) {
const lookup = () => this.connections.connections().find((c) => c.id === id);
if (!lookup()) await this.connections.reload();
return lookup();
}
/** Native application-menu / manager-window actions forwarded from bun. */
private async handleMenuAction(action: string): Promise<void> {
if (action.startsWith('ctx-')) {
this.handleContextAction(action);
return;
}
if (action.startsWith('connect:')) {
const connection = await this.findConnection(action.slice('connect:'.length));
if (connection) {
// remote goes right, local stays left — the commander convention
void this.panels.connectPanel('right', connection, connection.name, connection.localDir);
}
return;
}
if (action.startsWith('col:')) {
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
return;
}
if (action.startsWith('tab:')) {
this.handleTabAction(action.slice('tab:'.length));
return;
}
if (action.startsWith('open-stack:')) {
const id = action.slice('open-stack:'.length);
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
if (!stackConnections.length) {
await this.connections.reload();
stackConnections = this.connections.connections().filter((c) => c.stackId === id);
}
if (stackConnections.length) this.panels.openStack(stackConnections);
return;
}
switch (action) {
case 'new-tab':
this.panels.newWorkspace();
break;
case 'close-tab':
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
break;
case 'new-folder':
this.actions.mkdir(this.panels.active());
break;
case 'new-file':
this.actions.createFile(this.panels.active());
break;
case 'edit-file':
void this.actions.edit(this.panels.active());
break;
case 'refresh-active':
void this.panels.active().refresh();
break;
case 'check-for-updates':
void this.updater.check();
break;
}
}
/** Window shortcuts — matching lives in ShortcutsService (one keymap for the
* app); this switch only maps ids onto actions. Panel-level keys stay on
* the panels themselves. */
onWindowKeydown(event: KeyboardEvent): void {
if (this.managerOpen()) return; // modal-blocked behind the manager
const match = this.shortcuts.resolve(event, 'main');
if (!match) return;
// scale keys are hostile mid-typing ('-' in a rename prompt must type '-');
// the tab shortcuts keep firing there, as they always did
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
event.preventDefault();
switch (match.id) {
case 'new-tab':
this.panels.newWorkspace();
break;
case 'close-tab':
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
break;
case 'cycle-tab':
this.panels.cycleWorkspace(1);
break;
case 'cycle-tab-back':
this.panels.cycleWorkspace(-1);
break;
case 'go-to-tab':
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1);
break;
case 'scale-up':
this.settings.setUiScale(this.settings.uiScale() + 0.1);
break;
case 'scale-down':
this.settings.setUiScale(this.settings.uiScale() - 0.1);
break;
case 'scale-reset':
this.settings.setUiScale(1);
break;
}
}
/** Context-menu actions apply to the row/panel remembered at right-click time. */
/**
* Workspace-tab menu actions. The target id travels INSIDE the action string
* because the right-clicked tab is not necessarily the active one — nothing
* here may fall back to activeWorkspaceId.
*/
private handleTabAction(action: string): void {
if (action === 'new') {
this.panels.newWorkspace();
return;
}
const parsed = /^([a-z-]+):(\d+)$/.exec(action);
if (!parsed) return;
const id = Number(parsed[2]);
switch (parsed[1]) {
case 'close':
this.panels.closeWorkspace(id);
break;
case 'close-others':
this.panels.closeOtherWorkspaces(id);
break;
case 'close-right':
this.panels.closeWorkspacesToRight(id);
break;
case 'disconnect':
void this.panels.disconnectWorkspace(id);
break;
}
}
private handleContextAction(action: string): void {
const target = this.actions.menuTarget;
if (!target) return;
const { panel, side, entry } = target;
// the empty-space menu acts on the directory itself
const path = entry?.path ?? panel.cwd();
switch (action) {
case 'ctx-open':
if (entry) panel.open(entry);
break;
// local shell integration — only bun can reach the OS shell/clipboard
case 'ctx-reveal':
void this.rpc.request.shellItemAction({ action: 'reveal', path });
break;
case 'ctx-open-default':
void this.rpc.request.shellItemAction({ action: 'open', path });
break;
case 'ctx-copy-path':
void this.rpc.request.shellItemAction({ action: 'copyPath', path });
break;
case 'ctx-edit':
void this.actions.edit(panel);
break;
case 'ctx-copy':
void this.transfers.copyToOtherPanel(side);
break;
case 'ctx-rename':
this.actions.rename(panel);
break;
case 'ctx-chmod':
this.actions.chmod(panel);
break;
case 'ctx-delete':
this.actions.delete(panel);
break;
case 'ctx-trash':
if (entry) void this.actions.trash(panel, entry);
break;
case 'ctx-new-folder':
this.actions.mkdir(panel);
break;
case 'ctx-new-file':
this.actions.createFile(panel);
break;
case 'ctx-refresh':
void panel.refresh();
break;
}
}
}
@@ -1,390 +0,0 @@
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
<!-- Frameless window. Fixed-size modal, so Close only. -->
<app-title-bar
[title]="i18n.t('connMgr.title')"
[resizable]="false"
(closeRequested)="close()"
/>
<!-- Toolbar strip under the title bar. h-9/pl-3 are load-bearing: without a
height this row collapses onto the icon and the screen reads as broken. -->
<header
class="w7-toolbar flex h-9 shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2 pl-3"
>
<ng-icon name="mynaServers" size="0.95rem" class="shrink-0 text-muted-foreground" />
<h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1>
<div class="flex-1"></div>
<button
(click)="importWinScp()"
[disabled]="importing()"
[title]="i18n.t('connMgr.importWinscpHint')"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50"
>
@if (importing()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
} @else {
<!-- <ng-icon name="mynaArchive" size="0.85rem" /> -->
}
{{ i18n.t('connMgr.importWinscp') }}
</button>
</header>
<div class="flex min-h-0 flex-1">
<!-- stacks + connections list -->
<div class="flex w-72 shrink-0 flex-col border-r border-border bg-chrome">
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
<button
(click)="newConnection()"
[title]="i18n.t('connMgr.newConnectionHint')"
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaPlus" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }}
</button>
<button
(click)="newStack()"
[title]="i18n.t('connMgr.newStackHint')"
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFolderPlus" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }}
</button>
</div>
<div class="has-scrollbar flex-1 overflow-y-auto py-1">
<!-- stacks -->
@for (group of store.grouped().stacks; track group.stack.id) {
<div
(dragover)="onDragOver($event, group.stack.id)"
(dragleave)="dropTarget() === group.stack.id && dropTarget.set(null)"
(drop)="onDrop($event, group.stack.id)"
class="rounded-md"
[class.bg-primary/10]="dropTarget() === group.stack.id"
[class.ring-1]="dropTarget() === group.stack.id"
[class.ring-inset]="dropTarget() === group.stack.id"
[class.ring-primary]="dropTarget() === group.stack.id"
>
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
<button
(click)="toggleStack(group.stack)"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground"
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
>
<ng-icon
[name]="group.stack.collapsed ? 'mynaChevronRight' : 'mynaChevronDown'"
size="0.8rem"
/>
</button>
<span class="truncate text-[0.6875rem] font-medium text-muted-foreground uppercase">
{{ group.stack.name }}
</span>
<div class="min-w-0 flex-1"></div>
<!-- count and actions swap on hover: they never share the space,
so a long stack name can't collide with the buttons -->
<span
class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-hover:hidden"
>
{{ group.connections.length }}
</span>
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
<button
(click)="renameStack(group.stack)"
[title]="i18n.t('connMgr.renameStack')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-foreground"
>
<ng-icon name="mynaEdit" size="0.75rem" />
</button>
<button
(click)="deleteStack(group.stack)"
[title]="i18n.t('connMgr.deleteStack')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-destructive"
>
<ng-icon name="mynaTrash" size="0.75rem" />
</button>
@if (group.connections.length) {
<button
(click)="openStack(group.stack.id)"
[title]="i18n.t('connMgr.openStack')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-primary"
>
<ng-icon name="mynaArrowRight" size="0.8rem" />
</button>
}
</div>
</div>
@if (!group.stack.collapsed) {
@for (connection of group.connections; track connection.id) {
<button
(click)="edit(connection)"
draggable="true"
(dragstart)="onDragStart($event, connection)"
(dragend)="onDragEnd()"
class="flex w-full items-center gap-1.5 py-1.5 pr-3 pl-6 text-left text-xs hover:bg-accent"
[class.bg-accent]="draft()?.id === connection.id"
[class.opacity-50]="draggedId() === connection.id"
>
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span>
</button>
} @empty {
<div class="py-1 pl-6 text-[0.6875rem] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div>
}
}
</div>
}
<!-- loose connections -->
<div
(dragover)="onDragOver($event, 'loose')"
(dragleave)="dropTarget() === 'loose' && dropTarget.set(null)"
(drop)="onDrop($event, null)"
class="min-h-8 rounded-md pb-1"
[class.bg-primary/10]="dropTarget() === 'loose'"
[class.ring-1]="dropTarget() === 'loose'"
[class.ring-inset]="dropTarget() === 'loose'"
[class.ring-primary]="dropTarget() === 'loose'"
>
@if (store.grouped().stacks.length && store.grouped().loose.length) {
<div class="px-2 pt-2 pb-0.5 text-[0.6875rem] font-medium text-muted-foreground uppercase">
{{ i18n.t('connMgr.connectionsHeader') }}
</div>
}
@for (connection of store.grouped().loose; track connection.id) {
<button
(click)="edit(connection)"
draggable="true"
(dragstart)="onDragStart($event, connection)"
(dragend)="onDragEnd()"
class="flex w-full items-center gap-1.5 px-3 py-1.5 text-left text-xs hover:bg-accent"
[class.bg-accent]="draft()?.id === connection.id"
[class.opacity-50]="draggedId() === connection.id"
>
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span>
</button>
}
</div>
@if (!store.connections().length && !store.stacks().length) {
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('connMgr.noneTitle') }}
</p>
<p class="px-3 py-2 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('connMgr.noneBody') }}
</p>
}
</div>
</div>
<!-- edit form -->
<div class="flex min-w-0 flex-1 flex-col">
@if (draft(); as d) {
<div class="has-scrollbar grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5">
<label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
<input
[value]="d.name"
(input)="patch('name', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.namePlaceholder')"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.host') }}</span>
<input
[value]="d.host"
(input)="patch('host', $any($event.target).value)"
placeholder="192.168.1.10"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<div class="grid grid-cols-2 gap-3">
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.port') }}</span>
<input
type="number"
[value]="d.port"
(input)="patch('port', +$any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.stack') }}</span>
<select
(change)="patch('stackId', $any($event.target).value || null)"
class="h-7 rounded-md border border-input bg-background px-1 outline-none focus:ring-2 focus:ring-ring"
>
<!-- [selected] per option: binding [value] on the <select> runs
before the options render and silently shows "(none)" -->
<option value="" [selected]="!d.stackId">{{ i18n.t('connMgr.stackNone') }}</option>
@for (stack of store.stacks(); track stack.id) {
<option [value]="stack.id" [selected]="d.stackId === stack.id">
{{ stack.name }}
</option>
}
</select>
</label>
</div>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.username') }}</span>
<input
[value]="d.username"
(input)="patch('username', $any($event.target).value)"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.remotePath') }}</span>
<input
[value]="d.initialPath"
(input)="patch('initialPath', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.remotePathPlaceholder')"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<div class="col-span-2 flex items-center gap-4 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.auth') }}</span>
<label class="flex items-center gap-1.5">
<input
type="radio"
name="authKind"
[checked]="d.authKind === 'password'"
(change)="patch('authKind', 'password')"
class="accent-primary"
/>
{{ i18n.t('connMgr.authPassword') }}
</label>
<label class="flex items-center gap-1.5">
<input
type="radio"
name="authKind"
[checked]="d.authKind === 'key'"
(change)="patch('authKind', 'key')"
class="accent-primary"
/>
{{ i18n.t('connMgr.authKey') }}
</label>
</div>
@if (d.authKind === 'password') {
<label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.password') }}</span>
<input
type="password"
[value]="d.password"
(input)="patch('password', $any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/>
</label>
} @else {
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.privateKeyFile') }}</span>
<div class="flex items-center gap-1">
<input
[value]="d.privateKeyPath"
(input)="patch('privateKeyPath', $any($event.target).value)"
placeholder="~/.ssh/id_ed25519"
spellcheck="false"
class="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
<button
type="button"
(click)="pickKeyFile()"
class="h-7 shrink-0 rounded-md border border-border px-2 text-[0.6875rem] hover:bg-accent"
>
{{ i18n.t('common.choose') }}
</button>
</div>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.passphrase') }}</span>
<input
type="password"
[value]="d.passphrase"
(input)="patch('passphrase', $any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/>
</label>
}
@if (moreOpen()) {
<label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.localDir') }}</span>
<input
[value]="d.localDir"
(input)="patch('localDir', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.localDirPlaceholder')"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
<span class="text-[0.625rem] text-muted-foreground/80">
{{ i18n.t('connMgr.localDirHint') }}
</span>
</label>
}
</div>
<div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5">
<button
(click)="deleteCurrent()"
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
>
<ng-icon name="mynaTrash" size="0.85rem" /> {{ i18n.t('common.delete') }}
</button>
<button
(click)="moreOpen.set(!moreOpen())"
class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ moreOpen() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }}
</button>
<div class="flex-1"></div>
<button
(click)="save()"
[disabled]="!valid()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
>
{{ i18n.t('common.save') }}
</button>
<button
(click)="saveAndConnect()"
[disabled]="!valid() || busy()"
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
@if (busy()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
}
{{ i18n.t('common.connect') }}
</button>
</div>
} @else {
<div class="grid flex-1 place-items-center">
<div class="text-center">
<ng-icon name="mynaServers" size="1.75rem" class="text-muted-foreground" />
<p class="mt-2 text-xs text-muted-foreground">
{{ i18n.t('connMgr.selectPrompt') }}
</p>
</div>
</div>
}
</div>
</div>
<!-- in-window dialogs (safe here: they depend only on FocusService) -->
@if (dialogs.prompt(); as req) {
<app-prompt-dialog [request]="req" />
}
@if (dialogs.confirm(); as req) {
<app-confirm-dialog [request]="req" />
}
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
own handles. Delete with the rest of the hack — checklist in
window-resize/window-resize.ts. -->
<app-window-resize />
<app-toasts />
</div>
@@ -1,291 +0,0 @@
import { Component, computed, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import type { SavedConnection, Stack } from '@shared/types';
import { ConnectionsService } from '@core/connections.service';
import { ConfirmDialog } from '../dialogs/confirm-dialog';
import { PromptDialog } from '../dialogs/prompt-dialog';
import { DialogsService } from '@core/dialogs.service';
import { blockNativeContextMenu } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { TitleBar } from '../title-bar/title-bar';
import { Toasts } from '../toasts/toasts';
import { WindowResize } from '../window-resize/window-resize';
interface Draft {
id: string;
name: string;
stackId: string | null;
host: string;
port: number;
username: string;
authKind: 'password' | 'key';
password: string;
privateKeyPath: string;
passphrase: string;
initialPath: string;
localDir: string;
}
function toDraft(c: SavedConnection): Draft {
return {
id: c.id,
name: c.name,
stackId: c.stackId,
host: c.host,
port: c.port,
username: c.username,
// agent auth only exists on ~/.ssh/config hosts, which never reach this form
authKind: c.auth.kind === 'password' ? 'password' : 'key',
password: c.auth.kind === 'password' ? c.auth.password : '',
privateKeyPath: c.auth.kind === 'key' ? c.auth.privateKeyPath : '',
passphrase: c.auth.kind === 'key' ? (c.auth.passphrase ?? '') : '',
initialPath: c.initialPath ?? '',
localDir: c.localDir ?? '',
};
}
function fromDraft(d: Draft): SavedConnection {
return {
id: d.id,
name: d.name.trim() || `${d.username}@${d.host}`,
stackId: d.stackId,
host: d.host.trim(),
port: d.port || 22,
username: d.username.trim(),
auth:
d.authKind === 'password'
? { kind: 'password', password: d.password }
: {
kind: 'key',
privateKeyPath: d.privateKeyPath.trim(),
...(d.passphrase ? { passphrase: d.passphrase } : {}),
},
initialPath: d.initialPath.trim() || null,
localDir: d.localDir.trim() || null,
};
}
/**
* Root component of the connection-manager window (__APP_MODE === 'manager').
* Modal to the main window (enforced bun-side); connect/open-stack requests
* are executed by the MAIN window — this window never touches panels.
*/
@Component({
selector: 'app-root',
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts, TitleBar, WindowResize],
templateUrl: './connection-manager.html',
host: {
'(window:keydown.escape)': 'onEscape()',
'(window:keydown)': 'onWindowKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)',
// Aero desaturates an unfocused window's chrome — see .window.active.
'(window:focus)': 'windowFocused.set(true)',
'(window:blur)': 'windowFocused.set(false)',
},
})
export class ConnectionManager {
readonly i18n = inject(I18nService);
/** Drives `.window.active` on this window's Aero frame. */
readonly windowFocused = signal(document.hasFocus());
readonly store = inject(ConnectionsService);
readonly dialogs = inject(DialogsService);
private readonly rpc = inject(RpcService);
// injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root
private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
readonly draft = signal<Draft | null>(null);
readonly busy = signal(false);
readonly importing = signal(false);
/** The More… section (local directory) in the edit form. */
readonly moreOpen = signal(false);
/** Connection id being dragged between stacks. */
readonly draggedId = signal<string | null>(null);
/** Highlighted drop target: a stack id, 'loose', or null. */
readonly dropTarget = signal<string | null>(null);
readonly valid = computed(() => {
const d = this.draft();
if (!d) return false;
if (!d.host.trim() || !d.username.trim()) return false;
if (d.authKind === 'key' && !d.privateKeyPath.trim()) return false;
return true;
});
patch<K extends keyof Draft>(key: K, value: Draft[K]): void {
this.draft.update((d) => (d ? { ...d, [key]: value } : d));
}
newConnection(): void {
this.moreOpen.set(false);
this.draft.set({
id: '',
name: '',
stackId: null,
host: '',
port: 22,
username: 'root',
authKind: 'password',
password: '',
privateKeyPath: '',
passphrase: '',
initialPath: '',
localDir: '',
});
}
edit(connection: SavedConnection): void {
this.draft.set(toDraft(connection));
this.moreOpen.set(!!connection.localDir);
}
close(): void {
void this.rpc.request.managerClose({});
}
onEscape(): void {
// in-window dialogs (confirm/prompt) handle their own Escape first
if (!this.dialogs.anyOpen) this.close();
}
/** Any-window shortcuts (interface scale). Escape has its own binding above. */
onWindowKeydown(event: KeyboardEvent): void {
const match = this.shortcuts.resolve(event, 'manager');
if (!match || this.shortcuts.isTypingTarget(event)) return;
event.preventDefault();
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
else if (match.id === 'scale-reset') this.settings.setUiScale(1);
}
blockContextMenu(event: Event): void {
blockNativeContextMenu(event);
}
async save(): Promise<string | null> {
const d = this.draft();
if (!d || !this.valid()) return null;
const id = await this.store.upsert(fromDraft(d));
if (id) this.patch('id', id);
return id;
}
async saveAndConnect(): Promise<void> {
if (!this.draft()) return;
this.busy.set(true);
try {
const id = await this.save();
if (!id) return;
// the main window connects (and this window closes); a failed connect
// surfaces as an error there
await this.rpc.request.managerConnect({ id });
} finally {
this.busy.set(false);
}
}
deleteCurrent(): void {
const d = this.draft();
if (!d?.id) {
this.draft.set(null);
return;
}
this.dialogs.confirm.set({
title: this.i18n.t('connMgr.deleteConnectionTitle'),
body: this.i18n.t('connMgr.deleteConnectionBody', { name: d.name || d.host }),
confirmLabel: this.i18n.t('common.delete'),
destructive: true,
onConfirm: async () => {
await this.store.remove(d.id);
this.draft.set(null);
},
});
}
async pickKeyFile(): Promise<void> {
const res = await this.rpc.request.pickKeyFile({});
if (res.ok && res.path) this.patch('privateKeyPath', res.path);
}
async importWinScp(): Promise<void> {
this.importing.set(true);
try {
await this.store.importWinScp();
} finally {
this.importing.set(false);
}
}
// ---- stacks ----
openStack(stackId: string): void {
// opened as tabs in the main window; bun closes this window after
void this.rpc.request.managerOpenStack({ id: stackId });
}
toggleStack(stack: Stack): void {
void this.store.upsertStack({ ...stack, collapsed: !stack.collapsed });
}
newStack(): void {
this.dialogs.prompt.set({
title: this.i18n.t('connMgr.newStackTitle'),
placeholder: this.i18n.t('connMgr.newStackPrompt'),
confirmLabel: this.i18n.t('fileActions.createConfirm'),
onConfirm: (name) => void this.store.upsertStack({ id: '', name }),
});
}
renameStack(stack: Stack): void {
this.dialogs.prompt.set({
title: this.i18n.t('connMgr.renameStackTitle'),
initial: stack.name,
confirmLabel: this.i18n.t('connMgr.renameStack'),
onConfirm: (name) => void this.store.upsertStack({ ...stack, name }),
});
}
deleteStack(stack: Stack): void {
this.dialogs.confirm.set({
title: this.i18n.t('connMgr.deleteStackTitle'),
body: this.i18n.t('connMgr.deleteStackBody', { name: stack.name }),
confirmLabel: this.i18n.t('common.delete'),
destructive: true,
onConfirm: () => void this.store.removeStack(stack.id),
});
}
// ---- drag & drop (move a connection between stacks / loose) ----
onDragStart(event: DragEvent, connection: SavedConnection): void {
this.draggedId.set(connection.id);
event.dataTransfer?.setData('text/plain', connection.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
}
onDragOver(event: DragEvent, target: string): void {
if (!this.draggedId()) return;
event.preventDefault();
this.dropTarget.set(target);
}
onDrop(event: DragEvent, stackId: string | null): void {
event.preventDefault();
const id = this.draggedId();
this.draggedId.set(null);
this.dropTarget.set(null);
const connection = this.store.connections().find((c) => c.id === id);
if (!connection || connection.stackId === stackId) return;
void this.store.upsert({ ...connection, stackId });
}
onDragEnd(): void {
this.draggedId.set(null);
this.dropTarget.set(null);
}
}
-220
View File
@@ -1,220 +0,0 @@
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
import { FocusService } from '@core/focus.service';
import { remPx } from '@core/metrics';
/** Rough per-item metrics in REM, used to size the menu and decide whether it
* must flip. Rem because the interface scale is a root font-size lever: the
* old `MENU_W = 200` was pinned into the DOM via [style.width.px], so the box
* stayed 200px while the text inside grew with the scale and overflowed.
* Values mirror the skinfix restatement of 7.css's menu metrics in 7like.css
* (item padding 0.25rem x2 + the ~0.9375rem line). */
const ITEM_H_REM = 1.5; // 24px @16
const DIVIDER_H_REM = 0.4375; // 7px @16: 2px bevel + 3px/2px margins
const MENU_W_REM = 12.5; // 200px @16
const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
/** One rendered <li>: separators collapse onto the item BEFORE them. */
interface MenuRow {
item: MenuItem;
hasDivider: boolean;
}
/**
* The panel right-click menu. Replaces electrobun's native ContextMenu, which
* is a no-op on Linux — see ContextMenuService for the why. Emits the same
* `ctx-*` / `col:*` action strings the native menu did.
*
* The markup is 7.css's menu contract EXACTLY — `ul[role=menu] >
* li[role=menuitem] > button` for actions, `> input[type=checkbox] + label`
* for the checkable column toggles, and dividers as `.has-divider` on the
* PRECEDING item (7.css draws them as an ::after bevel, not as a node). Get
* any of that wrong and 7.css silently styles nothing: an earlier version
* rendered div > button, concluded "7.css has no menu component", and
* hand-built a lookalike. It does have one; the selectors just never matched.
*
* `w7-ctx` is OURS, not 7.css's: `ul[role=menu]` carries `position: relative`
* in 7.css (for its gutter stripe and submenus), and the skin layer outranks
* Tailwind's `.fixed` — so the menu laid out in flow, at the bottom of the
* h-screen frame, off the viewport. The class scopes a `position: fixed`
* restatement in 7like.css to THIS menu; see scripts/menu-probe.ts.
*
* Keyboard follows the platform: arrows move REAL focus between items (7.css
* styles :focus-visible identically to :hover), Enter/Space on a button
* activate it natively; only labels need a synthetic click.
*/
@Component({
selector: 'app-context-menu',
template: `
@if (menu.request(); as req) {
<!-- click-outside catcher; mousedown so it beats the panel's own handlers -->
<div class="fixed inset-0 z-50" (mousedown)="close()" (contextmenu)="onBackdropContext($event)"></div>
<ul
#menuEl
role="menu"
tabindex="-1"
(keydown)="onKeydown($event)"
[style.left.px]="position().x"
[style.top.px]="position().y"
[style.width.rem]="menuWidthRem"
class="w7-ctx fixed z-50 outline-none"
>
@for (row of rows(); track $index) {
<!-- aria-disabled on the LI, not the button: that is where 7.css
hangs its disabled state (opacity + pointer-events: none). -->
<li
role="menuitem"
[class.has-divider]="row.hasDivider"
[attr.aria-disabled]="row.item.disabled ? true : null"
[title]="row.item.tooltip ?? ''"
>
@if (row.item.checked !== undefined) {
<input type="checkbox" [id]="'ctx-check-' + $index" [checked]="row.item.checked" tabindex="-1" />
<label [for]="'ctx-check-' + $index" tabindex="-1" (click)="pick(row.item, $event)">
{{ row.item.label }}
</label>
} @else {
<button type="button" (click)="pick(row.item)">
{{ row.item.label }}
@if (row.item.accelerator) {
<span>{{ row.item.accelerator }}</span>
}
</button>
}
</li>
}
</ul>
}
`,
// Closing on scroll/blur matches native menu behavior: the anchor row moves
// out from under a menu that would otherwise float over stale content.
host: {
'(window:blur)': 'close()',
'(window:resize)': 'close()',
'(document:scroll)': 'close()',
},
})
export class ContextMenu {
readonly menu = inject(ContextMenuService);
private readonly focus = inject(FocusService);
private readonly menuEl = viewChild<ElementRef<HTMLUListElement>>('menuEl');
/** Rendered width AND math input are the same rem value, so the flip point
* and the box can never disagree — the shadlike menu's old bug. */
protected readonly menuWidthRem = MENU_W_REM;
/** Model items → rendered rows: a separator becomes has-divider on its predecessor. */
readonly rows = computed<MenuRow[]>(() => {
const items = this.menu.request()?.items ?? [];
const rows: MenuRow[] = [];
for (const item of items) {
if (item.type === 'separator') {
if (rows.length) rows[rows.length - 1].hasDivider = true;
} else {
rows.push({ item, hasDivider: false });
}
}
return rows;
});
/** Click point, flipped/clamped so the menu never overflows the viewport.
* UI metrics convert through the live root font-size (remPx); the click
* point and window.inner* are both viewport CSS px already. */
readonly position = computed(() => {
const req = this.menu.request();
if (!req) return { x: 0, y: 0 };
const rows = this.rows();
const menuW = remPx(MENU_W_REM);
const height =
rows.length * remPx(ITEM_H_REM) +
rows.filter((r) => r.hasDivider).length * remPx(DIVIDER_H_REM) +
remPx(0.25); // ul padding 0.125rem x2
const x =
req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : req.x;
const y =
req.y + height > window.innerHeight - EDGE_GAP ? Math.max(EDGE_GAP, req.y - height) : req.y;
return { x, y };
});
constructor() {
// Focus the menu once it exists so arrow keys / Escape reach it. Keyed on
// the viewChild signal, NOT on request(): under zoneless CD an effect can
// run before the @if has rendered the element.
effect(() => this.menuEl()?.nativeElement.focus());
}
close(): void {
if (!this.menu.request()) return;
this.menu.close();
this.focus.bump();
}
pick(item: MenuItem, event?: Event): void {
// labels: don't let the click also toggle the real checkbox — the fresh
// `checked` state comes back through settings when the menu reopens
event?.preventDefault();
// 7.css already blocks the pointer on [aria-disabled]; this covers the
// keyboard path, which routes through focus and never consults CSS
if (item.disabled) return;
if (!item.action) return;
const action = item.action;
this.close();
this.menu.onPick(action);
}
/** Right-clicking the backdrop closes rather than stacking a second menu. */
onBackdropContext(event: MouseEvent): void {
event.preventDefault();
this.close();
}
onKeydown(event: KeyboardEvent): void {
switch (event.key) {
case 'Escape':
event.preventDefault();
event.stopPropagation();
this.close();
break;
case 'ArrowDown':
event.preventDefault();
this.step(1);
break;
case 'ArrowUp':
event.preventDefault();
this.step(-1);
break;
case 'Enter':
case ' ': {
// buttons activate natively from their own focus; labels don't, so
// synthesize the click (which routes through pick via the template)
const active = document.activeElement;
if (active instanceof HTMLLabelElement && this.menuEl()?.nativeElement.contains(active)) {
event.preventDefault();
active.click();
}
break;
}
}
}
/**
* Move REAL focus by one item, wrapping. 7.css styles :focus-visible the
* same as :hover, and keyboard-initiated programmatic focus counts as
* focus-visible, so the highlight comes from the framework for free.
*/
private step(delta: number): void {
const root = this.menuEl()?.nativeElement;
if (!root) return;
// disabled items are skipped, not merely unclickable: pointer-events:none
// does not make an element unfocusable, so arrows would still land on them
const targets = Array.from(
root.querySelectorAll<HTMLElement>(
'li:not([aria-disabled]) > button, li:not([aria-disabled]) > label',
),
);
if (!targets.length) return;
const current = targets.indexOf(document.activeElement as HTMLElement);
const next = current < 0 && delta < 0 ? targets.length - 1 : (current + delta + targets.length) % targets.length;
targets[next].focus();
}
}
@@ -1,129 +0,0 @@
import { Component, computed, inject, input, signal } from '@angular/core';
import { I18nService } from '@core/i18n.service';
import { DialogsService, type ChmodRequest } from '@core/dialogs.service';
import { FocusService } from '@core/focus.service';
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
const PERMS = ['r', 'w', 'x'] as const;
@Component({
selector: 'app-chmod-dialog',
template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div
class="w-80 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()"
>
<h2 class="text-sm font-semibold">{{ i18n.t('dialogs.chmodTitle') }}</h2>
<p class="mt-1 truncate text-xs text-muted-foreground">{{ targetLabel() }}</p>
<table class="mt-3 w-full text-xs">
<thead>
<tr class="text-muted-foreground">
<th class="pb-1 text-left font-normal"></th>
<th class="pb-1 font-normal">{{ i18n.t('dialogs.read') }}</th>
<th class="pb-1 font-normal">{{ i18n.t('dialogs.write') }}</th>
<th class="pb-1 font-normal">{{ i18n.t('dialogs.execute') }}</th>
</tr>
</thead>
<tbody>
@for (role of roles; track role; let r = $index) {
<tr>
<td class="py-1 pr-2 text-muted-foreground">{{ i18n.t('dialogs.' + role) }}</td>
@for (perm of perms; track perm; let p = $index) {
<td class="py-1 text-center">
<input
type="checkbox"
[checked]="hasBit(r, p)"
(change)="toggleBit(r, p)"
class="size-3.5 accent-primary"
/>
</td>
}
</tr>
}
</tbody>
</table>
<div class="mt-3 flex items-center gap-2">
<label class="text-xs text-muted-foreground">{{ i18n.t('dialogs.octal') }}</label>
<input
[value]="octal()"
(input)="setOctal($any($event.target).value)"
maxlength="4"
spellcheck="false"
class="h-7 w-20 rounded-md border border-input bg-transparent px-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
(click)="cancel()"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('common.cancel') }}
</button>
<button
(click)="submit()"
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{{ i18n.t('common.apply') }}
</button>
</div>
</div>
</div>
`,
})
export class ChmodDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<ChmodRequest>();
readonly roles = ROLES;
readonly perms = PERMS;
private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService);
// mode is initialized lazily from the request on first read
private readonly modeOverride = signal<number | null>(null);
readonly mode = computed(() => this.modeOverride() ?? this.request().initialMode & 0o777);
readonly octal = computed(() => this.mode().toString(8).padStart(3, '0'));
targetLabel(): string {
const targets = this.request().targets;
return targets.length === 1
? targets[0]
: this.i18n.t('dialogs.chmodItems', { count: targets.length });
}
hasBit(role: number, perm: number): boolean {
return !!(this.mode() & (0o400 >> (role * 3 + perm)));
}
toggleBit(role: number, perm: number): void {
this.modeOverride.set(this.mode() ^ (0o400 >> (role * 3 + perm)));
}
setOctal(text: string): void {
if (/^[0-7]{1,4}$/.test(text)) {
this.modeOverride.set(parseInt(text, 8) & 0o777);
}
}
submit(): void {
const req = this.request();
const mode = this.mode();
this.close();
req.onConfirm(mode);
}
cancel(): void {
this.close();
}
private close(): void {
this.dialogs.chmod.set(null);
this.focus.bump();
}
}
@@ -1,66 +0,0 @@
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
import { I18nService } from '@core/i18n.service';
import { DialogsService, type ConfirmRequest } from '@core/dialogs.service';
import { FocusService } from '@core/focus.service';
@Component({
selector: 'app-confirm-dialog',
template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()"
>
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
<p class="mt-2 text-xs text-muted-foreground">{{ request().body }}</p>
<div class="mt-4 flex justify-end gap-2">
<button
(click)="cancel()"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('common.cancel') }}
</button>
<button
#confirmBtn
(click)="submit()"
[class]="
request().destructive
? 'w7-btn rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90'
: 'w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90'
"
>
{{ request().confirmLabel }}
</button>
</div>
</div>
</div>
`,
})
export class ConfirmDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<ConfirmRequest>();
private readonly confirmBtn = viewChild.required<ElementRef<HTMLButtonElement>>('confirmBtn');
private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService);
constructor() {
afterNextRender(() => this.confirmBtn().nativeElement.focus());
}
submit(): void {
const req = this.request();
this.close();
req.onConfirm();
}
cancel(): void {
this.close();
}
private close(): void {
this.dialogs.confirm.set(null);
this.focus.bump();
}
}
@@ -1,149 +0,0 @@
import {
Component,
ElementRef,
afterNextRender,
computed,
inject,
input,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service';
import { FocusService } from '@core/focus.service';
@Component({
selector: 'app-overwrite-dialog',
imports: [NgIcon],
template: `
<div
class="fixed inset-0 z-40 grid place-items-center bg-black/40"
(keydown.escape)="pick('cancel')"
>
<div
class="w-[26rem] rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
>
<div class="flex items-start justify-between gap-2">
<h2 class="min-w-0 text-sm font-semibold">
{{ isDir() ? i18n.t('dialogs.mergeFolder') : i18n.t('dialogs.replace') }}
<span class="font-mono">"{{ request().srcEntry.name }}"</span>?
</h2>
@if (request().total > 1) {
<span
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[0.625rem] font-medium text-muted-foreground"
>
{{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }}
</span>
}
</div>
<p class="mt-1 text-xs text-muted-foreground">
@if (isDir()) {
{{ i18n.t('dialogs.overwriteDirBody') }}
} @else {
{{ i18n.t('dialogs.overwriteFileBody') }}
}
</p>
<!-- comparison -->
<div class="mt-3 grid grid-cols-2 gap-2">
@for (side of comparison(); track side.label) {
<div class="rounded-md border border-border p-2.5">
<div class="flex items-center justify-between">
<span class="text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
{{ i18n.t('dialogs.' + side.label) }}
</span>
@if (side.newer) {
<span class="rounded-full bg-success/15 px-1.5 text-[0.625rem] font-medium text-success">
{{ i18n.t('dialogs.newer') }}
</span>
}
</div>
<div class="mt-1.5 flex items-center gap-1.5 text-xs">
<ng-icon
[name]="isDir() ? 'mynaFolder' : 'mynaFile'"
size="0.9rem"
class="shrink-0 text-muted-foreground"
/>
<span class="font-mono tabular-nums">
{{ isDir() ? '—' : i18n.size(side.entry.size) }}
</span>
</div>
<div class="mt-1 font-mono text-[0.6875rem] tabular-nums text-muted-foreground">
{{ i18n.date(side.entry.mtime) }}
</div>
</div>
}
</div>
<!-- actions -->
<div class="mt-4 flex items-center gap-2">
<button
(click)="pick('cancel')"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ i18n.t('common.cancel') }}
</button>
<div class="flex-1"></div>
@if (request().total > 1) {
<button
(click)="pick('skipAll')"
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ i18n.t('dialogs.skipAll') }}
</button>
<button
(click)="pick('overwriteAll')"
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ isDir() ? i18n.t('dialogs.mergeAll') : i18n.t('dialogs.replaceAll') }}
</button>
<div class="h-4 w-px bg-border"></div>
}
<button
(click)="pick('skip')"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('dialogs.skip') }}
</button>
<button
#primaryBtn
(click)="pick('overwrite')"
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{{ isDir() ? i18n.t('dialogs.merge') : i18n.t('dialogs.replace') }}
</button>
</div>
</div>
</div>
`,
})
export class OverwriteDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<OverwriteRequest>();
private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService);
private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn');
constructor() {
afterNextRender(() => this.primaryBtn().nativeElement.focus());
}
readonly isDir = computed(() => this.request().srcEntry.type === 'dir');
readonly comparison = computed(() => {
const { srcEntry, dstEntry } = this.request();
return [
{ label: 'source', entry: srcEntry, newer: srcEntry.mtime > dstEntry.mtime },
{ label: 'destination', entry: dstEntry, newer: dstEntry.mtime > srcEntry.mtime },
];
});
pick(choice: OverwriteChoice): void {
const req = this.request();
this.dialogs.overwrite.set(null);
this.focus.bump();
req.resolve(choice);
}
}
@@ -1,86 +0,0 @@
import {
Component,
ElementRef,
afterNextRender,
inject,
input,
signal,
viewChild,
} from '@angular/core';
import { I18nService } from '@core/i18n.service';
import { DialogsService, type PromptRequest } from '@core/dialogs.service';
import { FocusService } from '@core/focus.service';
@Component({
selector: 'app-prompt-dialog',
template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
>
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
<input
#box
[value]="value()"
(input)="value.set($any($event.target).value)"
(keydown.enter)="submit()"
(keydown.escape)="cancel()"
[placeholder]="request().placeholder ?? ''"
spellcheck="false"
class="mt-3 h-8 w-full rounded-md border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring"
/>
<div class="mt-4 flex justify-end gap-2">
<button
(click)="cancel()"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('common.cancel') }}
</button>
<button
(click)="submit()"
[disabled]="!value().trim()"
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{{ request().confirmLabel }}
</button>
</div>
</div>
</div>
`,
})
export class PromptDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<PromptRequest>();
readonly value = signal('');
private readonly box = viewChild.required<ElementRef<HTMLInputElement>>('box');
private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService);
constructor() {
afterNextRender(() => {
this.value.set(this.request().initial ?? '');
const el = this.box().nativeElement;
el.focus();
el.select();
});
}
submit(): void {
const value = this.value().trim();
if (!value) return;
const req = this.request();
this.close();
req.onConfirm(value);
}
cancel(): void {
this.close();
}
private close(): void {
this.dialogs.prompt.set(null);
this.focus.bump();
}
}
@@ -1,212 +0,0 @@
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
@if (!ctx) {
<div class="flex flex-1 items-center justify-center">
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ i18n.t('editor.noFileBody') }}
</p>
</div>
</div>
} @else {
<!-- The window is frameless. The X runs the SAME dirty prompt as ⌘W and
the Close button — never a raw window close. -->
<app-title-bar [title]="ctx.name + (dirty() ? ' *' : '')" (closeRequested)="requestClose()" />
<!-- Toolbar strip under the title bar. h-9/pl-3 are load-bearing: without a
height this row collapses onto the icon and the screen reads as broken. -->
<header
class="w7-toolbar flex h-9 shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2 pl-3"
>
<ng-icon name="mynaFileText" size="0.95rem" class="shrink-0 text-muted-foreground" />
<h1 class="min-w-0 truncate font-mono text-sm font-semibold">
{{ ctx.name }}{{ dirty() ? ' •' : '' }}
</h1>
<span class="shrink-0 text-[0.6875rem] text-muted-foreground">{{ ctx.sessionTitle }}</span>
<div class="flex-1"></div>
<button
(click)="toggleWrap()"
[title]="i18n.t('editor.wrapHint', { alt: altLabel })"
class="rounded-md px-2 py-1 text-[0.6875rem] electrobun-webkit-app-region-no-drag hover:bg-accent"
[class.bg-accent]="wrap()"
[class.text-foreground]="wrap()"
[class.text-muted-foreground]="!wrap()"
>
{{ i18n.t('editor.wrap') }}
</button>
<button
(click)="openFind()"
[title]="i18n.t('editor.findHint', { mod: modLabel })"
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaSearch" size="0.85rem" />
</button>
</header>
@if (findOpen()) {
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome px-3 py-1.5">
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
<input
#findBox
[value]="findQuery()"
(input)="onFindInput($any($event.target).value)"
(keydown)="onFindKeydown($event)"
[placeholder]="i18n.t('editor.findPlaceholder')"
spellcheck="false"
class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
/>
<span
class="text-[0.6875rem] tabular-nums"
[class.text-destructive]="findQuery() && !matches().length"
[class.text-muted-foreground]="!findQuery() || matches().length"
>
{{ countLabel() }}
</span>
<button
(click)="findStep(-1)"
[title]="i18n.t('editor.prevMatch')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaChevronUp" size="0.8rem" />
</button>
<button
(click)="findStep(1)"
[title]="i18n.t('editor.nextMatch')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaChevronDown" size="0.8rem" />
</button>
<div class="flex-1"></div>
<button
(click)="closeFind()"
[title]="i18n.t('editor.closeHint')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.8rem" />
</button>
</div>
}
@if (sessionDead()) {
<div
class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[0.6875rem] text-destructive"
>
<ng-icon name="mynaXCircle" size="0.85rem" class="shrink-0" />
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
</div>
}
@if (loading()) {
<div class="flex flex-1 items-center justify-center">
<ng-icon name="mynaSpinner" size="1.25rem" class="animate-spin text-primary" />
</div>
} @else if (loadError(); as error) {
<div class="flex flex-1 items-center justify-center">
<div class="max-w-md rounded-lg border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ error }}</p>
<button
(click)="discardAndClose()"
class="mt-4 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('editor.closeWindow') }}
</button>
</div>
</div>
} @else {
<div class="flex min-h-0 flex-1">
<!-- gutter and textarea share font/leading/vertical padding — that's
the whole alignment contract; keep the classes in lockstep -->
@if (!wrap()) {
<div
#gutter
aria-hidden="true"
[style.width.ch]="gutterDigits() + 3"
class="shrink-0 overflow-hidden border-r border-border py-4 pr-2 text-right font-mono text-xs leading-relaxed whitespace-pre text-muted-foreground select-none"
>{{ gutterText() }}</div>
}
<textarea
#box
[value]="text()"
(input)="onInput($any($event.target).value)"
(scroll)="onScroll()"
spellcheck="false"
[attr.wrap]="wrap() ? 'soft' : 'off'"
class="min-h-0 flex-1 resize-none overflow-auto bg-transparent px-4 py-4 font-mono text-xs leading-relaxed outline-none"
[class.whitespace-pre]="!wrap()"
></textarea>
</div>
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2">
<span class="text-[0.6875rem] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
<span class="text-[0.6875rem] text-muted-foreground">·</span>
<span class="text-[0.6875rem] tabular-nums text-muted-foreground">
{{ i18n.t('editor.lines', { count: lineCount() }) }}
</span>
<div class="flex-1"></div>
<button
(click)="requestClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('editor.close') }}
</button>
<button
(click)="save()"
[disabled]="saving() || !dirty()"
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
@if (saving()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
}
{{ i18n.t('common.save') }}
</button>
</footer>
}
<!-- dirty-close prompt. Component-local on purpose: the shared confirm
dialog pulls in PanelsService, which must not boot in this window. -->
@if (closePrompt()) {
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()">
<div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
>
<h2 class="text-sm font-semibold">{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}</h2>
<p class="mt-1.5 text-xs text-muted-foreground">
{{ i18n.t('editor.saveChangesBody') }}
</p>
<div class="mt-4 flex items-center gap-2">
<button
(click)="discardAndClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent"
>
{{ i18n.t('editor.dontSave') }}
</button>
<div class="flex-1"></div>
<button
(click)="cancelClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('common.cancel') }}
</button>
<button
(click)="saveAndClose()"
[disabled]="saving()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{{ i18n.t('common.save') }}
</button>
</div>
</div>
</div>
}
}
<!-- STOPGAP: the frameless window has no WM resize borders, so we draw our
own handles. Delete with the rest of the hack — checklist in
window-resize/window-resize.ts. -->
<app-window-resize />
<app-toasts />
</div>
@@ -1,389 +0,0 @@
import {
Component,
DOCUMENT,
ElementRef,
afterNextRender,
computed,
effect,
inject,
signal,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, MOD_LABEL } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { ToastService } from '@core/toast.service';
import { TitleBar } from '../title-bar/title-bar';
import { Toasts } from '../toasts/toasts';
import { WindowResize } from '../window-resize/window-resize';
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
const DRAFT_DEBOUNCE_MS = 400;
/**
* Root component of an editor window (window.__APP_MODE === 'editor').
*
* Deliberately injects only Rpc/Toast/Settings: main-window root services
* (PanelsService, ConnectionsService, TransfersService — and the dialogs that
* inject them) have constructor side effects like creating workspaces and
* loading connections that must not run here.
*/
@Component({
selector: 'app-root',
imports: [NgIcon, Toasts, TitleBar, WindowResize],
templateUrl: './editor-window.html',
host: {
'(window:keydown)': 'onKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)',
// Aero desaturates an unfocused window's chrome — see .window.active.
// blur already had to flush the draft; both run from the one binding
// (a duplicated key is a TS1117 build error, not a merge).
'(window:focus)': 'windowFocused.set(true)',
'(window:blur)': 'flushDraft(); windowFocused.set(false)',
},
})
export class EditorWindow {
readonly i18n = inject(I18nService);
/** Drives `.window.active` on this window's Aero frame. */
readonly windowFocused = signal(document.hasFocus());
readonly ctx = EDITOR_CONTEXT;
readonly modLabel = MOD_LABEL;
readonly altLabel = ALT_LABEL;
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly text = signal('');
readonly savedContent = signal('');
readonly dirty = computed(() => this.text() !== this.savedContent());
readonly saving = signal(false);
readonly closePrompt = signal(false);
readonly sessionDead = signal(false);
/** Soft-wrap long lines. Hides the gutter: correct wrapped line numbers
* would need per-edit height measurement — not worth it without an editor lib. */
readonly wrap = signal(false);
readonly findOpen = signal(false);
readonly findQuery = signal('');
readonly activeMatch = signal(0);
/** Line count without allocating a split array on every keystroke. */
readonly lineCount = computed(() => {
const t = this.text();
let n = 1;
let i = -1;
while ((i = t.indexOf('\n', i + 1)) !== -1) n++;
return n;
});
readonly gutterText = computed(() =>
Array.from({ length: this.lineCount() }, (_, i) => i + 1).join('\n'),
);
readonly gutterDigits = computed(() => String(this.lineCount()).length);
/** Case-insensitive match start offsets for the find query. */
readonly matches = computed(() => {
const query = this.findQuery().toLowerCase();
if (!query) return [];
const hay = this.text().toLowerCase();
const out: number[] = [];
let i = hay.indexOf(query);
while (i !== -1) {
out.push(i);
i = hay.indexOf(query, i + query.length);
}
return out;
});
readonly countLabel = computed(() =>
this.matches().length ? `${this.activeMatch() + 1}/${this.matches().length}` : '0/0',
);
private readonly box = viewChild<ElementRef<HTMLTextAreaElement>>('box');
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
private cachedCharWidth = 0;
private cachedCharFont = '';
private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService);
private readonly document = inject(DOCUMENT);
// injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root
private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
private draftTimer: ReturnType<typeof setTimeout> | null = null;
private lastSentDirty = false;
constructor() {
const ctx = this.ctx;
if (!ctx) return; // opened outside the app (plain browser) — error card renders
const baseTitle = `${ctx.name} — ${ctx.sessionTitle}`;
effect(() => {
this.document.title = `${this.dirty() ? '• ' : ''}${baseTitle}`;
});
this.rpc.onEditorCloseRequest(() => this.requestClose());
this.rpc.onSessionClosed((event) => {
if (event.sessionId === ctx.sessionId) this.sessionDead.set(true);
});
afterNextRender(() => void this.load(ctx.sessionId, ctx.path));
}
private async load(sessionId: string, path: string): Promise<void> {
const res = await this.rpc.request.readFile({ sessionId, path });
if (!res.ok) {
this.loadError.set(res.error);
this.loading.set(false);
return;
}
this.savedContent.set(res.content);
this.text.set(res.content);
this.loading.set(false);
queueMicrotask(() => this.box()?.nativeElement.focus());
}
blockContextMenu(event: Event): void {
blockNativeContextMenu(event);
}
onInput(value: string): void {
this.text.set(value);
this.scheduleDraft();
}
/** Keep the line-number gutter locked to the textarea's scroll position. */
onScroll(): void {
const gutter = this.gutter()?.nativeElement;
if (gutter) gutter.scrollTop = this.box()!.nativeElement.scrollTop;
}
toggleWrap(): void {
this.wrap.update((w) => !w);
queueMicrotask(() => this.onScroll());
}
// ---- find ----
openFind(): void {
const el = this.box()?.nativeElement;
// prefill from the current selection when it's a sane single-line snippet
if (el && el.selectionStart !== el.selectionEnd) {
const selected = el.value.slice(el.selectionStart, el.selectionEnd);
if (selected.length < 200 && !selected.includes('\n')) this.findQuery.set(selected);
}
this.findOpen.set(true);
this.activeMatch.set(0);
queueMicrotask(() => {
const input = this.findBox()?.nativeElement;
input?.focus();
input?.select();
});
}
closeFind(): void {
this.findOpen.set(false);
// selection was left on the match — the caret lands there
this.box()?.nativeElement.focus();
}
onFindInput(value: string): void {
this.findQuery.set(value);
const list = this.matches();
if (!list.length) return;
// jump to the first match at/after the caret, else wrap to the first
const from = this.box()?.nativeElement.selectionStart ?? 0;
const at = list.findIndex((start) => start >= from);
this.goTo(at >= 0 ? at : 0);
}
findStep(delta: 1 | -1): void {
const count = this.matches().length;
if (!count) return;
this.goTo((this.activeMatch() + delta + count) % count);
}
onFindKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter') {
event.preventDefault();
this.findStep(event.shiftKey ? -1 : 1);
} else if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.closeFind();
}
}
/** Select match `index` and scroll it into the middle of the viewport. */
private goTo(index: number): void {
const el = this.box()?.nativeElement;
const start = this.matches()[index];
if (!el || start === undefined) return;
this.activeMatch.set(index);
el.setSelectionRange(start, start + this.findQuery().length);
const text = this.text();
// Fallback derives from the element's own font size, not a hardcoded 20 —
// the interface scale moves the root font-size, so any px literal here
// would be a 16px-root assumption that miscentres at every other scale.
const style = getComputedStyle(el);
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.625;
let line = 0;
let i = text.indexOf('\n');
while (i !== -1 && i < start) {
line++;
i = text.indexOf('\n', i + 1);
}
el.scrollTop = Math.max(0, line * lineHeight - el.clientHeight / 2);
if (!this.wrap()) {
const column = start - (text.lastIndexOf('\n', start - 1) + 1);
el.scrollLeft = Math.max(0, column * this.charWidth(el) - el.clientWidth / 2);
}
this.onScroll();
}
/** Monospace advance width, cached per computed FONT rather than per window:
* the interface scale changes the root font-size live, and a cache measured
* once would keep positioning finds with the old advance width. Keying on
* the font string makes it self-invalidating under any font change. */
private charWidth(el: HTMLTextAreaElement): number {
const font = getComputedStyle(el).font;
if (font !== this.cachedCharFont) {
const canvas = this.document.createElement('canvas').getContext('2d')!;
canvas.font = font;
this.cachedCharWidth = canvas.measureText('0').width || parseFloat(getComputedStyle(el).fontSize) * 0.6;
this.cachedCharFont = font;
}
return this.cachedCharWidth;
}
// ---- draft mirror: bun holds the latest content for the native-close prompt ----
private scheduleDraft(): void {
if (this.dirty() !== this.lastSentDirty) {
// dirty flag flipped — tell bun immediately so the native title updates
this.flushDraft();
return;
}
if (this.draftTimer) clearTimeout(this.draftTimer);
this.draftTimer = setTimeout(() => this.flushDraft(), DRAFT_DEBOUNCE_MS);
}
flushDraft(): void {
if (!this.ctx || this.loading()) return;
if (this.draftTimer) {
clearTimeout(this.draftTimer);
this.draftTimer = null;
}
this.lastSentDirty = this.dirty();
this.rpc.send.editorState({ dirty: this.dirty(), content: this.text() });
}
// ---- save / close ----
async save(): Promise<boolean> {
if (!this.dirty() || this.saving()) return true;
const content = this.text();
this.saving.set(true);
try {
const res = await this.rpc.request.editorSave({ content });
if (!res.ok) {
this.toast.error(res.error);
return false;
}
this.savedContent.set(content);
this.lastSentDirty = false; // bun record already cleaned by the handler
this.toast.success(this.i18n.t('toasts.saved', { name: this.ctx!.name }));
return true;
} finally {
this.saving.set(false);
}
}
/** ⌘W / Close button / bun's editorCloseRequest: prompt if dirty, else close. */
requestClose(): void {
if (this.dirty()) {
this.closePrompt.set(true);
} else {
void this.rpc.request.editorClose({});
}
}
async saveAndClose(): Promise<void> {
if (await this.save()) void this.rpc.request.editorClose({});
}
discardAndClose(): void {
void this.rpc.request.editorClose({});
}
cancelClose(): void {
this.closePrompt.set(false);
queueMicrotask(() => this.box()?.nativeElement.focus());
}
// ---- keyboard ----
onKeydown(event: KeyboardEvent): void {
// Chord shortcuts resolve through the shared keymap (ShortcutsService);
// Escape and Tab stay below because their meaning is THIS window's state
// (close prompt up? find bar open? caret in the textarea?), not a binding.
const match = this.shortcuts.resolve(event, 'editor');
if (match) {
// scale keys must not fire while typing — but the editor's own chords
// (save/find/wrap) are WANTED mid-typing, so the guard is per-shortcut
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
event.preventDefault();
switch (match.id) {
case 'save':
void this.save();
break;
case 'find':
this.openFind();
break;
case 'close-window':
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W
// and bun routes it back as editorCloseRequest
this.requestClose();
break;
case 'toggle-wrap':
this.toggleWrap();
break;
case 'scale-up':
this.settings.setUiScale(this.settings.uiScale() + 0.1);
break;
case 'scale-down':
this.settings.setUiScale(this.settings.uiScale() - 0.1);
break;
case 'scale-reset':
this.settings.setUiScale(1);
break;
}
return;
}
if (event.key === 'Escape' && this.closePrompt()) {
event.preventDefault();
this.cancelClose();
} else if (event.key === 'Escape' && this.findOpen()) {
event.preventDefault();
this.closeFind();
} else if (event.key === 'Tab' && event.target === this.box()?.nativeElement) {
// keep Tab as an input character inside the editor
event.preventDefault();
this.insertTab();
}
}
private insertTab(): void {
const el = this.box()!.nativeElement;
// execCommand keeps the native undo stack intact where supported
if (!this.document.execCommand('insertText', false, '\t')) {
const { selectionStart, selectionEnd, value } = el;
el.value = `${value.slice(0, selectionStart)}\t${value.slice(selectionEnd)}`;
el.selectionStart = el.selectionEnd = selectionStart + 1;
}
this.onInput(el.value);
}
}
@@ -1,349 +0,0 @@
<!-- session strip.
w7-toolbar: these are Explorer command-bar verbs, not dialog push buttons,
so under the win7 skin they go flat with a hover glow instead of wearing
the full Aero slab — the same treatment the Connections button gets from
the tab-bar row. -->
<div class="w7-toolbar relative flex items-center gap-1.5 border-b border-border px-2 py-1">
<button
(click)="toggleMenu()"
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent"
[title]="i18n.t('panel.choosePanel')"
>
@if (panel().connecting()) {
<ng-icon name="mynaSpinner" class="shrink-0 animate-spin text-primary" size="var(--action-icon)" />
} @else {
<ng-icon
[name]="isRemote() ? 'mynaServers' : 'mynaHardDrive'"
[class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
size="var(--action-icon)"
/>
}
<span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span>
<ng-icon
name="mynaChevronDown"
class="shrink-0 text-muted-foreground"
size="calc(var(--action-icon) - 0.1rem)"
/>
</button>
<div class="flex-1"></div>
<!-- panel actions: act on THIS panel. Frequent/destructive verbs grow text
labels when the panel is wide enough (container query). -->
<button
(click)="copyToOther()"
[title]="side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-primary"
>
<ng-icon [name]="copyIcon()" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.copy') }}</span>
</button>
<button
(click)="run('edit')"
[title]="i18n.t('panel.editFile')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFileText" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span>
</button>
<button
(click)="run('rename')"
[title]="i18n.t('panel.rename')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaEdit" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span>
</button>
<button
(click)="run('mkdir')"
[title]="i18n.t('panel.newFolder')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFolderPlus" size="var(--action-icon)" />
</button>
<button
(click)="run('createFile')"
[title]="i18n.t('panel.newFile')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFilePlus" size="var(--action-icon)" />
</button>
@if (chmodAvailable()) {
<button
(click)="run('chmod')"
[title]="i18n.t('panel.permissions')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaLock" size="var(--action-icon)" />
</button>
}
<button
(click)="run('delete')"
[title]="i18n.t('panel.deleteHint')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-destructive"
>
<ng-icon name="mynaTrash" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span>
</button>
@if (menuOpen()) {
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
<div
class="has-scrollbar absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl"
>
<button
(click)="pickLocal()"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1">{{ i18n.t('common.local') }}</span>
@if (!isRemote()) {
<ng-icon name="mynaCheck" size="0.85rem" class="shrink-0 text-primary" />
}
</button>
<!-- Windows drive roots (empty list elsewhere) -->
@for (drive of drives(); track drive) {
<button
(click)="pickDrive(drive)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 font-mono">{{ drive }}</span>
</button>
}
@if (connections.grouped().loose.length || connections.grouped().stacks.length) {
<div class="my-1 border-t border-border"></div>
}
@for (connection of connections.grouped().loose; track connection.id) {
<button
(click)="pickConnection(connection)"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ connection.name }}</span>
</button>
}
@for (group of connections.grouped().stacks; track group.stack.id) {
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
{{ group.stack.name }}
</div>
@for (connection of group.connections; track connection.id) {
<button
(click)="pickConnection(connection)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ connection.name }}</span>
</button>
}
}
@if (connections.sshHosts().length) {
<div class="my-1 border-t border-border"></div>
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
~/.ssh/config
</div>
@for (host of connections.sshHosts(); track host.name) {
<button
(click)="pickSshHost(host)"
[disabled]="host.unsupported"
[title]="sshHostTitle(host)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent disabled:opacity-60 disabled:hover:bg-transparent"
>
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ host.name }}</span>
</button>
}
}
</div>
}
</div>
<!-- path bar. Also w7-toolbar: the up/refresh controls sit next to the action
row, so leaving them as Aero slabs would read as arbitrary. The class only
targets buttons — the path <input> keeps its 7.css textbox treatment. -->
<div class="w7-toolbar flex items-center gap-1 border-b border-border px-2 py-1.5">
<button
(click)="panel().up()"
[disabled]="blocked()"
[title]="i18n.t('panel.parentDir')"
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
>
<ng-icon name="mynaArrowUp" size="1rem" />
</button>
<input
[value]="pathDraft()"
(input)="pathDraft.set($any($event.target).value)"
(keydown.enter)="commitPath()"
(keydown.escape)="resetPath()"
(keydown)="$event.stopPropagation()"
[disabled]="blocked()"
spellcheck="false"
class="h-(--path-ctl-h) min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
/>
<button
(click)="panel().refresh()"
[disabled]="blocked()"
[title]="i18n.t('panel.refresh')"
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
>
@if (panel().loading()) {
<ng-icon name="mynaSpinner" size="1rem" class="animate-spin" />
} @else {
<ng-icon name="mynaRefresh" size="1rem" />
}
</button>
</div>
<!-- Column headers live INSIDE the table now (7.css makes thead sticky), so
there is no separate header row here — see the ListView below. -->
<!-- type-to-filter bar (appears while a query is active) -->
@if (panel().filter(); as query) {
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
<span class="shrink-0 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('panel.matches', { count: filterMatches() }) }}
</span>
<button
(click)="clearFilter()"
[title]="i18n.t('panel.clearFilter')"
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.8rem" />
</button>
</div>
}
<!-- file list -->
<div class="relative min-h-0 flex-1">
<div
#list
tabindex="0"
(keydown)="onKeydown($event)"
(mousedown)="activate()"
(contextmenu)="listContextMenu($event)"
class="has-scrollbar h-full overflow-y-auto outline-none"
>
@if (panel().connecting()) {
<div class="panel-fade-in grid h-full place-items-center">
<div class="flex flex-col items-center gap-3">
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
<span class="text-xs text-muted-foreground">
{{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }}
</span>
<button
(click)="cancelConnect()"
class="rounded-md border border-border px-3 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ i18n.t('common.cancel') }}
</button>
</div>
</div>
} @else {
<!-- 7.css ListView: a plain <table>. The header is <thead> (7.css makes it
position:sticky, which is why it moved in here), the sorted column is
.highlighted.indicator[.up], and the selection band is tr.highlighted.
Widths come from <colgroup> because 7.css sets table-layout:fixed.
`w-full` is Tailwind's job and safe here: 7.css never declares `width` on
table, so the `skin` layer has nothing to outrank `utilities` with.
Without it the fixed layout shrinks to content and leaves a dead grey
gutter down the right of the pane; with it the unsized first <col>
absorbs the slack, which is what makes Name the elastic column. -->
<table class="w-full" [class.is-active]="isActive()">
<colgroup>
<col />
@for (col of visibleColumns(); track col.key) {
<col [style.width]="col.width" />
}
</colgroup>
<thead>
<tr (contextmenu)="headerContextMenu($event)">
<th
(click)="panel().toggleSort('name')"
[class.highlighted]="panel().sortKey() === 'name'"
[class.indicator]="panel().sortKey() === 'name'"
[class.up]="panel().sortKey() === 'name' && panel().sortAsc()"
>
{{ i18n.t('columns.name') }}
</th>
@for (col of visibleColumns(); track col.key) {
<th
(click)="panel().toggleSort(col.key)"
[class.highlighted]="panel().sortKey() === col.key"
[class.indicator]="panel().sortKey() === col.key"
[class.up]="panel().sortKey() === col.key && panel().sortAsc()"
[style.text-align]="col.key === 'size' ? 'right' : 'left'"
>
{{ i18n.t('columns.' + col.key) }}
</th>
}
</tr>
</thead>
<tbody>
@for (entry of panel().sorted(); track entry.name; let i = $index) {
<tr
[attr.data-index]="i"
(click)="rowClick($event, i)"
(dblclick)="openAt(i)"
(contextmenu)="rowContextMenu($event, i)"
[class.highlighted]="isSelected(entry) || i === panel().cursor()"
[class.is-cursor]="i === panel().cursor() && isActive()"
>
<td>
<span class="flex min-w-0 items-center gap-1.5">
<ng-icon [name]="iconFor(entry)" size="0.95rem" [class]="iconClass(entry)" />
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
</span>
</td>
@for (col of visibleColumns(); track col.key) {
@switch (col.key) {
@case ('size') {
<td style="text-align: right">{{ entry.type === 'dir' ? '—' : formatSize(entry.size) }}</td>
}
@case ('mtime') {
<td>{{ formatDate(entry.mtime) }}</td>
}
@case ('kind') {
<!-- fileKind is localized in file-panel.ts via i18n -->
<td>{{ fileKind(entry) }}</td>
}
@case ('rights') {
<td>{{ entry.name === '..' ? '' : formatMode(entry.mode) }}</td>
}
@case ('owner') {
<td>{{ entry.name === '..' ? '' : (entry.owner ?? '—') }}</td>
}
@case ('group') {
<td>{{ entry.name === '..' ? '' : (entry.group ?? '—') }}</td>
}
}
}
</tr>
} @empty {
<tr>
<td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 1.5rem">
{{ i18n.t('panel.emptyDir') }}
</td>
</tr>
}
@if (panel().filter() && filterMatches() === 0) {
<tr>
<td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 1.5rem">
{{ i18n.t('panel.noMatches') }}
</td>
</tr>
}
</tbody>
</table>
}
</div>
@if (panel().slowLoad() && !panel().connecting()) {
<div class="panel-fade-in absolute inset-0 z-10 grid place-items-center bg-background/60">
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
</div>
}
</div>
@@ -1,355 +0,0 @@
import {
Component,
ElementRef,
computed,
effect,
inject,
input,
signal,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
import { ConnectionsService } from '@core/connections.service';
import { ContextMenuService } from '@core/context-menu.service';
import { DialogsService } from '@core/dialogs.service';
import { FileActionsService } from '@core/file-actions.service';
import { fileGlyph, fileIconClass, fileKindKey } from '@core/file-icons';
import { formatMode } from '@core/format';
import { PanelsService, type Side, type SortKey } from '@core/panels.service';
import { IS_WINDOWS } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
import { TransfersService } from '@core/transfers.service';
/** Optional columns in fixed display order, with their track widths. */
const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
{ key: 'size', label: 'Size', width: '5.5rem' },
{ key: 'mtime', label: 'Modified', width: '8.5rem' },
{ key: 'kind', label: 'Kind', width: '5.5rem' },
{ key: 'rights', label: 'Rights', width: '6.5rem' },
{ key: 'owner', label: 'Owner', width: '6rem' },
{ key: 'group', label: 'Group', width: '6rem' },
];
@Component({
selector: 'app-file-panel',
imports: [NgIcon],
templateUrl: './file-panel.html',
host: { class: '@container flex min-w-0 flex-1 flex-col overflow-hidden' },
})
export class FilePanel {
readonly i18n = inject(I18nService);
readonly side = input.required<Side>();
readonly panels = inject(PanelsService);
readonly actions = inject(FileActionsService);
readonly transfers = inject(TransfersService);
readonly connections = inject(ConnectionsService);
readonly dialogs = inject(DialogsService);
private readonly menu = inject(ContextMenuService);
private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
private readonly rpc = inject(RpcService);
readonly panel = computed(() => this.panels.get(this.side()));
readonly isActive = computed(() => this.panels.activeSide() === this.side());
readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
/** Input is ignored while the panel is connecting or a listing is in flight. */
readonly blocked = computed(() => this.panel().busy());
/** The copy button points toward the other panel. */
readonly copyIcon = computed(() => (this.side() === 'left' ? 'mynaArrowRight' : 'mynaArrowLeft'));
readonly pathDraft = signal('');
readonly menuOpen = signal(false);
/** Windows drive roots for the source picker; always empty elsewhere. */
readonly drives = signal<string[]>([]);
/** chmod on Windows local files only flips the read-only bit — hide it. */
readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote()));
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
private lastClickedIndex = 0;
// locale-reactive wrappers: reading i18n.locale() inside makes the file-list
// cells re-render on a language switch (see i18n.service reactive t())
readonly formatSize = (bytes: number): string => this.i18n.size(bytes);
readonly formatDate = (ms: number): string => this.i18n.date(ms);
readonly formatMode = formatMode;
readonly fileKind = (entry: FileEntry): string => {
const key = fileKindKey(entry);
return key ? this.i18n.t('kinds.' + key) : '';
};
/** The optional columns currently shown, in canonical order. */
readonly visibleColumns = computed(() => {
const visible = this.settings.columns();
return COLUMN_DEFS.filter((c) => visible.includes(c.key));
});
headerContextMenu(event: MouseEvent): void {
event.preventDefault();
this.menu.open(event, this.menu.columnItems(this.settings.columns()));
}
pickDrive(drive: string): void {
this.menuOpen.set(false);
this.activate();
void this.panel().load(drive);
}
constructor() {
if (IS_WINDOWS && this.rpc.available) {
void this.rpc.request.listDrives({}).then((res) => {
if (res.ok) this.drives.set(res.drives);
});
}
// keep the editable path bar in sync with the actual cwd
effect(() => this.pathDraft.set(this.panel().cwd()));
// focus follows the active side (Tab switches panels, dialogs refocus on close)
effect(() => {
this.panels.focusTick();
if (this.isActive()) this.listEl().nativeElement.focus();
});
}
activate(): void {
this.panels.setActiveSide(this.side());
}
cancelConnect(): void {
this.panels.cancelConnect(this.side());
}
copyToOther(): void {
if (this.blocked()) return;
this.activate();
void this.transfers.copyToOtherPanel(this.side());
}
run(action: 'edit' | 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void {
if (this.blocked()) return;
this.activate();
void this.actions[action](this.panel());
}
pickLocal(): void {
this.menuOpen.set(false);
void this.panels.disconnectPanel(this.side());
}
pickConnection(connection: SavedConnection): void {
this.menuOpen.set(false);
this.activate();
void this.panels.connectPanel(this.side(), connection, connection.name, connection.localDir);
}
toggleMenu(): void {
this.menuOpen.set(!this.menuOpen());
// re-read ~/.ssh/config on each open so external edits show up
if (this.menuOpen()) void this.connections.reloadSshHosts();
}
pickSshHost(host: SshConfigHost): void {
this.menuOpen.set(false);
this.activate();
void this.panels.connectPanel(
this.side(),
{
host: host.hostname,
port: host.port,
username: host.username,
auth: host.identityFile
? { kind: 'key', privateKeyPath: host.identityFile }
: { kind: 'agent' },
initialPath: null,
},
host.name,
);
}
sshHostTitle(host: SshConfigHost): string {
if (host.unsupported === 'proxy') return 'Uses ProxyJump/ProxyCommand — not supported';
return `${host.username}@${host.hostname}:${host.port}`;
}
iconFor(entry: FileEntry): string {
return fileGlyph(entry, this.settings.iconColors() === 'multi');
}
iconClass(entry: FileEntry): string {
return fileIconClass(entry, this.settings.iconColors() === 'multi');
}
isSelected(entry: FileEntry): boolean {
return this.panel().selected().has(entry.name);
}
clearFilter(): void {
this.panel().setFilter('');
this.listEl().nativeElement.focus();
}
filterMatches(): number {
return this.panel()
.sorted()
.filter((e) => e.name !== '..').length;
}
commitPath(): void {
if (this.blocked()) return;
const value = this.pathDraft().trim();
if (value) void this.panel().load(value);
this.listEl().nativeElement.focus();
}
resetPath(): void {
this.pathDraft.set(this.panel().cwd());
this.listEl().nativeElement.focus();
}
rowClick(event: MouseEvent, index: number): void {
if (this.blocked()) return;
this.activate();
const panel = this.panel();
if (event.shiftKey) {
panel.selectRange(this.lastClickedIndex, index);
} else if (this.shortcuts.isMod(event)) {
const entry = panel.sorted()[index];
if (entry) panel.toggleSelect(entry.name);
} else {
panel.clearSelection();
}
panel.cursor.set(index);
this.lastClickedIndex = index;
}
rowContextMenu(event: MouseEvent, index: number): void {
event.preventDefault();
event.stopPropagation();
if (this.blocked()) return;
this.activate();
const panel = this.panel();
const entry = panel.sorted()[index] ?? null;
// standard behavior: keep the selection when right-clicking inside it
if (entry && !panel.selected().has(entry.name)) panel.clearSelection();
panel.cursor.set(index);
this.actions.openContextMenu(event, { side: this.side(), panel, entry });
}
listContextMenu(event: MouseEvent): void {
event.preventDefault();
if (this.blocked()) return;
this.activate();
this.actions.openContextMenu(event, { side: this.side(), panel: this.panel(), entry: null });
}
openAt(index: number): void {
if (this.blocked()) return;
const entry = this.panel().sorted()[index];
if (entry) this.panel().open(entry);
}
onKeydown(event: KeyboardEvent): void {
if (this.blocked()) return;
const panel = this.panel();
const key = event.key;
if (key === 'Tab') {
event.preventDefault();
this.panels.setActiveSide(this.panels.otherSide(this.side()));
return;
}
if (this.shortcuts.isMod(event) && key.toLowerCase() === 'a') {
event.preventDefault();
panel.selectAll();
return;
}
// type-to-filter: printable characters build the query (Space only once a
// query exists — an empty-filter Space keeps its toggle-select meaning)
if (
key.length === 1 &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
(key !== ' ' || panel.filter() !== '')
) {
event.preventDefault();
panel.setFilter(panel.filter() + key);
this.scrollCursorIntoView();
return;
}
if (key === 'Backspace' && panel.filter() !== '') {
event.preventDefault();
panel.setFilter(panel.filter().slice(0, -1));
return;
}
if (key === 'Escape' && panel.filter() !== '') {
event.preventDefault();
panel.setFilter('');
return;
}
switch (key) {
case 'ArrowDown':
panel.moveCursor(1);
break;
case 'ArrowUp':
panel.moveCursor(-1);
break;
case 'PageDown':
panel.moveCursor(20);
break;
case 'PageUp':
panel.moveCursor(-20);
break;
case 'Home':
panel.cursor.set(0);
break;
case 'End':
panel.cursor.set(Math.max(0, panel.sorted().length - 1));
break;
case ' ':
{
const entry = panel.sorted()[panel.cursor()];
if (entry) panel.toggleSelect(entry.name);
panel.moveCursor(1);
}
break;
case 'Enter':
this.openAt(panel.cursor());
return void event.preventDefault();
case 'Backspace':
panel.up();
break;
case 'F2':
this.actions.rename(panel);
break;
case 'F4':
void this.actions.edit(panel);
break;
case 'F5':
void this.transfers.copyToOtherPanel(this.side());
break;
case 'F7':
this.actions.mkdir(panel);
break;
case 'F8':
case 'Delete':
this.actions.delete(panel);
break;
default:
return; // let unhandled keys through
}
event.preventDefault();
this.scrollCursorIntoView();
}
private scrollCursorIntoView(): void {
const row = this.listEl().nativeElement.querySelector(
`[data-index="${this.panel().cursor()}"]`,
);
row?.scrollIntoView({ block: 'nearest' });
}
}
-17
View File
@@ -1,17 +0,0 @@
import { App } from './app';
import { ConnectionManager } from './connection-manager/connection-manager';
import { EditorWindow } from './editor-window/editor-window';
/**
* The three window roots of the Windows 7 skin, keyed by APP_MODE.
*
* Every skin exports this same shape, so main.ts selects a tree by skin and
* then a root by mode without knowing anything about either skin's internals.
* All three roots declare `selector: 'app-root'` — the single <app-root> in
* index.html hosts whichever one gets bootstrapped.
*/
export const SEVEN_ROOTS = {
main: App,
manager: ConnectionManager,
editor: EditorWindow,
} as const;
@@ -1,81 +0,0 @@
import { Component, computed, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import type { TransferSnapshot } from '@shared/types';
import { formatSpeed } from '@core/format';
import { PanelsService } from '@core/panels.service';
import { SettingsService } from '@core/settings.service';
import { TransfersService } from '@core/transfers.service';
@Component({
selector: 'app-status-bar',
imports: [NgIcon],
template: `
<div
class="flex h-6 items-center justify-between gap-4 border-t border-border bg-chrome px-3 text-[0.6875rem] text-muted-foreground select-none"
>
<!-- transfers summary; click toggles the detailed list -->
<!-- w7-flat: this is a text toggle, not a push button. Without it 7.css
paints the full Aero slab and the status line reads as a stray
bordered button sitting in the status bar. -->
<button
(click)="transfers.expanded.set(!transfers.expanded())"
class="w7-flat flex min-w-0 items-center gap-1.5 hover:text-foreground"
[title]="i18n.t('transfer.showDetails')"
>
@if (transfers.currentJob(); as job) {
<ng-icon name="mynaSpinner" size="0.8rem" class="shrink-0 animate-spin text-primary" />
<span class="truncate">
{{ i18n.t('transfer.copying', { name: job.name, percent: percent(job), speed: speedSuffix(job) }) }}
@if (transfers.queuedCount(); as queued) {
· {{ i18n.t('transfer.queued', { count: queued }) }}
}
</span>
} @else if (transfers.failedJobs().length; as failed) {
<ng-icon name="mynaXCircle" size="0.8rem" class="shrink-0 text-destructive" />
<span class="text-destructive">
{{ i18n.t('transfer.failed', { count: failed }) }}
</span>
} @else if (transfers.hasJobs()) {
<ng-icon name="mynaCheckCircle" size="0.8rem" class="shrink-0 text-success" />
<span>{{ i18n.t('transfer.finished') }}</span>
} @else {
<span>{{ i18n.t('transfer.ready') }}</span>
}
</button>
<!-- active panel info -->
<div class="flex shrink-0 items-center gap-1.5">
<ng-icon
[name]="activePanel().sessionId() === 'local' ? 'mynaHardDrive' : 'mynaServers'"
size="0.8rem"
[class.text-primary]="activePanel().sessionId() !== 'local'"
/>
<span class="max-w-56 truncate">
{{ activePanel().sessionId() === 'local' ? i18n.t('common.local') : activePanel().title() }}
</span>
<span>· {{ i18n.t('transfer.items', { count: activePanel().sorted().length }) }}</span>
@if (activePanel().selected().size; as count) {
<span>· {{ i18n.t('transfer.selected', { count }) }}</span>
}
</div>
</div>
`,
})
export class StatusBar {
readonly i18n = inject(I18nService);
readonly transfers = inject(TransfersService);
readonly panels = inject(PanelsService);
readonly settings = inject(SettingsService);
readonly activePanel = computed(() => this.panels.get(this.panels.activeSide()));
percent(job: TransferSnapshot): number {
if (!job.totalBytes) return 0;
return Math.min(100, Math.round((job.doneBytes / job.totalBytes) * 100));
}
speedSuffix(job: TransferSnapshot): string {
const speed = this.transfers.speeds().get(job.id);
return speed ? ` · ${formatSpeed(speed, this.settings.speedUnit())}` : '';
}
}
-621
View File
@@ -1,621 +0,0 @@
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { BRANDING } from '@shared/branding';
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
import type { AccentTone } from '@shared/types';
import { ContextMenuService } from '@core/context-menu.service';
import { I18nService } from '@core/i18n.service';
import { PanelsService, type Workspace } from '@core/panels.service';
import { APP_SKIN, MOD_LABEL } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { UpdaterService } from '@core/updater.service';
const SKINS = ['modern', 'win7'] as const;
const THEMES = ['system', 'light', 'dark'] as const;
const DARK_TONES = ['dark', 'darker'] as const;
// Swatch colors are fixed mid-tones so every dot reads on both light and dark
// cards (the live --primary values would vanish against one of them). Names are
// translated via options.accentNames.<value>.
const ACCENTS: readonly { value: AccentTone; swatch: string }[] = [
{ value: 'graphite', swatch: 'oklch(0.552 0.016 285.9)' },
{ value: 'blue', swatch: 'oklch(0.65 0.17 255)' },
{ value: 'indigo', swatch: 'oklch(0.65 0.19 277)' },
{ value: 'purple', swatch: 'oklch(0.65 0.17 305)' },
{ value: 'rose', swatch: 'oklch(0.65 0.17 8)' },
{ value: 'orange', swatch: 'oklch(0.68 0.17 50)' },
{ value: 'green', swatch: 'oklch(0.65 0.15 150)' },
{ value: 'teal', swatch: 'oklch(0.65 0.12 185)' },
];
const SPEED_UNITS = [
{ value: 'MBps', label: 'MB/s' },
{ value: 'Mbps', label: 'Mbps' },
] as const;
const ICON_COLOR_MODES = ['plain', 'multi'] as const;
const TRANSFER_MODES = ['single', 'multi'] as const;
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@Component({
selector: 'app-tab-bar',
imports: [NgIcon],
template: `
<!-- The workspace strip, directly under the title bar. Draggable so the
empty stretch between the tabs and the right-hand tools still moves the
frameless window. ONE class attribute: a second one is silently dropped
by the template parser, which is exactly how this row lost its height. -->
<div
class="w7-toolbar relative flex h-9 items-center gap-0.5 border-b border-border bg-chrome pt-1 pr-1.5 pl-1.5 electrobun-webkit-app-region-drag"
>
<!-- 7.css's real tab component. The contract is exact: menu[role=tablist]
for the strip and a button[aria-selected] per tab — 7.css then draws
the joined inactive tabs and raises the active one (white fill, no
bottom border, pulled 2px down over the row's border so it reads as
joined to the content). Everything the hand-rolled version painted
(bg-accent, the absolutely-positioned bg-primary underline) is gone:
it was a lookalike competing with the framework.
NOTE the selector is "menu[role=tablist] button" — a DESCENDANT, so
any button in here becomes a tab. The "+" and the right-hand tools
deliberately live OUTSIDE this element.
(No backticks in this template: it is a TS template literal.) -->
<menu
#tabScroll
role="tablist"
class="flex min-w-0 items-center overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
@for (ws of panels.workspaces(); track ws.id; let i = $index) {
<!-- No ✕ inside the tab: real Win7 tab controls had none, and a button
nested in a button is invalid HTML. Closing is Ctrl/Cmd+W or
middle-click (auxclick), so a pointer path survives. -->
<button
role="tab"
[attr.aria-selected]="ws.id === panels.activeWorkspaceId()"
[attr.data-tab-id]="ws.id"
(click)="panels.setActiveWorkspace(ws.id)"
(auxclick)="onAuxClick($event, ws.id)"
(contextmenu)="onTabContextMenu($event, ws, i)"
class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5 electrobun-webkit-app-region-no-drag"
[class.opacity-50]="draggedId() === ws.id"
[style.box-shadow]="dropIndicatorShadow(i)"
[title]="panels.workspaceName(ws)"
draggable="true"
(dragstart)="onDragStart($event, ws.id)"
(dragover)="onDragOver($event, i)"
(drop)="onDrop($event, i)"
(dragend)="onDragEnd()"
>
<ng-icon
[name]="panels.workspaceIsRemote(ws) ? 'mynaServers' : 'mynaHardDrive'"
size="0.85rem"
class="shrink-0"
/>
<span class="truncate">{{ panels.workspaceName(ws) }}</span>
</button>
}
</menu>
<button
(click)="panels.newWorkspace()"
[title]="i18n.t('tabBar.newTab', { mod: modLabel })"
class="ml-0.5 grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaPlus" size=".8rem" />
</button>
<div class="flex-1"></div>
<!-- auto-update tool: one accent pill that announces the update, becomes its
own progress bar while downloading, then turns into "Restart to apply" -->
@if (updater.state() !== 'idle' && updater.state() !== 'error') {
<button
(click)="onUpdateClick()"
[disabled]="updater.state() === 'downloading'"
[title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })"
class="relative mr-1 grid h-6 place-items-center overflow-hidden rounded-md px-2.5 text-[0.6875rem] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default"
>
@if (updater.state() === 'downloading') {
<span
class="absolute inset-y-0 left-0 bg-primary-foreground/25 transition-[width] duration-200"
[style.width.%]="updater.progress()"
></span>
}
<span class="relative flex items-center gap-1.5">
@switch (updater.state()) {
@case ('available') {
<ng-icon name="mynaDownload" size=".8rem" />{{ i18n.t('tabBar.update') }}
}
@case ('downloading') {
<ng-icon name="mynaSpinner" size=".8rem" class="animate-spin" />{{ i18n.t('tabBar.updating') }} {{ updater.progress() }}%
}
@case ('ready') {
<ng-icon name="mynaRefresh" size=".8rem" />{{ i18n.t('tabBar.restartToUpdate') }}
}
}
</span>
</button>
}
<!-- connection manager (own native window) -->
<button
(click)="openManager()"
[title]="i18n.t('tabBar.manageConnections')"
class="grid h-6 px-2 py-1 place-items-center rounded-md text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<!-- <ng-icon name="mynaCog" size="0.9rem" /> -->
{{ i18n.t('tabBar.connections') }}
</button>
<!-- global options -->
<button
(click)="settingsOpen.set(!settingsOpen())"
[title]="i18n.t('tabBar.options')"
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaDots" size="1.5rem" />
</button>
@if (settingsOpen()) {
<div class="fixed inset-0 z-20" (click)="settingsOpen.set(false)"></div>
<div
class="absolute top-full right-1.5 z-30 mt-1 w-70 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-xl electrobun-webkit-app-region-no-drag"
>
<h3 class="text-xs font-semibold">{{ i18n.t('options.title') }}</h3>
<div class="mt-3 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.language') }}</span>
<select
(change)="settings.setLocale($any($event.target).value)"
class="h-7 rounded-md border border-input bg-background px-1.5 text-[0.6875rem] outline-none focus:ring-2 focus:ring-ring"
>
<option value="system" [selected]="settings.locale() === 'system'">
{{ i18n.t('options.system') }}
</option>
@for (loc of localeOptions; track loc) {
<option [value]="loc" [selected]="settings.locale() === loc">
{{ localeLabels[loc] }}
</option>
}
</select>
</div>
<!-- Skin: a LAUNCH-TIME choice. It swaps the whole component tree
(ui/src/app/skins/) and the native frame, and neither can change
on a live window — the tree is bootstrapped from the preload-baked
__APP_SKIN and titleBarStyle/transparent are BrowserWindow
constructor options. Clicking only saves; the running UI is left
alone. Highlight the SAVED value so the click registers, and show
the pending hint whenever it differs from the booted skin. -->
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.skin.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (s of skins; track s) {
<button
(click)="settings.setSkin(s)"
[title]="i18n.t('options.skin.restartHint')"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.skin() === s"
[class.text-foreground]="settings.skin() === s"
[class.text-muted-foreground]="settings.skin() !== s"
>
{{ i18n.t('options.skin.' + s) }}
</button>
}
</div>
</div>
@if (skinPending()) {
<p class="mt-1 text-right text-[0.625rem] text-muted-foreground">
{{ i18n.t('options.skin.restartHint') }}
</p>
}
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.theme.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (theme of themes; track theme) {
<button
(click)="settings.setTheme(theme)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.theme() === theme"
[class.text-foreground]="settings.theme() === theme"
[class.text-muted-foreground]="settings.theme() !== theme"
>
{{ i18n.t('options.theme.' + theme) }}
</button>
}
</div>
</div>
@if (settings.effectiveDark()) {
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.tone.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (tone of darkTones; track tone) {
<button
(click)="settings.setDarkTone(tone)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.darkTone() === tone"
[class.text-foreground]="settings.darkTone() === tone"
[class.text-muted-foreground]="settings.darkTone() !== tone"
>
{{ i18n.t('options.tone.' + tone) }}
</button>
}
</div>
</div>
}
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.accent.label') }}</span>
<div class="flex items-center gap-1.5">
@for (tone of accents; track tone.value) {
<button
(click)="settings.setAccent(tone.value)"
[title]="i18n.t('options.accentNames.' + tone.value)"
class="size-3 rounded-full border border-foreground/15"
[style.background-color]="tone.swatch"
[class.ring-2]="settings.accent() === tone.value"
[class.ring-foreground]="settings.accent() === tone.value"
[class.ring-offset-1]="settings.accent() === tone.value"
[class.ring-offset-card]="settings.accent() === tone.value"
></button>
}
</div>
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.fileIcons.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (mode of iconColorModes; track mode) {
<button
(click)="settings.setIconColors(mode)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.iconColors() === mode"
[class.text-foreground]="settings.iconColors() === mode"
[class.text-muted-foreground]="settings.iconColors() !== mode"
>
{{ i18n.t('options.fileIcons.' + mode) }}
</button>
}
</div>
</div>
<!-- Interface scale: a continuous stepper, 50%..400% in 10-point
steps. One lever moves the whole UI (root font-size; everything
is rem), which is also why this popover itself grows with it. -->
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.interface.label') }}</span>
<div class="flex items-center rounded-md border border-border p-0.5">
<button
(click)="settings.setUiScale(settings.uiScale() - 0.1)"
[disabled]="settings.uiScale() <= 0.5"
[title]="i18n.t('options.interface.smaller')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
−
</button>
<button
(click)="settings.setUiScale(1)"
[title]="i18n.t('options.interface.reset')"
class="w-11 rounded px-1 py-0.5 text-center text-[0.6875rem] tabular-nums hover:bg-accent"
>
{{ scalePercent() }}
</button>
<button
(click)="settings.setUiScale(settings.uiScale() + 0.1)"
[disabled]="settings.uiScale() >= 4"
[title]="i18n.t('options.interface.larger')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
+
</button>
</div>
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.speedUnit.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (unit of speedUnits; track unit.value) {
<button
(click)="settings.setSpeedUnit(unit.value)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.speedUnit() === unit.value"
[class.text-foreground]="settings.speedUnit() === unit.value"
[class.text-muted-foreground]="settings.speedUnit() !== unit.value"
>
{{ unit.label }}
</button>
}
</div>
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.transfers.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (mode of transferModes; track mode) {
<button
(click)="settings.setTransferMode(mode)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.transferMode() === mode"
[class.text-foreground]="settings.transferMode() === mode"
[class.text-muted-foreground]="settings.transferMode() !== mode"
>
{{ i18n.t('options.transfers.' + mode) }}
</button>
}
</div>
</div>
@if (settings.transferMode() === 'multi') {
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.atOnce') }}</span>
<div class="flex rounded-md border border-border p-0.5">
@for (count of concurrencyChoices; track count) {
<button
(click)="settings.setTransferConcurrency(count)"
class="rounded px-2 py-0.5 text-[0.6875rem] tabular-nums"
[class.bg-accent]="settings.transferConcurrency() === count"
[class.text-foreground]="settings.transferConcurrency() === count"
[class.text-muted-foreground]="settings.transferConcurrency() !== count"
>
{{ count }}
</button>
}
</div>
</div>
}
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.editor.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
<button
(click)="settings.setEditor('builtin')"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.editor() === 'builtin'"
[class.text-foreground]="settings.editor() === 'builtin'"
[class.text-muted-foreground]="settings.editor() !== 'builtin'"
>
{{ i18n.t('options.editor.builtin') }}
</button>
<button
(click)="settings.setEditor('custom')"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.editor() === 'custom'"
[class.text-foreground]="settings.editor() === 'custom'"
[class.text-muted-foreground]="settings.editor() !== 'custom'"
>
{{ i18n.t('options.editor.custom') }}
</button>
</div>
</div>
@if (settings.editor() === 'custom') {
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
<span class="min-w-0 truncate text-xs text-muted-foreground" [title]="settings.editorApp()">
{{ editorAppName() }}
</span>
<div class="flex shrink-0 items-center gap-1">
<button
(click)="pickEditorApp()"
class="rounded-md border border-border px-2 py-0.5 text-[0.6875rem] hover:bg-accent"
>
{{ i18n.t('common.choose') }}
</button>
@if (settings.editorApp()) {
<button
(click)="settings.setEditorApp('')"
[title]="i18n.t('options.resetDefault')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.7rem" />
</button>
}
</div>
</div>
}
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.finishedTransfers.label') }}</span>
<div class="flex rounded-md border border-border p-0.5">
<button
(click)="settings.setAutoClearCompleted(true)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.autoClearCompleted()"
[class.text-foreground]="settings.autoClearCompleted()"
[class.text-muted-foreground]="!settings.autoClearCompleted()"
>
{{ i18n.t('options.finishedTransfers.autoClear') }}
</button>
<button
(click)="settings.setAutoClearCompleted(false)"
class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="!settings.autoClearCompleted()"
[class.text-foreground]="!settings.autoClearCompleted()"
[class.text-muted-foreground]="settings.autoClearCompleted()"
>
{{ i18n.t('options.finishedTransfers.keep') }}
</button>
</div>
</div>
<!-- Auto-refresh is always on for now. To expose a toggle, uncomment
the breadcrumbs in types.ts + settings.service, then this row:
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">Auto-refresh</span>
<div class="flex rounded-md border border-border p-0.5">
<button (click)="settings.setAutoRefresh(true)" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.autoRefresh()"
[class.text-foreground]="settings.autoRefresh()"
[class.text-muted-foreground]="!settings.autoRefresh()">On</button>
<button (click)="settings.setAutoRefresh(false)" class="rounded px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="!settings.autoRefresh()"
[class.text-foreground]="!settings.autoRefresh()"
[class.text-muted-foreground]="settings.autoRefresh()">Off</button>
</div>
</div>
-->
<!-- about footer -->
<button
(click)="showAbout()"
class="mt-3 w-full border-t border-border pt-2 text-center text-[0.6875rem] text-muted-foreground hover:text-foreground"
>
{{ appName }} · {{ buildLabel }}
</button>
</div>
}
</div>
`,
})
export class TabBar {
readonly panels = inject(PanelsService);
readonly settings = inject(SettingsService);
readonly i18n = inject(I18nService);
readonly updater = inject(UpdaterService);
private readonly contextMenu = inject(ContextMenuService);
private readonly rpc = inject(RpcService);
readonly settingsOpen = signal(false);
readonly localeOptions = SUPPORTED;
readonly localeLabels = LOCALE_LABELS;
readonly modLabel = MOD_LABEL;
readonly appName = BRANDING.appName;
readonly buildLabel = BRANDING.buildLabel;
/** What the window was BUILT with, not the current setting — the frameless
* chrome only changes on reopen, so the hint compares against this. */
readonly skins = SKINS;
/**
* The saved skin differs from the one this window actually booted, so the
* choice is waiting on a restart. APP_SKIN is the preload-baked value that
* picked the running component tree; settings.skin() is merely persisted.
*/
readonly skinPending = computed(() => this.settings.skin() !== APP_SKIN);
readonly themes = THEMES;
readonly darkTones = DARK_TONES;
readonly accents = ACCENTS;
readonly iconColorModes = ICON_COLOR_MODES;
readonly speedUnits = SPEED_UNITS;
/** "100%" for the stepper readout; the value itself lives in settings. */
readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`);
readonly transferModes = TRANSFER_MODES;
readonly concurrencyChoices = CONCURRENCY_CHOICES;
// drag & drop reordering
readonly draggedId = signal<number | null>(null);
readonly dropTarget = signal<{ index: number; before: boolean } | null>(null);
private readonly tabScroll = viewChild.required<ElementRef<HTMLMenuElement>>('tabScroll');
constructor() {
// keep the active tab visible when the strip overflows
effect(() => {
const id = this.panels.activeWorkspaceId();
this.tabScroll()
.nativeElement.querySelector(`[data-tab-id="${id}"]`)
?.scrollIntoView({ inline: 'nearest', block: 'nearest' });
});
}
close(event: MouseEvent, id: number): void {
event.stopPropagation();
this.panels.closeWorkspace(id);
}
/**
* Middle-click closes the tab — the pointer path that replaced the per-tab ✕.
* `auxclick` is the non-primary-button click event; guard on button 1 so the
* right button (which also fires auxclick) never closes anything. Refuses the
* last tab, matching closeWorkspace's own floor.
*/
onAuxClick(event: MouseEvent, id: number): void {
if (event.button !== 1) return;
event.preventDefault();
if (this.panels.workspaces().length > 1) this.close(event, id);
}
/**
* Right-click a tab. The menu targets THIS tab, not the active one — its
* actions all carry the id — so right-clicking a background tab does not
* yank focus to it first, the way Explorer and every browser behave.
*/
onTabContextMenu(event: MouseEvent, workspace: Workspace, index: number): void {
event.preventDefault();
this.contextMenu.open(
event,
this.contextMenu.tabItems({
id: workspace.id,
index,
tabCount: this.panels.workspaces().length,
isRemote: this.panels.workspaceIsRemote(workspace),
}),
);
}
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
editorAppName(): string {
const path = this.settings.editorApp();
if (!path) return this.i18n.t('options.systemDefault');
const base = path.split('/').filter(Boolean).pop() ?? path;
return base.replace(/\.app$/i, '');
}
openManager(): void {
void this.rpc.request.openConnectionManager({});
}
/** Update pill: first click downloads, second click (when ready) applies + relaunches. */
onUpdateClick(): void {
const state = this.updater.state();
if (state === 'available') void this.updater.download();
else if (state === 'ready') this.updater.apply();
}
showAbout(): void {
void this.rpc.request.showAbout({});
}
async pickEditorApp(): Promise<void> {
const res = await this.rpc.request.pickEditorApp({});
if (res.ok && res.path) this.settings.setEditorApp(res.path);
}
/** Insertion line at the drop position (inset shadow: no layout shift). */
dropIndicatorShadow(index: number): string | null {
const target = this.dropTarget();
if (!target || target.index !== index || this.draggedId() === null) return null;
return target.before
? 'inset 2px 0 0 var(--primary)'
: 'inset -2px 0 0 var(--primary)';
}
onDragStart(event: DragEvent, id: number): void {
this.draggedId.set(id);
// WebKit refuses to start a drag without data
event.dataTransfer?.setData('text/plain', String(id));
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
}
onDragOver(event: DragEvent, index: number): void {
if (this.draggedId() === null) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
const el = event.currentTarget as HTMLElement;
const before = event.clientX < el.getBoundingClientRect().left + el.offsetWidth / 2;
this.dropTarget.set({ index, before });
}
onDrop(event: DragEvent, index: number): void {
event.preventDefault();
const id = this.draggedId();
const target = this.dropTarget();
this.onDragEnd();
if (id === null || !target) return;
const list = this.panels.workspaces();
const fromIndex = list.findIndex((w) => w.id === id);
if (fromIndex < 0) return;
let toIndex = target.before ? index : index + 1;
if (fromIndex < toIndex) toIndex--; // account for removal before insert
if (toIndex !== fromIndex) this.panels.moveWorkspace(id, toIndex);
}
onDragEnd(): void {
this.draggedId.set(null);
this.dropTarget.set(null);
}
}
@@ -1,81 +0,0 @@
import { Component, inject, input, output, signal } from '@angular/core';
import { RpcService } from '@core/rpc.service';
/**
* The Aero title bar. Rendered by all three window kinds. The native frame is
* gone on every platform (titleBarStyle 'hidden'), so this row IS the window's
* chrome.
*
* The markup is 7.css's contract, not ours: `.title-bar` > `.title-bar-text` +
* `.title-bar-controls` > buttons keyed by `aria-label` (Minimize / Maximize /
* Restore / Close each get their own glyph). Nothing here is styled with
* Tailwind on purpose — the whole row comes from @layer skin.
*
* `.title-bar` lives on the HOST element, not on an inner div. 7.css couples
* the glass treatment with a DIRECT-child selector — `.window.glass >
* .title-bar` — and an inner div puts <app-title-bar> in between, so the rule
* never matched: the title bar rendered as the opaque fallback with its own
* border, visibly disconnected from the frame, instead of the translucent
* streaked glass that fuses with it.
*
* Dragging rides on electrobun's preload drag-region classes. Two things
* electrobun does NOT provide and this component implements:
* - double-click to maximize (it only wires mousedown/mouseup)
* - the maximize/restore glyph state, which has no event; bun derives it
* from the native resize and pushes `windowState` (see trackMaximizeState).
*/
@Component({
selector: 'app-title-bar',
host: {
class: 'title-bar electrobun-webkit-app-region-drag',
// Published so the FRAME can react: a maximized Win7 window has square
// corners. The window root reads this off its own child via :has() rather
// than subscribing to windowState a second time — see styles.css.
'[class.is-maximized]': 'maximized()',
'(dblclick)': 'toggle()',
},
template: `
<div class="title-bar-text">{{ title() }}</div>
<div class="title-bar-controls electrobun-webkit-app-region-no-drag">
@if (resizable()) {
<button aria-label="Minimize" (click)="minimize()"></button>
<button
[attr.aria-label]="maximized() ? 'Restore' : 'Maximize'"
(click)="toggle()"
></button>
}
<button aria-label="Close" (click)="closeRequested.emit()"></button>
</div>
`,
})
export class TitleBar {
readonly title = input('');
/** The manager window is a fixed-size modal — it gets Close only. */
readonly resizable = input(true);
/** Hosts decide what closing means (quit / dirty prompt / managerClose). */
readonly closeRequested = output<void>();
protected readonly maximized = signal(false);
private readonly rpc = inject(RpcService);
constructor() {
if (!this.rpc.available) return;
// No maximize event exists, so seed from a one-shot read and then follow
// the resize-derived push (which also covers Aero Snap and Win+Up).
void this.rpc.request.windowIsMaximized({}).then((res) => {
if (res.ok) this.maximized.set(res.maximized);
});
this.rpc.onWindowState((maximized) => this.maximized.set(maximized));
}
protected minimize(): void {
void this.rpc.request.windowMinimize({});
}
protected toggle(): void {
void this.rpc.request.windowToggleMaximize({}).then((res) => {
if (res.ok) this.maximized.set(res.maximized);
});
}
}
-50
View File
@@ -1,50 +0,0 @@
import { Component, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { ToastService } from '@core/toast.service';
/** Toast stack overlay; shared by the main window and editor windows. */
@Component({
selector: 'app-toasts',
imports: [NgIcon],
template: `
<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>
`,
})
export class Toasts {
readonly toast = inject(ToastService);
toastIcon(kind: 'error' | 'info' | 'success'): string {
if (kind === 'error') return 'mynaXCircle';
if (kind === 'success') return 'mynaCheckCircle';
return 'mynaCheck';
}
}
@@ -1,59 +0,0 @@
@if (transfers.expanded() && transfers.hasJobs()) {
<div class="flex items-center justify-between border-t border-border bg-chrome px-3 py-1 text-[0.6875rem] text-muted-foreground">
<span>
{{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }}
</span>
<button
(click)="transfers.clearFinished()"
[disabled]="!transfers.finishedJobs().length"
class="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
<ng-icon name="mynaTrash" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }}
</button>
</div>
<div class="has-scrollbar max-h-40 overflow-y-auto border-t border-border bg-chrome">
@for (job of transfers.jobs(); track job.id) {
<div class="flex items-center gap-2 px-3 py-1.5 text-xs">
<ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" />
<span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span>
<div class="min-w-0 flex-1">
<div class="truncate text-[0.6875rem] text-muted-foreground">{{ progressLabel(job) }}</div>
@if (isActive(job)) {
<!-- role+aria are 7.css's hook for the Win7 progress bar
([role=progressbar] > div); under the modern skin they are
inert and the Tailwind classes below still draw the bar. -->
<div
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
[attr.aria-valuenow]="jobPercent(job)"
class="mt-0.5 h-1 overflow-hidden rounded-full bg-muted"
>
<div
class="h-full rounded-full bg-primary transition-[width] duration-100"
[style.width.%]="jobPercent(job)"
></div>
</div>
}
</div>
@if (isActive(job)) {
<button
(click)="transfers.cancel(job.id)"
[title]="i18n.t('transfer.cancel')"
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
>
<ng-icon name="mynaX" size="0.9rem" />
</button>
} @else {
<button
(click)="transfers.dismiss(job.id)"
[title]="i18n.t('transfer.dismiss')"
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.9rem" />
</button>
}
</div>
}
</div>
}
@@ -1,82 +0,0 @@
import { Component, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import type { TransferSnapshot } from '@shared/types';
import { formatEta } from '@core/format';
import { I18nService } from '@core/i18n.service';
import { SettingsService } from '@core/settings.service';
import { TransfersService } from '@core/transfers.service';
@Component({
selector: 'app-transfer-bar',
imports: [NgIcon],
templateUrl: './transfer-bar.html',
})
export class TransferBar {
readonly i18n = inject(I18nService);
readonly transfers = inject(TransfersService);
readonly settings = inject(SettingsService);
readonly formatSize = (bytes: number): string => this.i18n.size(bytes);
/** "14.2 MB/s · 0:42 left" while a job is moving bytes. */
speedLabel(job: TransferSnapshot): string {
if (job.state !== 'running') return '';
const speed = this.transfers.speeds().get(job.id);
if (!speed) return '';
const parts = [this.i18n.speed(speed, this.settings.speedUnit())];
const remaining = job.totalBytes - job.doneBytes;
if (remaining > 0 && speed > 0) {
parts.push(this.i18n.t('transfer.left', { eta: formatEta(remaining / speed) }));
}
return parts.join(' · ');
}
stateIcon(job: TransferSnapshot): string {
switch (job.state) {
case 'completed':
return 'mynaCheckCircle';
case 'failed':
case 'cancelled':
return 'mynaXCircle';
default:
return 'mynaSpinner';
}
}
stateClass(job: TransferSnapshot): string {
switch (job.state) {
case 'completed':
return 'shrink-0 text-success';
case 'failed':
return 'shrink-0 text-destructive';
case 'cancelled':
return 'shrink-0 text-muted-foreground';
default:
return 'shrink-0 animate-spin text-primary';
}
}
isActive(job: TransferSnapshot): boolean {
return job.state === 'queued' || job.state === 'scanning' || job.state === 'running';
}
jobPercent(job: TransferSnapshot): number {
if (!job.totalBytes) return job.state === 'completed' ? 100 : 0;
return Math.min(100, (job.doneBytes / job.totalBytes) * 100);
}
progressLabel(job: TransferSnapshot): string {
if (job.state === 'queued') return this.i18n.t('transfer.queuedState');
if (job.state === 'scanning') return this.i18n.t('transfer.scanning');
if (job.state === 'failed')
return `${job.currentFile ? `${job.currentFile} — ` : ''}${job.error ?? this.i18n.t('transfer.failedState')}`;
if (job.state === 'cancelled') return this.i18n.t('transfer.cancelled');
if (job.state === 'completed')
return this.i18n.t('transfer.completedFiles', {
count: job.doneFiles,
size: this.i18n.size(job.doneBytes),
});
const speed = this.speedLabel(job);
const base = `${job.currentFile} · ${this.i18n.size(job.doneBytes)} / ${this.i18n.size(job.totalBytes)}`;
return speed ? `${base} · ${speed}` : base;
}
}
@@ -1,85 +0,0 @@
/**
* Frame arithmetic for the synthesized window resize. Deliberately a separate,
* DEPENDENCY-FREE module: `scripts/resize-math-probe.ts` imports it directly to
* assert all eight edges without booting Angular or opening a window, which is
* the only automated coverage this feature can have (pointer drags cannot be
* synthesized headlessly on this machine).
*
* Part of the resize stopgap — see the removal checklist in window-resize.ts.
*/
export interface Frame {
x: number;
y: number;
width: number;
height: number;
}
/** Compass point of the grabbed edge or corner. */
export type ResizeEdge = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
/** Every handle, in DOM order: edges first so the corners overlay them. */
export const RESIZE_EDGES: readonly ResizeEdge[] = ['n', 's', 'w', 'e', 'nw', 'ne', 'sw', 'se'];
/**
* Floor for the synthesized resize. electrobun has no min-size window option,
* so this is enforced here or not at all. Chosen to keep both panels usable
* with their toolbars intact; the interface scale can still make the CONTENT
* too big for it, which is a separate (pre-existing) problem.
*/
export const MIN_WIDTH = 640;
export const MIN_HEIGHT = 420;
/**
* The frame a drag should produce.
*
* `dx`/`dy` are SCREEN-space deltas from where the drag began (see the note on
* screenX in window-resize.ts). North and west edges move the window's origin
* as well as its size — that is the whole reason this returns a full frame
* rather than a size, and why `setSize` alone would be wrong.
*
* The clamp holds the ANCHORED edge still: dragging the west edge east past the
* minimum must park `x` where the window is exactly MIN_WIDTH wide, not let it
* keep travelling while the width sits pinned — that would walk the window
* across the screen while it appears frozen.
*
* Results are rounded: fractional frames make the native setter jitter by a
* pixel between ticks.
*/
export function resizeFrame(
edge: ResizeEdge,
start: Frame,
dx: number,
dy: number,
min: { width: number; height: number } = { width: MIN_WIDTH, height: MIN_HEIGHT },
): Frame {
let { x, y, width, height } = start;
if (edge.includes('e')) width = start.width + dx;
if (edge.includes('w')) {
width = start.width - dx;
x = start.x + dx;
}
if (edge.includes('s')) height = start.height + dy;
if (edge.includes('n')) {
height = start.height - dy;
y = start.y + dy;
}
if (width < min.width) {
// west drag: the east edge is the anchor, so x is derived from it
if (edge.includes('w')) x = start.x + start.width - min.width;
width = min.width;
}
if (height < min.height) {
if (edge.includes('n')) y = start.y + start.height - min.height;
height = min.height;
}
return {
x: Math.round(x),
y: Math.round(y),
width: Math.round(width),
height: Math.round(height),
};
}
@@ -1,191 +0,0 @@
import { Component, inject } from '@angular/core';
import { RpcService } from '@core/rpc.service';
import { type Frame, RESIZE_EDGES, type ResizeEdge, resizeFrame } from './resize-math';
/* ============================================================================
* ⚠️ STOPGAP — DELETE THIS WHOLE FEATURE WHEN ELECTROBUN SHIPS WINDOW RESIZE
* ============================================================================
*
* WHY IT EXISTS
*
* The win7 skin is frameless: `chromeOptions()` (src/bun/windows.ts) passes
* `titleBarStyle: "hidden"` so 7.css can draw the Aero caption itself, and on
* Linux the GTK wrapper honours that with `gtk_window_set_decorated(false)`.
* Undecorating the window also removes the window manager's resize borders and
* corner grips — so all three win7 windows became completely unresizable, on
* every platform. (The modern skin keeps the native frame and is unaffected;
* it must never mount this component.)
*
* Electrobun 1.18.1 offers nothing to re-enable. Verified against the SHIPPED
* binary rather than assumed:
*
* - libNativeWrapper.so exports `startWindowMove` / `stopWindowMove` and no
* resize counterpart whatsoever. Its only `resize*` exports are
* `resizeWebview` / `resizeAutoSizingWebviewsInWindow`, internal webview
* plumbing unrelated to the window frame.
* - It imports `gtk_window_begin_move_drag` but NOT
* `gtk_window_begin_resize_drag`, and not `gtk_window_set_resizable`.
* - `dist/api/bun/preload/dragRegions.ts` implements moving only: there is no
* `app-region: resize`, no per-edge variant.
* - `BrowserWindow` has no `setResizable`, and its constructor options carry
* no `resizable` / `minWidth` / `minHeight`.
*
* The window is still PROGRAMMATICALLY resizable — nothing ever calls
* `gtk_window_set_resizable(false)`, and `setFrame`/`getFrame` work. So only
* the input affordance is missing, and that is exactly what this synthesizes:
* eight handles → pointer drag → `windowSetFrame` RPC → `BrowserWindow.setFrame`.
*
* WHAT IT COSTS (i.e. why it should die as soon as it can)
*
* Every drag tick is an RPC hop plus a synchronous FFI call, where a real WM
* grab is a compositor-side operation — so it is jerkier than a native resize
* and always will be. Minimum size is enforced in JS because electrobun has no
* min-size option. And the handles occupy real estate inside the window that a
* native frame would have provided outside it.
*
* ────────────────────────────────────────────────────────────────────────────
* REMOVAL CHECKLIST — when upstream ships a resize gesture, delete ALL of:
*
* 1. this directory (window-resize.ts + resize-math.ts)
* 2. its three mount points: `<app-window-resize />` and the `WindowResize`
* entry in `imports:` for skins/7like/{app, connection-manager/
* connection-manager, editor-window/editor-window}
* 3. the `.w7-resize` rule in skins/7like/7like.css (@layer skinfix)
* 4. `windowGetFrame` / `windowSetFrame` in src/shared/rpc.ts
* 5. their handlers in `windowControlHandlers` (src/bun/rpc-handlers.ts)
* 6. scripts/check-electrobun-resize.ts and scripts/resize-math-probe.ts
* 7. the check-script call in package.json's `postinstall`
* 8. the resize paragraphs in docs/{gotchas,platform-notes,architecture}.md
*
* Then adopt whatever upstream provides — most likely a `no-drag`-style CSS
* class or a `startWindowResize(edge)` preload call, which needs handles for
* hit-testing but none of the frame arithmetic below.
*
* HOW WE FIND OUT: `scripts/check-electrobun-resize.ts` inspects the installed
* electrobun for exactly those symbols and prints this checklist when they
* appear. It runs from `postinstall`, so an electrobun bump surfaces it without
* anyone remembering to look.
* ============================================================================
*
* IMPLEMENTATION NOTES
*
* - `electrobun-webkit-app-region-no-drag` on every handle is LOAD-BEARING, not
* decoration. `dragRegions.ts` starts a window MOVE from a `mousedown`
* listener on `document` in the bubble phase; it never checks
* `defaultPrevented`, so `preventDefault()` does not stop it, and a
* `pointerdown` handler does not either (the compatibility `mousedown` still
* fires afterwards, and stopping a POINTER event does nothing to a MOUSE
* event). `isAppRegionDrag` tests for the no-drag class first and
* short-circuits, so the class is the only reliable suppressor. Without it
* every resize drag would also drag the window.
* - Deltas come from `screenX/screenY`, never `clientX/clientY`. Resizing from
* the north or west edge moves the window's origin, which moves the webview's
* client origin under a stationary pointer — client coords would feed that
* movement back into the next delta and the window would run away.
* - The frame is read once per drag, asynchronously, but the pointer origin is
* recorded synchronously in the same handler: a move that arrives before the
* frame does is simply ignored, and no delta is measured from a stale origin.
* - At most one setFrame is in flight, with the newest target coalesced behind
* it. Firing one RPC per pointermove would queue faster than it drains and
* the window would keep resizing after the mouse stopped.
* - Handles are hidden when maximized by CSS alone (7like.css reads the title
* bar's own `is-maximized` through `:has()`), so this component never
* subscribes to `windowState`. That matters: RpcService's push registries
* have no `off()`, so every subscription is permanent.
*/
/** Edge → the Tailwind box that places and cues it. Edge thickness is 0.375rem
* = 6px at 1×, matching the gutter 7.css's `.window-body { margin: 0 6px 6px }`
* leaves around the content, so left/right/bottom handles sit on frame chrome
* instead of stealing clicks from the panels. Rem, so the grab area scales with
* the interface scale like everything else. Corners are listed last: same
* z-index, later in the DOM, so they win the overlap. */
const HANDLE_BOX: Record<ResizeEdge, string> = {
n: 'top-0 right-0 left-0 h-1.5 cursor-ns-resize',
s: 'right-0 bottom-0 left-0 h-1.5 cursor-ns-resize',
w: 'top-0 bottom-0 left-0 w-1.5 cursor-ew-resize',
e: 'top-0 right-0 bottom-0 w-1.5 cursor-ew-resize',
nw: 'top-0 left-0 size-3 cursor-nwse-resize',
ne: 'top-0 right-0 size-3 cursor-nesw-resize',
sw: 'bottom-0 left-0 size-3 cursor-nesw-resize',
se: 'right-0 bottom-0 size-3 cursor-nwse-resize',
};
@Component({
selector: 'app-window-resize',
template: `
@for (edge of edges; track edge) {
<div
class="pointer-events-auto absolute touch-none electrobun-webkit-app-region-no-drag {{ box[edge] }}"
(pointerdown)="onPointerDown($event, edge)"
(pointermove)="onPointerMove($event)"
(pointerup)="onPointerEnd()"
(pointercancel)="onPointerEnd()"
></div>
}
`,
// The overlay spans the frame but is transparent to the pointer; only the
// handles inside it take hits. `w7-resize` is what the maximized rule hides.
host: { class: 'w7-resize pointer-events-none absolute inset-0 z-10' },
})
export class WindowResize {
protected readonly edges = RESIZE_EDGES;
protected readonly box = HANDLE_BOX;
private readonly rpc = inject(RpcService);
/** Null except during a drag. Set only once the frame read resolves. */
private drag: { edge: ResizeEdge; screenX: number; screenY: number; frame: Frame } | null = null;
/** Newest target frame not yet sent, if a send is already in flight. */
private pending: Frame | null = null;
private sending = false;
onPointerDown(event: PointerEvent, edge: ResizeEdge): void {
if (!this.rpc.available || event.button !== 0) return;
(event.target as HTMLElement).setPointerCapture(event.pointerId);
// captured synchronously — the frame read below is a round trip
const screenX = event.screenX;
const screenY = event.screenY;
void this.rpc.request.windowGetFrame({}).then((res) => {
if (!res.ok) return;
this.drag = {
edge,
screenX,
screenY,
frame: { x: res.x, y: res.y, width: res.width, height: res.height },
};
});
}
onPointerMove(event: PointerEvent): void {
const drag = this.drag;
if (!drag) return; // moved before the frame arrived, or not dragging
this.pending = resizeFrame(
drag.edge,
drag.frame,
event.screenX - drag.screenX,
event.screenY - drag.screenY,
);
void this.flush();
}
onPointerEnd(): void {
this.drag = null; // capture releases itself on pointerup/cancel
}
/** Drain `pending`, one request at a time. Re-entrant calls return
* immediately; the running loop picks up whatever they left behind. */
private async flush(): Promise<void> {
if (this.sending) return;
this.sending = true;
try {
while (this.pending) {
const frame = this.pending;
this.pending = null;
await this.rpc.request.windowSetFrame(frame);
}
} finally {
this.sending = false;
}
}
}
+6 -5
View File
@@ -1,8 +1,8 @@
<div class="flex h-screen flex-col bg-background text-foreground"> <div class="flex h-screen flex-col bg-background text-foreground">
@if (!rpc.available) { @if (!rpc.available) {
<div class="flex flex-1 items-center justify-center"> <div class="flex flex-1 items-center justify-center">
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm"> <div class="max-w-sm border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" /> <ng-icon name="closeCircle24" size="var(--icon-size-xl)" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p> <p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground"> <p class="mt-1 text-xs text-muted-foreground">
{{ i18n.t('app.notConnectedBody') }} {{ i18n.t('app.notConnectedBody') }}
@@ -27,11 +27,12 @@
class="fixed inset-0 z-60 flex items-center justify-center bg-black/40" class="fixed inset-0 z-60 flex items-center justify-center bg-black/40"
(mousedown)="focusManager()" (mousedown)="focusManager()"
> >
<div class="rounded-lg border border-border bg-card px-5 py-4 text-center shadow-xl"> <div class="border border-border bg-card px-5 py-4 text-center shadow-xl">
<ng-icon name="mynaServers" size="1.25rem" class="text-muted-foreground" /> <ng-icon name="server24" size="var(--icon-size-xl)" class="text-muted-foreground" />
<p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p> <p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p>
<button <button
class="mt-3 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90" (click)="focusManager()"
class="mt-3 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
> >
{{ i18n.t('app.showConnections') }} {{ i18n.t('app.showConnections') }}
</button> </button>
+138 -85
View File
@@ -1,22 +1,24 @@
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal, viewChild } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import { BRANDING } from '@shared/branding'; import { BRANDING } from '@shared/branding';
import { NATIVE_MENU_IDS } from '@shared/keymap';
import type { ColumnKey } from '@shared/types'; import type { ColumnKey } from '@shared/types';
import { ConnectionsService } from '@core/connections.service'; import { ConnectionsService } from '@core/connections.service';
import { ContextMenuService } from '@core/context-menu.service';
import { ContextMenu } from './context-menu'; import { ContextMenu } from './context-menu';
import { ChmodDialog } from './dialogs/chmod-dialog'; import { ChmodDialog } from './dialogs/chmod-dialog';
import { ConfirmDialog } from './dialogs/confirm-dialog'; import { ConfirmDialog } from './dialogs/confirm-dialog';
import { OverwriteDialog } from './dialogs/overwrite-dialog'; import { OverwriteDialog } from './dialogs/overwrite-dialog';
import { PromptDialog } from './dialogs/prompt-dialog'; import { PromptDialog } from './dialogs/prompt-dialog';
import { DialogsService } from '@core/dialogs.service'; import { DialogsService } from '@core/dialogs.service';
import { FileActionsService } from '@core/file-actions.service'; import { FileActionsService, type FileCommand } from '@core/file-actions.service';
import { FilePanel } from './file-panel/file-panel'; import { FilePanel } from './file-panel/file-panel';
import { PanelsService } from '@core/panels.service'; import { PanelsService } from '@core/panels.service';
import { blockNativeContextMenu } from '@core/platform'; import { blockNativeContextMenu, blockReloadKeys } from '@core/platform';
import { RpcService } from '@core/rpc.service'; import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service'; import { ShortcutsService, type ShortcutId } from '@core/shortcuts.service';
import { StatusBar } from './status-bar/status-bar'; import { StatusBar } from './status-bar/status-bar';
import { TabBar } from './tab-bar/tab-bar'; import { TabBar } from './tab-bar/tab-bar';
import { ToastService } from '@core/toast.service'; import { ToastService } from '@core/toast.service';
@@ -44,6 +46,7 @@ import { UpdaterService } from '@core/updater.service';
host: { host: {
'(window:keydown)': 'onWindowKeydown($event)', '(window:keydown)': 'onWindowKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)', '(window:contextmenu)': 'blockContextMenu($event)',
'(mousedown)': 'keepListFocus($event)',
}, },
}) })
export class App { export class App {
@@ -58,6 +61,8 @@ export class App {
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService); private readonly shortcuts = inject(ShortcutsService);
private readonly updater = inject(UpdaterService); private readonly updater = inject(UpdaterService);
private readonly menu = inject(ContextMenuService);
private readonly transferBar = viewChild(TransferBar);
/** The connection-manager window is open — this window is modal-blocked. */ /** The connection-manager window is open — this window is modal-blocked. */
readonly managerOpen = signal(false); readonly managerOpen = signal(false);
@@ -105,10 +110,6 @@ export class App {
/** Native application-menu / manager-window actions forwarded from bun. */ /** Native application-menu / manager-window actions forwarded from bun. */
private async handleMenuAction(action: string): Promise<void> { private async handleMenuAction(action: string): Promise<void> {
if (action.startsWith('ctx-')) {
this.handleContextAction(action);
return;
}
if (action.startsWith('connect:')) { if (action.startsWith('connect:')) {
const connection = await this.findConnection(action.slice('connect:'.length)); const connection = await this.findConnection(action.slice('connect:'.length));
if (connection) { if (connection) {
@@ -137,22 +138,19 @@ export class App {
} }
switch (action) { switch (action) {
case 'new-tab': case 'new-tab':
this.panels.newWorkspace();
break;
case 'close-tab': case 'close-tab':
this.panels.closeWorkspace(this.panels.activeWorkspaceId()); this.runWindowCommand(action);
break; break;
// the macOS File / View items act on the active panel, exactly like their keys
case 'new-folder': case 'new-folder':
this.actions.mkdir(this.panels.active());
break;
case 'new-file': case 'new-file':
this.actions.createFile(this.panels.active()); this.runFileCommand(action);
break; break;
case 'edit-file': case 'edit-file':
void this.actions.edit(this.panels.active()); this.runFileCommand('edit');
break; break;
case 'refresh-active': case 'refresh-active':
void this.panels.active().refresh(); this.runFileCommand('refresh');
break; break;
case 'check-for-updates': case 'check-for-updates':
void this.updater.check(); void this.updater.check();
@@ -160,33 +158,101 @@ export class App {
} }
} }
/** Window shortcuts — matching lives in ShortcutsService (one keymap for the private runFileCommand(id: FileCommand): void {
* app); this switch only maps ids onto actions. Panel-level keys stay on const panel = this.panels.active();
* the panels themselves. */ const entry = panel.sorted()[panel.cursor()] ?? null;
this.actions.run(id, { side: this.panels.activeSide(), panel, entry });
}
/**
* Window keys — the `main` scope of the shared keymap (ShortcutsService); the
* focused file list handles its own `panel` keys first. Whatever nobody
* claims ends at the WebView2 reload guard.
*/
onWindowKeydown(event: KeyboardEvent): void { onWindowKeydown(event: KeyboardEvent): void {
if (this.managerOpen()) return; // modal-blocked behind the manager this.handleWindowKey(event);
const match = this.shortcuts.resolve(event, 'main'); blockReloadKeys(event); // a no-op once something claimed the key
if (!match) return; }
// scale keys are hostile mid-typing ('-' in a rename prompt must type '-');
// the tab shortcuts keep firing there, as they always did private handleWindowKey(event: KeyboardEvent): void {
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return; // a component took it (the list, a menu, a dialog), or the connection
event.preventDefault(); // manager holds this window modal
switch (match.id) { if (event.defaultPrevented || this.managerOpen()) return;
if (this.dialogs.anyOpen || this.menu.request()) {
this.swallowBehindOverlay(event);
return;
}
if (event.target === document.body) {
// a click dropped focus to <body>: the active list still gets its keys
this.panels.forwardListKey(event);
this.panels.refocus();
if (event.defaultPrevented) return;
}
const id = this.shortcuts.claim(event, 'main');
if (id) this.runWindowCommand(id);
}
/**
* A dialog or menu owns the keyboard. Window commands are swallowed — an
* unclaimed ⌘T would fall through to the macOS menu bar and open a tab
* behind the dialog — and so are the panel commands the menu bar carries
* (⌘N, ⌘E…). The interface-scale keys still work.
*/
private swallowBehindOverlay(event: KeyboardEvent): void {
const id = this.shortcuts.resolve(event, 'main');
if (id === 'scale-up' || id === 'scale-down' || id === 'scale-reset') {
const claimed = this.shortcuts.claim(event, 'main');
if (claimed) this.runWindowCommand(claimed);
return;
}
const panelId = id ? null : this.shortcuts.resolve(event, 'panel');
if (id || (panelId && NATIVE_MENU_IDS.includes(panelId))) event.preventDefault();
}
private runWindowCommand(id: ShortcutId): void {
const active = this.panels.activeWorkspaceId();
switch (id) {
case 'new-tab': case 'new-tab':
this.panels.newWorkspace(); this.panels.newWorkspace();
break; break;
case 'close-tab': case 'close-tab':
this.panels.closeWorkspace(this.panels.activeWorkspaceId()); this.panels.closeWorkspace(active);
break; break;
case 'cycle-tab': case 'close-other-tabs':
this.panels.closeOtherWorkspaces(active);
break;
case 'close-tabs-right':
this.panels.closeWorkspacesToRight(active);
break;
case 'next-tab':
this.panels.cycleWorkspace(1); this.panels.cycleWorkspace(1);
break; break;
case 'cycle-tab-back': case 'prev-tab':
this.panels.cycleWorkspace(-1); this.panels.cycleWorkspace(-1);
break; break;
case 'go-to-tab': case 'go-to-last-tab':
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox this.panels.goToWorkspace(-1);
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1); break;
case 'move-tab-left':
this.moveActiveTab(-1);
break;
case 'move-tab-right':
this.moveActiveTab(1);
break;
case 'disconnect-tab':
void this.panels.disconnectWorkspace(active);
break;
case 'open-settings':
void this.rpc.request.openSettings({});
break;
case 'connection-manager':
void this.rpc.request.openConnectionManager({});
break;
case 'toggle-transfers':
this.toggleTransfers();
break;
case 'install-update':
this.updater.advance();
break; break;
case 'scale-up': case 'scale-up':
this.settings.setUiScale(this.settings.uiScale() + 0.1); this.settings.setUiScale(this.settings.uiScale() + 0.1);
@@ -197,10 +263,48 @@ export class App {
case 'scale-reset': case 'scale-reset':
this.settings.setUiScale(1); this.settings.setUiScale(1);
break; break;
default: {
// go-to-tab-1…8: that tab (go-to-last-tab is ⌘9, like Firefox)
const n = this.shortcuts.binding(id)?.n;
if (n) this.panels.goToWorkspace(n - 1);
}
}
}
/** Move-tab keys: the keyboard's tab drag. */
private moveActiveTab(delta: 1 | -1): void {
const list = this.panels.workspaces();
const index = list.findIndex((w) => w.id === this.panels.activeWorkspaceId());
if (index >= 0) this.panels.moveWorkspace(list[index].id, index + delta);
}
/** ⌘J: open the transfer list with focus in it (Esc goes back to the files),
* or close it. It only renders with jobs, so with none there is nothing to show. */
private toggleTransfers(): void {
if (!this.transfers.hasJobs()) return;
const open = !this.transfers.expanded();
this.transfers.expanded.set(open);
if (open) this.transferBar()?.focusFirst();
else this.panels.refocus();
}
/**
* A click on a chrome button keeps keyboard focus where it was — normally the
* file list. WebKit drops focus to <body> on a button click (every list key
* then died until the list was clicked), Windows parks it on the button,
* where Space would click it again. The click itself still fires. Text
* fields, menus, dialogs and the draggable tabs keep their default.
*/
keepListFocus(event: MouseEvent): void {
const target = event.target as Element;
if (
target.closest('button') &&
!target.closest('input, select, textarea, [role=menu], [role=dialog], [draggable=true]')
) {
event.preventDefault();
} }
} }
/** Context-menu actions apply to the row/panel remembered at right-click time. */
/** /**
* Workspace-tab menu actions. The target id travels INSIDE the action string * Workspace-tab menu actions. The target id travels INSIDE the action string
* because the right-clicked tab is not necessarily the active one — nothing * because the right-clicked tab is not necessarily the active one — nothing
@@ -229,55 +333,4 @@ export class App {
break; break;
} }
} }
private handleContextAction(action: string): void {
const target = this.actions.menuTarget;
if (!target) return;
const { panel, side, entry } = target;
// the empty-space menu acts on the directory itself
const path = entry?.path ?? panel.cwd();
switch (action) {
case 'ctx-open':
if (entry) panel.open(entry);
break;
// local shell integration — only bun can reach the OS shell/clipboard
case 'ctx-reveal':
void this.rpc.request.shellItemAction({ action: 'reveal', path });
break;
case 'ctx-open-default':
void this.rpc.request.shellItemAction({ action: 'open', path });
break;
case 'ctx-copy-path':
void this.rpc.request.shellItemAction({ action: 'copyPath', path });
break;
case 'ctx-edit':
void this.actions.edit(panel);
break;
case 'ctx-copy':
void this.transfers.copyToOtherPanel(side);
break;
case 'ctx-rename':
this.actions.rename(panel);
break;
case 'ctx-chmod':
this.actions.chmod(panel);
break;
case 'ctx-delete':
this.actions.delete(panel);
break;
case 'ctx-trash':
if (entry) void this.actions.trash(panel, entry);
break;
case 'ctx-new-folder':
this.actions.mkdir(panel);
break;
case 'ctx-new-file':
this.actions.createFile(panel);
break;
case 'ctx-refresh':
void panel.refresh();
break;
}
}
} }
@@ -9,19 +9,19 @@
[class.h-9]="!isMacChrome" [class.h-9]="!isMacChrome"
[class.pl-3]="!isMacChrome" [class.pl-3]="!isMacChrome"
> >
<ng-icon name="mynaServers" size="0.95rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="server16" size="var(--icon-size-md)" class="shrink-0 text-muted-foreground" />
<h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1> <h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1>
<div class="flex-1"></div> <div class="flex-1"></div>
<button <button
(click)="importWinScp()" (click)="importWinScp()"
[disabled]="importing()" [disabled]="importing()"
[title]="i18n.t('connMgr.importWinscpHint')" [title]="shortcuts.tip(i18n.t('connMgr.importWinscpHint'), 'conn-import')"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50" class="flex items-center gap-1.5 px-2 py-1 text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50"
> >
@if (importing()) { @if (importing()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" /> <ng-icon name="tdesignLoading" size="var(--icon-size-sm)" class="animate-spin" />
} @else { } @else {
<!-- <ng-icon name="mynaArchive" size="0.85rem" /> --> <!-- <ng-icon name="tdesignFileZip" size="var(--icon-size-sm)" /> -->
} }
{{ i18n.t('connMgr.importWinscp') }} {{ i18n.t('connMgr.importWinscp') }}
</button> </button>
@@ -33,77 +33,79 @@
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5"> <div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
<button <button
(click)="newConnection()" (click)="newConnection()"
[title]="i18n.t('connMgr.newConnectionHint')" [title]="shortcuts.tip(i18n.t('connMgr.newConnectionHint'), 'conn-new')"
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaPlus" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }} <ng-icon name="add14" size="var(--icon-size-sm)" /> {{ i18n.t('connMgr.newConnection') }}
</button> </button>
<button <button
(click)="newStack()" (click)="newStack()"
[title]="i18n.t('connMgr.newStackHint')" [title]="shortcuts.tip(i18n.t('connMgr.newStackHint'), 'conn-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" class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaFolderPlus" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }} <ng-icon name="folderAdd14" size="var(--icon-size-sm)" /> {{ i18n.t('connMgr.newStack') }}
</button> </button>
</div> </div>
<div class="flex-1 overflow-y-auto py-1"> <!-- ↑ ↓ ← → Home End Enter move through it (onListKeydown) -->
<div #list (keydown)="onListKeydown($event)" class="flex-1 overflow-y-auto py-1">
<!-- stacks --> <!-- stacks -->
@for (group of store.grouped().stacks; track group.stack.id) { @for (group of store.grouped().stacks; track group.stack.id) {
<div <div
(dragover)="onDragOver($event, group.stack.id)" (dragover)="onDragOver($event, group.stack.id)"
(dragleave)="dropTarget() === group.stack.id && dropTarget.set(null)" (dragleave)="dropTarget() === group.stack.id && dropTarget.set(null)"
(drop)="onDrop($event, group.stack.id)" (drop)="onDrop($event, group.stack.id)"
class="rounded-md"
[class.bg-primary/10]="dropTarget() === group.stack.id" [class.bg-primary/10]="dropTarget() === group.stack.id"
[class.ring-1]="dropTarget() === group.stack.id" [class.ring-1]="dropTarget() === group.stack.id"
[class.ring-inset]="dropTarget() === group.stack.id" [class.ring-inset]="dropTarget() === group.stack.id"
[class.ring-primary]="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"> <div [attr.data-stack]="group.stack.id" class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
<button <button
data-row
(click)="toggleStack(group.stack)" (click)="toggleStack(group.stack)"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground" class="grid size-4 shrink-0 place-items-center text-muted-foreground hover:text-foreground"
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')" [title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
> >
<ng-icon <ng-icon
[name]="group.stack.collapsed ? 'mynaChevronRight' : 'mynaChevronDown'" [name]="group.stack.collapsed ? 'chevronRight14' : 'chevronDown14'"
size="0.8rem" size="var(--icon-size-sm)"
/> />
</button> </button>
<span class="truncate text-[0.6875rem] font-medium text-muted-foreground uppercase"> <span class="truncate text-[0.6875rem] font-medium text-muted-foreground uppercase">
{{ group.stack.name }} {{ group.stack.name }}
</span> </span>
<div class="min-w-0 flex-1"></div> <div class="min-w-0 flex-1"></div>
<!-- count and actions swap on hover: they never share the space, <!-- count and actions swap on hover — and when the keyboard is in
so a long stack name can't collide with the buttons --> the row: they never share the space, so a long stack name
can't collide with the buttons -->
<span <span
class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-hover:hidden" class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-focus-within:hidden group-hover:hidden"
> >
{{ group.connections.length }} {{ group.connections.length }}
</span> </span>
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex"> <div class="hidden shrink-0 items-center gap-0.5 group-focus-within:flex group-hover:flex">
<button <button
(click)="renameStack(group.stack)" (click)="renameStack(group.stack)"
[title]="i18n.t('connMgr.renameStack')" [title]="shortcuts.tip(i18n.t('connMgr.renameStack'), 'conn-rename-stack')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-foreground" class="grid size-4 place-items-center text-muted-foreground hover:text-foreground"
> >
<ng-icon name="mynaEdit" size="0.75rem" /> <ng-icon name="edit14" size="var(--icon-size-sm)" />
</button> </button>
<button <button
(click)="deleteStack(group.stack)" (click)="deleteStack(group.stack)"
[title]="i18n.t('connMgr.deleteStack')" [title]="shortcuts.tip(i18n.t('connMgr.deleteStack'), 'conn-delete')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-destructive" class="grid size-4 place-items-center text-muted-foreground hover:text-destructive"
> >
<ng-icon name="mynaTrash" size="0.75rem" /> <ng-icon name="delete14" size="var(--icon-size-sm)" />
</button> </button>
@if (group.connections.length) { @if (group.connections.length) {
<button <button
(click)="openStack(group.stack.id)" (click)="openStack(group.stack.id)"
[title]="i18n.t('connMgr.openStack')" [title]="shortcuts.tip(i18n.t('connMgr.openStack'), 'conn-connect')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-primary" class="grid size-4 place-items-center text-muted-foreground hover:text-primary"
> >
<ng-icon name="mynaArrowRight" size="0.8rem" /> <ng-icon name="arrowRight14" size="var(--icon-size-sm)" />
</button> </button>
} }
</div> </div>
@@ -111,6 +113,8 @@
@if (!group.stack.collapsed) { @if (!group.stack.collapsed) {
@for (connection of group.connections; track connection.id) { @for (connection of group.connections; track connection.id) {
<button <button
data-row
[attr.data-conn]="connection.id"
(click)="edit(connection)" (click)="edit(connection)"
draggable="true" draggable="true"
(dragstart)="onDragStart($event, connection)" (dragstart)="onDragStart($event, connection)"
@@ -119,7 +123,7 @@
[class.bg-accent]="draft()?.id === connection.id" [class.bg-accent]="draft()?.id === connection.id"
[class.opacity-50]="draggedId() === connection.id" [class.opacity-50]="draggedId() === connection.id"
> >
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="server14" size="var(--icon-size-sm)" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span> <span class="truncate">{{ connection.name }}</span>
</button> </button>
} @empty { } @empty {
@@ -134,7 +138,7 @@
(dragover)="onDragOver($event, 'loose')" (dragover)="onDragOver($event, 'loose')"
(dragleave)="dropTarget() === 'loose' && dropTarget.set(null)" (dragleave)="dropTarget() === 'loose' && dropTarget.set(null)"
(drop)="onDrop($event, null)" (drop)="onDrop($event, null)"
class="min-h-8 rounded-md pb-1" class="min-h-8 pb-1"
[class.bg-primary/10]="dropTarget() === 'loose'" [class.bg-primary/10]="dropTarget() === 'loose'"
[class.ring-1]="dropTarget() === 'loose'" [class.ring-1]="dropTarget() === 'loose'"
[class.ring-inset]="dropTarget() === 'loose'" [class.ring-inset]="dropTarget() === 'loose'"
@@ -147,6 +151,8 @@
} }
@for (connection of store.grouped().loose; track connection.id) { @for (connection of store.grouped().loose; track connection.id) {
<button <button
data-row
[attr.data-conn]="connection.id"
(click)="edit(connection)" (click)="edit(connection)"
draggable="true" draggable="true"
(dragstart)="onDragStart($event, connection)" (dragstart)="onDragStart($event, connection)"
@@ -155,7 +161,7 @@
[class.bg-accent]="draft()?.id === connection.id" [class.bg-accent]="draft()?.id === connection.id"
[class.opacity-50]="draggedId() === connection.id" [class.opacity-50]="draggedId() === connection.id"
> >
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="server14" size="var(--icon-size-sm)" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span> <span class="truncate">{{ connection.name }}</span>
</button> </button>
} }
@@ -175,15 +181,20 @@
<!-- edit form --> <!-- edit form -->
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
@if (draft(); as d) { @if (draft(); as d) {
<div class="grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5"> <!-- Enter in a field = Connect, the primary button -->
<div
(keydown.enter)="onFormEnter($event)"
class="grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5"
>
<label class="col-span-2 flex flex-col gap-1 text-xs"> <label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span> <span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
<input <input
#nameBox
[value]="d.name" [value]="d.name"
(input)="patch('name', $any($event.target).value)" (input)="patch('name', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.namePlaceholder')" [placeholder]="i18n.t('connMgr.namePlaceholder')"
spellcheck="false" spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
@@ -194,7 +205,7 @@
(input)="patch('host', $any($event.target).value)" (input)="patch('host', $any($event.target).value)"
placeholder="192.168.1.10" placeholder="192.168.1.10"
spellcheck="false" spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
@@ -204,14 +215,14 @@
type="number" type="number"
[value]="d.port" [value]="d.port"
(input)="patch('port', +$any($event.target).value)" (input)="patch('port', +$any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
<label class="flex flex-col gap-1 text-xs"> <label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.stack') }}</span> <span class="text-muted-foreground">{{ i18n.t('connMgr.stack') }}</span>
<select <select
(change)="patch('stackId', $any($event.target).value || null)" (change)="patch('stackId', $any($event.target).value || null)"
class="h-7 rounded-md border border-input bg-background px-1 outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-background pl-1 outline-none focus:ring-2 focus:ring-ring"
> >
<!-- [selected] per option: binding [value] on the <select> runs <!-- [selected] per option: binding [value] on the <select> runs
before the options render and silently shows "(none)" --> before the options render and silently shows "(none)" -->
@@ -231,7 +242,7 @@
[value]="d.username" [value]="d.username"
(input)="patch('username', $any($event.target).value)" (input)="patch('username', $any($event.target).value)"
spellcheck="false" spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
<label class="flex flex-col gap-1 text-xs"> <label class="flex flex-col gap-1 text-xs">
@@ -241,7 +252,7 @@
(input)="patch('initialPath', $any($event.target).value)" (input)="patch('initialPath', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.remotePathPlaceholder')" [placeholder]="i18n.t('connMgr.remotePathPlaceholder')"
spellcheck="false" spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
@@ -253,7 +264,6 @@
name="authKind" name="authKind"
[checked]="d.authKind === 'password'" [checked]="d.authKind === 'password'"
(change)="patch('authKind', 'password')" (change)="patch('authKind', 'password')"
class="accent-primary"
/> />
{{ i18n.t('connMgr.authPassword') }} {{ i18n.t('connMgr.authPassword') }}
</label> </label>
@@ -263,7 +273,6 @@
name="authKind" name="authKind"
[checked]="d.authKind === 'key'" [checked]="d.authKind === 'key'"
(change)="patch('authKind', 'key')" (change)="patch('authKind', 'key')"
class="accent-primary"
/> />
{{ i18n.t('connMgr.authKey') }} {{ i18n.t('connMgr.authKey') }}
</label> </label>
@@ -276,7 +285,7 @@
type="password" type="password"
[value]="d.password" [value]="d.password"
(input)="patch('password', $any($event.target).value)" (input)="patch('password', $any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
} @else { } @else {
@@ -288,12 +297,12 @@
(input)="patch('privateKeyPath', $any($event.target).value)" (input)="patch('privateKeyPath', $any($event.target).value)"
placeholder="~/.ssh/id_ed25519" placeholder="~/.ssh/id_ed25519"
spellcheck="false" spellcheck="false"
class="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" class="h-7 min-w-0 flex-1 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/> />
<button <button
type="button" type="button"
(click)="pickKeyFile()" (click)="pickKeyFile()"
class="h-7 shrink-0 rounded-md border border-border px-2 text-[0.6875rem] hover:bg-accent" class="h-7 shrink-0 border border-border px-2 text-[0.6875rem] hover:bg-accent"
> >
{{ i18n.t('common.choose') }} {{ i18n.t('common.choose') }}
</button> </button>
@@ -305,7 +314,7 @@
type="password" type="password"
[value]="d.passphrase" [value]="d.passphrase"
(input)="patch('passphrase', $any($event.target).value)" (input)="patch('passphrase', $any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
} }
@@ -318,7 +327,7 @@
(input)="patch('localDir', $any($event.target).value)" (input)="patch('localDir', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.localDirPlaceholder')" [placeholder]="i18n.t('connMgr.localDirPlaceholder')"
spellcheck="false" spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/> />
<span class="text-[0.625rem] text-muted-foreground/80"> <span class="text-[0.625rem] text-muted-foreground/80">
{{ i18n.t('connMgr.localDirHint') }} {{ i18n.t('connMgr.localDirHint') }}
@@ -330,13 +339,14 @@
<div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5"> <div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5">
<button <button
(click)="deleteCurrent()" (click)="deleteCurrent()"
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive" [title]="shortcuts.tip(i18n.t('common.delete'), 'conn-delete')"
class="flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
> >
<ng-icon name="mynaTrash" size="0.85rem" /> {{ i18n.t('common.delete') }} <ng-icon name="delete14" size="var(--icon-size-sm)" /> {{ i18n.t('common.delete') }}
</button> </button>
<button <button
(click)="moreOpen.set(!moreOpen())" (click)="moreOpen.set(!moreOpen())"
class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" class="border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
{{ moreOpen() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }} {{ moreOpen() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }}
</button> </button>
@@ -344,17 +354,19 @@
<button <button
(click)="save()" (click)="save()"
[disabled]="!valid()" [disabled]="!valid()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50" [title]="shortcuts.tip(i18n.t('common.save'), 'conn-save')"
class="px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
> >
{{ i18n.t('common.save') }} {{ i18n.t('common.save') }}
</button> </button>
<button <button
(click)="saveAndConnect()" (click)="saveAndConnect()"
[disabled]="!valid() || busy()" [disabled]="!valid() || busy()"
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" [title]="shortcuts.tip(i18n.t('common.connect'), 'conn-connect')"
class="flex items-center gap-1.5 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
@if (busy()) { @if (busy()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" /> <ng-icon name="tdesignLoading" size="var(--icon-size-sm)" class="animate-spin" />
} }
{{ i18n.t('common.connect') }} {{ i18n.t('common.connect') }}
</button> </button>
@@ -362,7 +374,7 @@
} @else { } @else {
<div class="grid flex-1 place-items-center"> <div class="grid flex-1 place-items-center">
<div class="text-center"> <div class="text-center">
<ng-icon name="mynaServers" size="1.75rem" class="text-muted-foreground" /> <ng-icon name="server24" size="var(--icon-size-xl)" class="text-muted-foreground" />
<p class="mt-2 text-xs text-muted-foreground"> <p class="mt-2 text-xs text-muted-foreground">
{{ i18n.t('connMgr.selectPrompt') }} {{ i18n.t('connMgr.selectPrompt') }}
</p> </p>
@@ -1,4 +1,14 @@
import { Component, computed, inject, signal } from '@angular/core'; import {
Component,
ElementRef,
Injector,
afterNextRender,
computed,
effect,
inject,
signal,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import type { SavedConnection, Stack } from '@shared/types'; import type { SavedConnection, Stack } from '@shared/types';
@@ -6,10 +16,12 @@ import { ConnectionsService } from '@core/connections.service';
import { ConfirmDialog } from '../dialogs/confirm-dialog'; import { ConfirmDialog } from '../dialogs/confirm-dialog';
import { PromptDialog } from '../dialogs/prompt-dialog'; import { PromptDialog } from '../dialogs/prompt-dialog';
import { DialogsService } from '@core/dialogs.service'; import { DialogsService } from '@core/dialogs.service';
import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform'; import { cycleFocus } from '@core/focus-cycle';
import { FocusService } from '@core/focus.service';
import { blockNativeContextMenu, blockReloadKeys, IS_MAC_CHROME } from '@core/platform';
import { RpcService } from '@core/rpc.service'; import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service'; import { ShortcutsService, type ShortcutId } from '@core/shortcuts.service';
import { Toasts } from '../toasts/toasts'; import { Toasts } from '../toasts/toasts';
interface Draft { interface Draft {
@@ -76,8 +88,8 @@ function fromDraft(d: Draft): SavedConnection {
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts], imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts],
templateUrl: './connection-manager.html', templateUrl: './connection-manager.html',
host: { host: {
'(window:keydown.escape)': 'onEscape()',
'(window:keydown)': 'onWindowKeydown($event)', '(window:keydown)': 'onWindowKeydown($event)',
'(window:focusin)': 'rememberFocus($event)',
'(window:contextmenu)': 'blockContextMenu($event)', '(window:contextmenu)': 'blockContextMenu($event)',
}, },
}) })
@@ -90,7 +102,13 @@ export class ConnectionManager {
// injected for its side effects: fetches settings and applies theme/accent/ // injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root // text-size data attributes to this window's document root
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService); readonly shortcuts = inject(ShortcutsService);
private readonly focus = inject(FocusService);
private readonly injector = inject(Injector);
private readonly list = viewChild.required<ElementRef<HTMLElement>>('list');
private readonly nameBox = viewChild<ElementRef<HTMLInputElement>>('nameBox');
/** Where focus was outside a dialog, to come back to when it closes. */
private lastFocus: HTMLElement | null = null;
readonly draft = signal<Draft | null>(null); readonly draft = signal<Draft | null>(null);
readonly busy = signal(false); readonly busy = signal(false);
@@ -102,6 +120,27 @@ export class ConnectionManager {
/** Highlighted drop target: a stack id, 'loose', or null. */ /** Highlighted drop target: a stack id, 'loose', or null. */
readonly dropTarget = signal<string | null>(null); readonly dropTarget = signal<string | null>(null);
constructor() {
// the keyboard starts in the list — and lands there again whenever focus
// fell to <body> (the store loads after the first render; a deleted row
// takes its focus with it)
effect(() => {
this.store.grouped();
afterNextRender(() => this.focusFallback(), { injector: this.injector });
});
// dialogs bump FocusService when they close: return to where the user was
effect(() => {
this.focus.tick();
afterNextRender(
() => {
if (this.lastFocus?.isConnected) this.lastFocus.focus();
else this.focusFallback();
},
{ injector: this.injector },
);
});
}
readonly valid = computed(() => { readonly valid = computed(() => {
const d = this.draft(); const d = this.draft();
if (!d) return false; if (!d) return false;
@@ -141,19 +180,174 @@ export class ConnectionManager {
void this.rpc.request.managerClose({}); void this.rpc.request.managerClose({});
} }
onEscape(): void { /**
// in-window dialogs (confirm/prompt) handle their own Escape first * This window's keys (the `manager` scope), then the fixed ones: Esc closes
if (!this.dialogs.anyOpen) this.close(); * the window — from a text field it first just leaves the field, so a
* half-typed connection isn't thrown away — and Tab is walked by hand
* (WKWebView skips buttons and radios on Tab otherwise). The dialogs take
* their own Esc and stop it, so it never reaches here.
*/
onWindowKeydown(event: KeyboardEvent): void {
const id = this.shortcuts.claim(event, 'manager');
if (id) this.runCommand(id);
else if (event.key === 'Escape' && !this.dialogs.anyOpen) this.onEscape(event);
else cycleFocus(document.body, event);
blockReloadKeys(event);
} }
/** Any-window shortcuts (interface scale). Escape has its own binding above. */ private onEscape(event: KeyboardEvent): void {
onWindowKeydown(event: KeyboardEvent): void {
const match = this.shortcuts.resolve(event, 'manager');
if (!match || this.shortcuts.isTypingTarget(event)) return;
event.preventDefault(); event.preventDefault();
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1); if (this.shortcuts.isTypingTarget(event)) {
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1); (event.target as HTMLElement).blur();
else if (match.id === 'scale-reset') this.settings.setUiScale(1); this.focusFallback(true);
} else {
this.close();
}
}
private runCommand(id: ShortcutId): void {
const row = this.focusedRow();
switch (id) {
case 'close-window':
this.close();
break;
case 'conn-new':
this.newConnection();
this.focusName();
break;
case 'conn-new-stack':
this.newStack();
break;
case 'conn-save':
void this.save();
break;
case 'conn-connect':
// the focused stack opens in tabs; a focused connection, or the form, connects
if (row?.stack) this.openStack(row.stack.id);
else {
if (row?.connection) this.edit(row.connection);
void this.saveAndConnect();
}
break;
case 'conn-delete':
if (row?.stack) this.deleteStack(row.stack);
else {
if (row?.connection) this.edit(row.connection);
this.deleteCurrent();
}
break;
case 'conn-rename-stack':
if (row?.stack) this.renameStack(row.stack);
break;
case 'conn-import':
void this.importWinScp();
break;
case 'scale-up':
this.settings.setUiScale(this.settings.uiScale() + 0.1);
break;
case 'scale-down':
this.settings.setUiScale(this.settings.uiScale() - 0.1);
break;
case 'scale-reset':
this.settings.setUiScale(1);
break;
}
}
// ---- keyboard: the list ----
/** The stack or connection row the focus sits in (its buttons included). */
private focusedRow(): { stack?: Stack; connection?: SavedConnection } | null {
const el = document.activeElement as HTMLElement | null;
const stackId = el?.closest<HTMLElement>('[data-stack]')?.dataset['stack'];
if (stackId) {
const stack = this.store.stacks().find((s) => s.id === stackId);
return stack ? { stack } : null;
}
const connId = el?.closest<HTMLElement>('[data-conn]')?.dataset['conn'];
const connection = connId && this.store.connections().find((c) => c.id === connId);
return connection ? { connection } : null;
}
private rows(): HTMLElement[] {
return [...this.list().nativeElement.querySelectorAll<HTMLElement>('[data-row]')];
}
/**
* ↑ ↓ Home End move between rows (focus only — loading a connection would
* replace the form without asking); ← / → collapse and expand a stack, ← on
* a connection goes up to its stack; Enter on a connection opens it in the
* form (Enter / Space on a stack toggles it, natively).
*/
onListKeydown(event: KeyboardEvent): void {
if (event.metaKey || event.ctrlKey || event.altKey) return;
const rows = this.rows();
const at = rows.indexOf(document.activeElement as HTMLElement);
const row = this.focusedRow();
let target: HTMLElement | undefined;
switch (event.key) {
case 'ArrowDown':
target = rows[Math.min(rows.length - 1, at + 1)];
break;
case 'ArrowUp':
target = rows[Math.max(0, at - 1)];
break;
case 'Home':
target = rows[0];
break;
case 'End':
target = rows.at(-1);
break;
case 'ArrowLeft':
if (row?.stack && !row.stack.collapsed) this.toggleStack(row.stack);
else if (row?.connection?.stackId) target = this.stackRow(row.connection.stackId);
else return;
break;
case 'ArrowRight':
if (row?.stack?.collapsed) this.toggleStack(row.stack);
else return;
break;
case 'Enter':
if (!row?.connection) return;
this.edit(row.connection);
this.focusName();
break;
default:
return;
}
event.preventDefault();
target?.focus();
}
private stackRow(stackId: string): HTMLElement | undefined {
return this.rows().find((el) => el.closest<HTMLElement>('[data-stack]')?.dataset['stack'] === stackId);
}
/** Enter in a form field = Connect, the primary button (not mid-IME). */
onFormEnter(event: Event): void {
const key = event as KeyboardEvent;
const target = key.target as HTMLElement;
if (key.isComposing || !target.matches('input:not([type=radio]):not([type=checkbox])')) return;
key.preventDefault();
if (this.valid()) void this.saveAndConnect();
}
rememberFocus(event: FocusEvent): void {
const target = event.target as HTMLElement;
if (target instanceof HTMLElement && !target.closest('[role=dialog]')) this.lastFocus = target;
}
/** Focus the edited connection's row, else the first row — only when focus
* is nowhere (on <body>), unless forced. */
private focusFallback(force = false): void {
if (!force && document.activeElement !== document.body) return;
const id = this.draft()?.id;
const own = id ? this.rows().find((el) => el.closest<HTMLElement>('[data-conn]')?.dataset['conn'] === id) : undefined;
(own ?? this.rows()[0])?.focus();
}
private focusName(): void {
afterNextRender(() => this.nameBox()?.nativeElement.focus(), { injector: this.injector });
} }
blockContextMenu(event: Event): void { blockContextMenu(event: Event): void {
+88 -47
View File
@@ -8,9 +8,11 @@ import {
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { ContextMenuService, type MenuItem } from '@core/context-menu.service'; import { ContextMenuService, isSelectable, type MenuItem } from '@core/context-menu.service';
import { FocusService } from '@core/focus.service'; import { FocusService } from '@core/focus.service';
import { stepIndex } from '@core/menu-nav';
import { remPx } from '@core/metrics'; import { remPx } from '@core/metrics';
import { ShortcutsService } from '@core/shortcuts.service';
/** Rough per-item metrics in REM, used only to decide whether the menu must /** Rough per-item metrics in REM, used only to decide whether the menu must
* flip. Converted through remPx() at open time so the math tracks the same * flip. Converted through remPx() at open time so the math tracks the same
@@ -19,13 +21,19 @@ import { remPx } from '@core/metrics';
* scale while the flip math still believed 224 and fired at the wrong edge. */ * scale while the flip math still believed 224 and fired at the wrong edge. */
const ITEM_H_REM = 1.625; // px-3 py-1 row + --path-font line ≈ 26px @16 const ITEM_H_REM = 1.625; // px-3 py-1 row + --path-font line ≈ 26px @16
const SEPARATOR_H_REM = 0.4375; // my-1 + 1px border ≈ 7px @16 const SEPARATOR_H_REM = 0.4375; // my-1 + 1px border ≈ 7px @16
const MENU_W_REM = 14; // w-56 const HEADER_H_REM = 1.25; // pt-1.5 pb-0.5 + the 0.625rem caps line
const MENU_W_REM = 15; // w-60
const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
/** /**
* The panel right-click menu. Replaces electrobun's native ContextMenu, which * The HTML context menu (rows, tabs, column headers, the panel source picker).
* is a no-op on Linux — see ContextMenuService for the why. Emits the same * Replaces electrobun's native ContextMenu, which is a no-op on Linux — see
* `ctx-*` / `col:*` action strings the native menu did. * ContextMenuService for the why.
*
* Keyboard: ↑↓ Home End move one highlight shared with the mouse, Enter/Space
* pick, Esc closes, and an item's own shortcut picks it while the menu is open
* (F2 renames from an open row menu). Every key it handles stops here, so the
* window's shortcut handler never runs it a second time.
*/ */
@Component({ @Component({
selector: 'app-context-menu', selector: 'app-context-menu',
@@ -38,33 +46,50 @@ const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
#menuEl #menuEl
role="menu" role="menu"
tabindex="-1" tabindex="-1"
[attr.aria-activedescendant]="activeIndex() >= 0 ? 'ctx-item-' + activeIndex() : null"
(keydown)="onKeydown($event)" (keydown)="onKeydown($event)"
[style.left.px]="position().x" [style.left.px]="position().x"
[style.top.px]="position().y" [style.top.px]="position().y"
class="fixed z-50 w-56 rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl outline-none" [style.max-height.px]="position().maxHeight"
class="fixed z-50 w-60 overflow-y-auto border border-border/60 bg-chrome py-1 text-foreground shadow-xl outline-none"
> >
@for (item of req.items; track $index) { @for (item of req.items; track $index) {
@if (item.type === 'separator') { @if (item.type === 'separator') {
<div class="my-1 border-t border-border"></div> <div class="my-1 border-t border-border/60"></div>
} @else if (item.type === 'header') {
<div class="truncate px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
{{ item.label }}
</div>
} @else { } @else {
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
tabindex="-1"
[attr.id]="'ctx-item-' + $index"
[title]="item.tooltip ?? ''" [title]="item.tooltip ?? ''"
[disabled]="!!item.disabled" [disabled]="!!item.disabled"
[class.bg-accent]="$index === activeIndex() && !item.disabled" [class.bg-primary-surface]="$index === activeIndex() && !item.disabled"
[class.text-primary]="$index === activeIndex() && !item.disabled"
(mouseenter)="activeIndex.set($index)" (mouseenter)="activeIndex.set($index)"
(click)="pick(item)" (click)="pick(item)"
class="flex w-full items-center gap-2 px-3 py-1 text-left text-(length:--path-font) hover:bg-accent disabled:pointer-events-none disabled:opacity-50" class="flex w-full items-center gap-2 px-3 py-1 text-left text-(length:--path-font) disabled:pointer-events-none disabled:opacity-50"
> >
<span class="w-3.5 shrink-0"> <!-- the check column: a checkmark, else the item's icon -->
<span class="grid w-4 shrink-0 place-items-center">
@if (item.checked) { @if (item.checked) {
<ng-icon name="mynaCheck" size="0.85rem" class="text-primary" /> <ng-icon name="check14" size="var(--icon-size-sm)" class="text-primary" />
} @else if (item.icon) {
<ng-icon
[name]="item.icon"
size="var(--icon-size-sm)"
[class.text-muted-foreground]="$index !== activeIndex() || !!item.disabled"
/>
} }
</span> </span>
<span class="flex-1 truncate">{{ item.label }}</span> <span class="flex-1 truncate">{{ item.label }}</span>
@if (item.accelerator) { @if (item.shortcut && shortcuts.label(item.shortcut); as hint) {
<span class="shrink-0 text-muted-foreground">{{ item.accelerator }}</span> <!-- soft: the row's own colour at 60%, so it turns soft green with the highlight -->
<span class="ml-4 shrink-0 opacity-60">{{ hint }}</span>
} }
</button> </button>
} }
@@ -82,30 +107,37 @@ const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
}) })
export class ContextMenu { export class ContextMenu {
readonly menu = inject(ContextMenuService); readonly menu = inject(ContextMenuService);
readonly shortcuts = inject(ShortcutsService);
private readonly focus = inject(FocusService); private readonly focus = inject(FocusService);
private readonly menuEl = viewChild<ElementRef<HTMLDivElement>>('menuEl'); private readonly menuEl = viewChild<ElementRef<HTMLDivElement>>('menuEl');
readonly activeIndex = signal(-1); readonly activeIndex = signal(-1);
/** Click point, flipped/clamped so the menu never overflows the viewport. /** Click point, flipped/clamped so the menu never overflows the viewport.
* All UI metrics convert through the live root font-size; window.inner* is * A menu taller than the viewport (a long source picker) scrolls inside a
* viewport CSS px, so both sides of every comparison share units. */ * clamped box. All UI metrics convert through the live root font-size;
* window.inner* is viewport CSS px, so both sides of every comparison share
* units. */
readonly position = computed(() => { readonly position = computed(() => {
const req = this.menu.request(); const req = this.menu.request();
if (!req) return { x: 0, y: 0 }; if (!req) return { x: 0, y: 0, maxHeight: 0 };
const itemH = remPx(ITEM_H_REM); const heights = {
const separatorH = remPx(SEPARATOR_H_REM); separator: remPx(SEPARATOR_H_REM),
header: remPx(HEADER_H_REM),
item: remPx(ITEM_H_REM),
};
const content =
req.items.reduce((sum, i) => sum + heights[i.type ?? 'item'], 0) + remPx(0.5); // py-1 ×2
const maxHeight = window.innerHeight - 2 * EDGE_GAP;
const height = Math.min(content, maxHeight);
const menuW = remPx(MENU_W_REM); const menuW = remPx(MENU_W_REM);
const height =
req.items.reduce((sum, i) => sum + (i.type === 'separator' ? separatorH : itemH), 0) +
remPx(0.5); // the menu's py-1 (0.25rem x2)
const x = const x =
req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : req.x; req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : req.x;
const y = const y =
req.y + height > window.innerHeight - EDGE_GAP req.y + height > window.innerHeight - EDGE_GAP
? Math.max(EDGE_GAP, req.y - height) ? Math.max(EDGE_GAP, req.y - height)
: req.y; : req.y;
return { x, y }; return { x, y, maxHeight };
}); });
constructor() { constructor() {
@@ -113,22 +145,25 @@ export class ContextMenu {
// the viewChild signal, NOT on request(): under zoneless CD an effect can // the viewChild signal, NOT on request(): under zoneless CD an effect can
// run before the @if has rendered the element. // run before the @if has rendered the element.
effect(() => this.menuEl()?.nativeElement.focus()); effect(() => this.menuEl()?.nativeElement.focus());
// a fresh menu starts with nothing highlighted — or, opened from the
// keyboard, with its first item
effect(() => this.activeIndex.set(this.menu.request()?.initialIndex ?? -1));
} }
close(): void { close(): void {
if (!this.menu.request()) return; if (!this.menu.request()) return;
this.menu.close(); this.menu.close();
this.activeIndex.set(-1); // a fresh menu starts with nothing highlighted
this.focus.bump(); this.focus.bump();
} }
pick(item: MenuItem): void { pick(item: MenuItem): void {
// the keyboard path never consults the disabled attribute — guard here // the keyboard path never consults the disabled attribute — guard here
if (item.disabled) return; if (item.disabled || !item.action) return;
if (!item.action) return;
const action = item.action; const action = item.action;
const onPick = this.menu.request()?.onPick;
this.close(); this.close();
this.menu.onPick(action); if (onPick) onPick(action);
else this.menu.emit(action);
} }
/** Right-clicking the backdrop closes rather than stacking a second menu. */ /** Right-clicking the backdrop closes rather than stacking a second menu. */
@@ -139,42 +174,48 @@ export class ContextMenu {
onKeydown(event: KeyboardEvent): void { onKeydown(event: KeyboardEvent): void {
const items = this.menu.request()?.items ?? []; const items = this.menu.request()?.items ?? [];
const selectable = items.map(isSelectable);
switch (event.key) { switch (event.key) {
case 'Escape': case 'Escape':
event.preventDefault();
event.stopPropagation();
this.close(); this.close();
break; break;
case 'ArrowDown': case 'ArrowDown':
event.preventDefault(); this.highlight(stepIndex(selectable, this.activeIndex(), 1));
this.step(items, 1);
break; break;
case 'ArrowUp': case 'ArrowUp':
event.preventDefault(); this.highlight(stepIndex(selectable, this.activeIndex(), -1));
this.step(items, -1); break;
case 'Home':
this.highlight(stepIndex(selectable, -1, 1));
break;
case 'End':
this.highlight(stepIndex(selectable, -1, -1));
break; break;
case 'Enter': case 'Enter':
case ' ': { case ' ': {
event.preventDefault();
const item = items[this.activeIndex()]; const item = items[this.activeIndex()];
if (item) this.pick(item); if (item) this.pick(item);
break; break;
} }
} case 'Tab':
} // focus stays on the menu: the items are not Tab stops
break;
/** Move the highlight by one selectable item, skipping separators and default: {
* disabled entries, wrapping. */ // an item's own shortcut picks it, as in a native menu
private step(items: MenuItem[], delta: number): void { const hit = items.find(
const count = items.length; (i) => isSelectable(i) && i.shortcut && this.shortcuts.matches(i.shortcut, event),
if (!count) return; );
let index = this.activeIndex(); if (!hit) return; // not ours: let it bubble
for (let hops = 0; hops < count; hops++) { this.pick(hit);
index = (index + delta + count) % count;
if (items[index].type !== 'separator' && !items[index].disabled) {
this.activeIndex.set(index);
return;
} }
} }
event.preventDefault();
event.stopPropagation();
}
/** Move the highlight and keep it in view (the menu may scroll). */
private highlight(index: number): void {
this.activeIndex.set(index);
document.getElementById(`ctx-item-${index}`)?.scrollIntoView({ block: 'nearest' });
} }
} }
@@ -1,6 +1,16 @@
import { Component, computed, inject, input, signal } from '@angular/core'; import {
Component,
ElementRef,
afterNextRender,
computed,
inject,
input,
signal,
viewChild,
} from '@angular/core';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import { DialogsService, type ChmodRequest } from '@core/dialogs.service'; import { DialogsService, type ChmodRequest } from '@core/dialogs.service';
import { cycleFocus } from '@core/focus-cycle';
import { FocusService } from '@core/focus.service'; import { FocusService } from '@core/focus.service';
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const; const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
@@ -11,9 +21,12 @@ const PERMS = ['r', 'w', 'x'] as const;
template: ` template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()"> <div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div <div
class="w-80 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl" role="dialog"
aria-modal="true"
[attr.aria-label]="i18n.t('dialogs.chmodTitle')"
class="w-80 border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()" (mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()" (keydown)="onKeydown($event)"
> >
<h2 class="text-sm font-semibold">{{ i18n.t('dialogs.chmodTitle') }}</h2> <h2 class="text-sm font-semibold">{{ i18n.t('dialogs.chmodTitle') }}</h2>
<p class="mt-1 truncate text-xs text-muted-foreground">{{ targetLabel() }}</p> <p class="mt-1 truncate text-xs text-muted-foreground">{{ targetLabel() }}</p>
@@ -37,7 +50,7 @@ const PERMS = ['r', 'w', 'x'] as const;
type="checkbox" type="checkbox"
[checked]="hasBit(r, p)" [checked]="hasBit(r, p)"
(change)="toggleBit(r, p)" (change)="toggleBit(r, p)"
class="size-3.5 accent-primary" class="size-3.5"
/> />
</td> </td>
} }
@@ -49,24 +62,25 @@ const PERMS = ['r', 'w', 'x'] as const;
<div class="mt-3 flex items-center gap-2"> <div class="mt-3 flex items-center gap-2">
<label class="text-xs text-muted-foreground">{{ i18n.t('dialogs.octal') }}</label> <label class="text-xs text-muted-foreground">{{ i18n.t('dialogs.octal') }}</label>
<input <input
#octalBox
[value]="octal()" [value]="octal()"
(input)="setOctal($any($event.target).value)" (input)="setOctal($any($event.target).value)"
maxlength="4" maxlength="4"
spellcheck="false" spellcheck="false"
class="h-7 w-20 rounded-md border border-input bg-transparent px-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring" class="h-7 w-20 border border-input bg-transparent px-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring"
/> />
</div> </div>
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<button <button
(click)="cancel()" (click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent" class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
> >
{{ i18n.t('common.cancel') }} {{ i18n.t('common.cancel') }}
</button> </button>
<button <button
(click)="submit()" (click)="submit()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90" class="bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
> >
{{ i18n.t('common.apply') }} {{ i18n.t('common.apply') }}
</button> </button>
@@ -84,6 +98,32 @@ export class ChmodDialog {
private readonly dialogs = inject(DialogsService); private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService); private readonly focus = inject(FocusService);
private readonly octalBox = viewChild.required<ElementRef<HTMLInputElement>>('octalBox');
constructor() {
// typing the octal is the fast path; the checkboxes are a Tab away
afterNextRender(() => {
const el = this.octalBox().nativeElement;
el.focus();
el.select();
});
}
/** Esc cancels (and stops here); Tab stays inside; Enter applies — except on
* a button, which Enter clicks natively, and mid IME composition. */
onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.cancel();
} else if (event.key === 'Enter') {
if (event.isComposing || (event.target as HTMLElement).closest('button')) return;
event.preventDefault();
this.submit();
} else {
cycleFocus(event.currentTarget as Element, event);
}
}
// mode is initialized lazily from the request on first read // mode is initialized lazily from the request on first read
private readonly modeOverride = signal<number | null>(null); private readonly modeOverride = signal<number | null>(null);
@@ -1,6 +1,7 @@
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core'; import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import { DialogsService, type ConfirmRequest } from '@core/dialogs.service'; import { DialogsService, type ConfirmRequest } from '@core/dialogs.service';
import { cycleFocus } from '@core/focus-cycle';
import { FocusService } from '@core/focus.service'; import { FocusService } from '@core/focus.service';
@Component({ @Component({
@@ -8,16 +9,19 @@ import { FocusService } from '@core/focus.service';
template: ` template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()"> <div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div <div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl" role="dialog"
aria-modal="true"
[attr.aria-label]="request().title"
class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()" (mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()" (keydown)="onKeydown($event)"
> >
<h2 class="text-sm font-semibold">{{ request().title }}</h2> <h2 class="text-sm font-semibold">{{ request().title }}</h2>
<p class="mt-2 text-xs text-muted-foreground">{{ request().body }}</p> <p class="mt-2 text-xs text-muted-foreground">{{ request().body }}</p>
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<button <button
(click)="cancel()" (click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent" class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
> >
{{ i18n.t('common.cancel') }} {{ i18n.t('common.cancel') }}
</button> </button>
@@ -26,8 +30,8 @@ import { FocusService } from '@core/focus.service';
(click)="submit()" (click)="submit()"
[class]=" [class]="
request().destructive request().destructive
? 'rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90' ? 'bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90'
: 'rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90' : 'bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90'
" "
> >
{{ request().confirmLabel }} {{ request().confirmLabel }}
@@ -49,6 +53,17 @@ export class ConfirmDialog {
afterNextRender(() => this.confirmBtn().nativeElement.focus()); afterNextRender(() => this.confirmBtn().nativeElement.focus());
} }
/** Esc cancels (and stops here — see PromptDialog); Tab stays inside. */
onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.cancel();
} else {
cycleFocus(event.currentTarget as Element, event);
}
}
submit(): void { submit(): void {
const req = this.request(); const req = this.request();
this.close(); this.close();
@@ -1,7 +1,7 @@
import { import {
Component, Component,
ElementRef, ElementRef,
afterNextRender, afterRenderEffect,
computed, computed,
inject, inject,
input, input,
@@ -10,6 +10,7 @@ import {
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service'; import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service';
import { cycleFocus } from '@core/focus-cycle';
import { FocusService } from '@core/focus.service'; import { FocusService } from '@core/focus.service';
@Component({ @Component({
@@ -18,10 +19,12 @@ import { FocusService } from '@core/focus.service';
template: ` template: `
<div <div
class="fixed inset-0 z-40 grid place-items-center bg-black/40" class="fixed inset-0 z-40 grid place-items-center bg-black/40"
(keydown.escape)="pick('cancel')" (keydown)="onKeydown($event)"
> >
<div <div
class="w-[26rem] rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl" role="dialog"
aria-modal="true"
class="w-[26rem] border border-border bg-card p-4 text-card-foreground shadow-xl"
> >
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<h2 class="min-w-0 text-sm font-semibold"> <h2 class="min-w-0 text-sm font-semibold">
@@ -30,7 +33,7 @@ import { FocusService } from '@core/focus.service';
</h2> </h2>
@if (request().total > 1) { @if (request().total > 1) {
<span <span
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[0.625rem] font-medium text-muted-foreground" class="shrink-0 bg-muted px-2 py-0.5 text-[0.625rem] font-medium text-muted-foreground"
> >
{{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }} {{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }}
</span> </span>
@@ -47,21 +50,21 @@ import { FocusService } from '@core/focus.service';
<!-- comparison --> <!-- comparison -->
<div class="mt-3 grid grid-cols-2 gap-2"> <div class="mt-3 grid grid-cols-2 gap-2">
@for (side of comparison(); track side.label) { @for (side of comparison(); track side.label) {
<div class="rounded-md border border-border p-2.5"> <div class="border border-border p-2.5">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase"> <span class="text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
{{ i18n.t('dialogs.' + side.label) }} {{ i18n.t('dialogs.' + side.label) }}
</span> </span>
@if (side.newer) { @if (side.newer) {
<span class="rounded-full bg-success/15 px-1.5 text-[0.625rem] font-medium text-success"> <span class="bg-success/15 px-1.5 text-[0.625rem] font-medium text-success">
{{ i18n.t('dialogs.newer') }} {{ i18n.t('dialogs.newer') }}
</span> </span>
} }
</div> </div>
<div class="mt-1.5 flex items-center gap-1.5 text-xs"> <div class="mt-1.5 flex items-center gap-1.5 text-xs">
<ng-icon <ng-icon
[name]="isDir() ? 'mynaFolder' : 'mynaFile'" [name]="isDir() ? 'folder14' : 'file14'"
size="0.9rem" size="var(--icon-size-sm)"
class="shrink-0 text-muted-foreground" class="shrink-0 text-muted-foreground"
/> />
<span class="font-mono tabular-nums"> <span class="font-mono tabular-nums">
@@ -79,7 +82,7 @@ import { FocusService } from '@core/focus.service';
<div class="mt-4 flex items-center gap-2"> <div class="mt-4 flex items-center gap-2">
<button <button
(click)="pick('cancel')" (click)="pick('cancel')"
class="rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground" class="px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
> >
{{ i18n.t('common.cancel') }} {{ i18n.t('common.cancel') }}
</button> </button>
@@ -87,13 +90,13 @@ import { FocusService } from '@core/focus.service';
@if (request().total > 1) { @if (request().total > 1) {
<button <button
(click)="pick('skipAll')" (click)="pick('skipAll')"
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" class="px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
{{ i18n.t('dialogs.skipAll') }} {{ i18n.t('dialogs.skipAll') }}
</button> </button>
<button <button
(click)="pick('overwriteAll')" (click)="pick('overwriteAll')"
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" class="px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
{{ isDir() ? i18n.t('dialogs.mergeAll') : i18n.t('dialogs.replaceAll') }} {{ isDir() ? i18n.t('dialogs.mergeAll') : i18n.t('dialogs.replaceAll') }}
</button> </button>
@@ -101,14 +104,14 @@ import { FocusService } from '@core/focus.service';
} }
<button <button
(click)="pick('skip')" (click)="pick('skip')"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent" class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
> >
{{ i18n.t('dialogs.skip') }} {{ i18n.t('dialogs.skip') }}
</button> </button>
<button <button
#primaryBtn #primaryBtn
(click)="pick('overwrite')" (click)="pick('overwrite')"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90" class="bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
> >
{{ isDir() ? i18n.t('dialogs.merge') : i18n.t('dialogs.replace') }} {{ isDir() ? i18n.t('dialogs.merge') : i18n.t('dialogs.replace') }}
</button> </button>
@@ -127,7 +130,23 @@ export class OverwriteDialog {
private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn'); private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn');
constructor() { constructor() {
afterNextRender(() => this.primaryBtn().nativeElement.focus()); // per QUESTION, not per component: a run of collisions reuses this one
// instance (the signal goes null → next inside one change detection)
afterRenderEffect(() => {
this.request();
this.primaryBtn().nativeElement.focus();
});
}
/** Esc cancels the whole run (and stops here); Tab stays inside. */
onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.pick('cancel');
} else {
cycleFocus(event.currentTarget as Element, event);
}
} }
readonly isDir = computed(() => this.request().srcEntry.type === 'dir'); readonly isDir = computed(() => this.request().srcEntry.type === 'dir');
@@ -9,6 +9,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import { DialogsService, type PromptRequest } from '@core/dialogs.service'; import { DialogsService, type PromptRequest } from '@core/dialogs.service';
import { cycleFocus } from '@core/focus-cycle';
import { FocusService } from '@core/focus.service'; import { FocusService } from '@core/focus.service';
@Component({ @Component({
@@ -16,31 +17,34 @@ import { FocusService } from '@core/focus.service';
template: ` template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()"> <div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div <div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl" role="dialog"
aria-modal="true"
[attr.aria-label]="request().title"
class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()" (mousedown)="$event.stopPropagation()"
(keydown)="onKeydown($event)"
> >
<h2 class="text-sm font-semibold">{{ request().title }}</h2> <h2 class="text-sm font-semibold">{{ request().title }}</h2>
<input <input
#box #box
[value]="value()" [value]="value()"
(input)="value.set($any($event.target).value)" (input)="value.set($any($event.target).value)"
(keydown.enter)="submit()" (keydown.enter)="onEnter($event)"
(keydown.escape)="cancel()"
[placeholder]="request().placeholder ?? ''" [placeholder]="request().placeholder ?? ''"
spellcheck="false" spellcheck="false"
class="mt-3 h-8 w-full rounded-md border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring" class="mt-3 h-8 w-full border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring"
/> />
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<button <button
(click)="cancel()" (click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent" class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
> >
{{ i18n.t('common.cancel') }} {{ i18n.t('common.cancel') }}
</button> </button>
<button <button
(click)="submit()" (click)="submit()"
[disabled]="!value().trim()" [disabled]="!value().trim()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" class="bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{{ request().confirmLabel }} {{ request().confirmLabel }}
</button> </button>
@@ -67,6 +71,25 @@ export class PromptDialog {
}); });
} }
/** Esc anywhere in the dialog cancels; Tab stays inside it. Esc stops here:
* a window-level Escape (the connection manager closes on it) must not see
* the same key. */
onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.cancel();
} else {
cycleFocus(event.currentTarget as Element, event);
}
}
/** Enter submits — but not the Enter that commits an IME candidate. */
onEnter(event: Event): void {
if ((event as KeyboardEvent).isComposing) return;
this.submit();
}
submit(): void { submit(): void {
const value = this.value().trim(); const value = this.value().trim();
if (!value) return; if (!value) return;
@@ -1,8 +1,8 @@
<div class="flex h-screen flex-col bg-background text-foreground"> <div class="flex h-screen flex-col bg-background text-foreground">
@if (!ctx) { @if (!ctx) {
<div class="flex flex-1 items-center justify-center"> <div class="flex flex-1 items-center justify-center">
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm"> <div class="max-w-sm border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" /> <ng-icon name="closeCircle24" size="var(--icon-size-xl)" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p> <p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground"> <p class="mt-1 text-xs text-muted-foreground">
{{ i18n.t('editor.noFileBody') }} {{ i18n.t('editor.noFileBody') }}
@@ -20,7 +20,7 @@
[class.h-9]="!isMacChrome" [class.h-9]="!isMacChrome"
[class.pl-3]="!isMacChrome" [class.pl-3]="!isMacChrome"
> >
<ng-icon name="mynaFileText" size="0.95rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="fileText16" size="var(--icon-size-md)" class="shrink-0 text-muted-foreground" />
<h1 class="min-w-0 truncate font-mono text-sm font-semibold"> <h1 class="min-w-0 truncate font-mono text-sm font-semibold">
{{ ctx.name }}{{ dirty() ? ' •' : '' }} {{ ctx.name }}{{ dirty() ? ' •' : '' }}
</h1> </h1>
@@ -28,8 +28,8 @@
<div class="flex-1"></div> <div class="flex-1"></div>
<button <button
(click)="toggleWrap()" (click)="toggleWrap()"
[title]="i18n.t('editor.wrapHint', { alt: altLabel })" [title]="shortcuts.tip(i18n.t('editor.wrapHint'), 'toggle-wrap')"
class="rounded-md px-2 py-1 text-[0.6875rem] electrobun-webkit-app-region-no-drag hover:bg-accent" class="px-2 py-1 text-[0.6875rem] electrobun-webkit-app-region-no-drag hover:bg-accent"
[class.bg-accent]="wrap()" [class.bg-accent]="wrap()"
[class.text-foreground]="wrap()" [class.text-foreground]="wrap()"
[class.text-muted-foreground]="!wrap()" [class.text-muted-foreground]="!wrap()"
@@ -38,16 +38,16 @@
</button> </button>
<button <button
(click)="openFind()" (click)="openFind()"
[title]="i18n.t('editor.findHint', { mod: modLabel })" [title]="shortcuts.tip(i18n.t('editor.findHint'), 'find')"
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground" class="grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaSearch" size="0.85rem" /> <ng-icon name="search14" size="var(--icon-size-sm)" />
</button> </button>
</header> </header>
@if (findOpen()) { @if (findOpen()) {
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome px-3 py-1.5"> <div class="flex shrink-0 items-center gap-2 border-b border-border bg-toolbar px-3 py-1.5">
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="search14" size="var(--icon-size-sm)" class="shrink-0 text-muted-foreground" />
<input <input
#findBox #findBox
[value]="findQuery()" [value]="findQuery()"
@@ -55,7 +55,7 @@
(keydown)="onFindKeydown($event)" (keydown)="onFindKeydown($event)"
[placeholder]="i18n.t('editor.findPlaceholder')" [placeholder]="i18n.t('editor.findPlaceholder')"
spellcheck="false" spellcheck="false"
class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring" class="h-6 w-56 border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
/> />
<span <span
class="text-[0.6875rem] tabular-nums" class="text-[0.6875rem] tabular-nums"
@@ -66,25 +66,25 @@
</span> </span>
<button <button
(click)="findStep(-1)" (click)="findStep(-1)"
[title]="i18n.t('editor.prevMatch')" [title]="shortcuts.tipFixed(i18n.t('editor.prevMatch'), 'Shift+Enter')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaChevronUp" size="0.8rem" /> <ng-icon name="chevronUp14" size="var(--icon-size-sm)" />
</button> </button>
<button <button
(click)="findStep(1)" (click)="findStep(1)"
[title]="i18n.t('editor.nextMatch')" [title]="shortcuts.tipFixed(i18n.t('editor.nextMatch'), 'Enter')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaChevronDown" size="0.8rem" /> <ng-icon name="chevronDown14" size="var(--icon-size-sm)" />
</button> </button>
<div class="flex-1"></div> <div class="flex-1"></div>
<button <button
(click)="closeFind()" (click)="closeFind()"
[title]="i18n.t('editor.closeHint')" [title]="i18n.t('editor.closeHint')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaX" size="0.8rem" /> <ng-icon name="close14" size="var(--icon-size-sm)" />
</button> </button>
</div> </div>
} }
@@ -93,24 +93,24 @@
<div <div
class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[0.6875rem] text-destructive" class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[0.6875rem] text-destructive"
> >
<ng-icon name="mynaXCircle" size="0.85rem" class="shrink-0" /> <ng-icon name="closeCircle14" size="var(--icon-size-sm)" class="shrink-0" />
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }} {{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
</div> </div>
} }
@if (loading()) { @if (loading()) {
<div class="flex flex-1 items-center justify-center"> <div class="flex flex-1 items-center justify-center">
<ng-icon name="mynaSpinner" size="1.25rem" class="animate-spin text-primary" /> <ng-icon name="tdesignLoading" size="var(--icon-size-lg)" class="animate-spin text-primary" />
</div> </div>
} @else if (loadError(); as error) { } @else if (loadError(); as error) {
<div class="flex flex-1 items-center justify-center"> <div class="flex flex-1 items-center justify-center">
<div class="max-w-md rounded-lg border border-border bg-card p-6 text-center shadow-sm"> <div class="max-w-md border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" /> <ng-icon name="closeCircle24" size="var(--icon-size-xl)" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p> <p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ error }}</p> <p class="mt-1 text-xs text-muted-foreground">{{ error }}</p>
<button <button
(click)="discardAndClose()" (click)="discardAndClose()"
class="mt-4 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent" class="mt-4 border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
> >
{{ i18n.t('editor.closeWindow') }} {{ i18n.t('editor.closeWindow') }}
</button> </button>
@@ -141,7 +141,7 @@
</div> </div>
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2"> <footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2">
<span class="text-[0.6875rem] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span> <span class="text-[0.6875rem] text-muted-foreground">{{ footerHint() }}</span>
<span class="text-[0.6875rem] text-muted-foreground">·</span> <span class="text-[0.6875rem] text-muted-foreground">·</span>
<span class="text-[0.6875rem] tabular-nums text-muted-foreground"> <span class="text-[0.6875rem] tabular-nums text-muted-foreground">
{{ i18n.t('editor.lines', { count: lineCount() }) }} {{ i18n.t('editor.lines', { count: lineCount() }) }}
@@ -149,17 +149,17 @@
<div class="flex-1"></div> <div class="flex-1"></div>
<button <button
(click)="requestClose()" (click)="requestClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent" class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
> >
{{ i18n.t('editor.close') }} {{ i18n.t('editor.close') }}
</button> </button>
<button <button
(click)="save()" (click)="save()"
[disabled]="saving() || !dirty()" [disabled]="saving() || !dirty()"
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" class="flex items-center gap-1.5 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
@if (saving()) { @if (saving()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" /> <ng-icon name="tdesignLoading" size="var(--icon-size-sm)" class="animate-spin" />
} }
{{ i18n.t('common.save') }} {{ i18n.t('common.save') }}
</button> </button>
@@ -171,7 +171,11 @@
@if (closePrompt()) { @if (closePrompt()) {
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()"> <div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()">
<div <div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl" #prompt
role="dialog"
aria-modal="true"
[attr.aria-label]="i18n.t('editor.saveChangesTitle', { name: ctx.name })"
class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()" (mousedown)="$event.stopPropagation()"
> >
<h2 class="text-sm font-semibold">{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}</h2> <h2 class="text-sm font-semibold">{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}</h2>
@@ -181,21 +185,22 @@
<div class="mt-4 flex items-center gap-2"> <div class="mt-4 flex items-center gap-2">
<button <button
(click)="discardAndClose()" (click)="discardAndClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent" class="px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent"
> >
{{ i18n.t('editor.dontSave') }} {{ i18n.t('editor.dontSave') }}
</button> </button>
<div class="flex-1"></div> <div class="flex-1"></div>
<button <button
(click)="cancelClose()" (click)="cancelClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent" class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
> >
{{ i18n.t('common.cancel') }} {{ i18n.t('common.cancel') }}
</button> </button>
<button <button
#promptSave
(click)="saveAndClose()" (click)="saveAndClose()"
[disabled]="saving()" [disabled]="saving()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" class="bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{{ i18n.t('common.save') }} {{ i18n.t('common.save') }}
</button> </button>
@@ -2,7 +2,9 @@ import {
Component, Component,
DOCUMENT, DOCUMENT,
ElementRef, ElementRef,
Injector,
afterNextRender, afterNextRender,
afterRenderEffect,
computed, computed,
effect, effect,
inject, inject,
@@ -11,7 +13,8 @@ import {
} from '@angular/core'; } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '@core/platform'; import { cycleFocus } from '@core/focus-cycle';
import { blockNativeContextMenu, blockReloadKeys, EDITOR_CONTEXT, IS_MAC_CHROME } from '@core/platform';
import { RpcService } from '@core/rpc.service'; import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service'; import { ShortcutsService } from '@core/shortcuts.service';
@@ -43,8 +46,6 @@ export class EditorWindow {
readonly i18n = inject(I18nService); readonly i18n = inject(I18nService);
readonly ctx = EDITOR_CONTEXT; readonly ctx = EDITOR_CONTEXT;
readonly isMacChrome = IS_MAC_CHROME; readonly isMacChrome = IS_MAC_CHROME;
readonly modLabel = MOD_LABEL;
readonly altLabel = ALT_LABEL;
readonly loading = signal(true); readonly loading = signal(true);
readonly loadError = signal<string | null>(null); readonly loadError = signal<string | null>(null);
@@ -92,6 +93,9 @@ export class EditorWindow {
); );
private readonly box = viewChild<ElementRef<HTMLTextAreaElement>>('box'); private readonly box = viewChild<ElementRef<HTMLTextAreaElement>>('box');
private readonly prompt = viewChild<ElementRef<HTMLElement>>('prompt');
private readonly promptSave = viewChild<ElementRef<HTMLButtonElement>>('promptSave');
private readonly injector = inject(Injector);
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter'); private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox'); private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
private cachedCharWidth = 0; private cachedCharWidth = 0;
@@ -102,7 +106,22 @@ export class EditorWindow {
// injected for its side effects: fetches settings and applies theme/accent/ // injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root // text-size data attributes to this window's document root
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService); readonly shortcuts = inject(ShortcutsService);
/** "⌘S save · ⌘F find · ⌘W close" — the keys from the live keymap, so a remap
* shows here too; an unbound command drops out. */
readonly footerHint = computed(() =>
(
[
['save', 'editor.verbs.save'],
['find', 'editor.verbs.find'],
['close-window', 'editor.verbs.close'],
] as const
)
.filter(([id]) => this.shortcuts.label(id))
.map(([id, verb]) => `${this.shortcuts.label(id)} ${this.i18n.t(verb)}`)
.join(' · '),
);
private draftTimer: ReturnType<typeof setTimeout> | null = null; private draftTimer: ReturnType<typeof setTimeout> | null = null;
private lastSentDirty = false; private lastSentDirty = false;
@@ -122,6 +141,11 @@ export class EditorWindow {
}); });
afterNextRender(() => void this.load(ctx.sessionId, ctx.path)); afterNextRender(() => void this.load(ctx.sessionId, ctx.path));
// the unsaved-changes prompt takes the keyboard, so keys can't edit the
// hidden text behind it: focus its Save button whenever it shows
afterRenderEffect(() => {
if (this.closePrompt()) this.promptSave()?.nativeElement.focus();
});
} }
private async load(sessionId: string, path: string): Promise<void> { private async load(sessionId: string, path: string): Promise<void> {
@@ -134,7 +158,8 @@ export class EditorWindow {
this.savedContent.set(res.content); this.savedContent.set(res.content);
this.text.set(res.content); this.text.set(res.content);
this.loading.set(false); this.loading.set(false);
queueMicrotask(() => this.box()?.nativeElement.focus()); // after the render that creates the textarea — a microtask runs before it
afterNextRender(() => this.box()?.nativeElement.focus(), { injector: this.injector });
} }
blockContextMenu(event: Event): void { blockContextMenu(event: Event): void {
@@ -168,11 +193,14 @@ export class EditorWindow {
} }
this.findOpen.set(true); this.findOpen.set(true);
this.activeMatch.set(0); this.activeMatch.set(0);
queueMicrotask(() => { afterNextRender(
const input = this.findBox()?.nativeElement; () => {
input?.focus(); const input = this.findBox()?.nativeElement;
input?.select(); input?.focus();
}); input?.select();
},
{ injector: this.injector },
);
} }
closeFind(): void { closeFind(): void {
@@ -197,6 +225,29 @@ export class EditorWindow {
this.goTo((this.activeMatch() + delta + count) % count); this.goTo((this.activeMatch() + delta + count) % count);
} }
/** ⌘G / ⇧⌘G (F3 / ⇧F3): the next / previous match from the caret, find bar
* open or not — the query survives closing it. With no query yet, open the bar. */
findNext(delta: 1 | -1): void {
if (!this.findQuery()) {
this.openFind();
return;
}
const list = this.matches();
if (!list.length) return;
const el = this.box()?.nativeElement;
let index = -1;
if (delta > 0) {
const caret = el?.selectionEnd ?? 0;
index = list.findIndex((start) => start >= caret);
if (index < 0) index = 0;
} else {
const caret = el?.selectionStart ?? 0;
for (let i = list.length - 1; i >= 0 && index < 0; i--) if (list[i] < caret) index = i;
if (index < 0) index = list.length - 1;
}
this.goTo(index);
}
onFindKeydown(event: KeyboardEvent): void { onFindKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
@@ -319,55 +370,78 @@ export class EditorWindow {
// ---- keyboard ---- // ---- keyboard ----
onKeydown(event: KeyboardEvent): void { onKeydown(event: KeyboardEvent): void {
// Chord shortcuts resolve through the shared keymap (ShortcutsService); this.handleKey(event);
// Escape and Tab stay below because their meaning is THIS window's state blockReloadKeys(event); // F5 / Ctrl+R must not reload over unsaved text
// (close prompt up? find bar open? caret in the textarea?), not a binding. }
const match = this.shortcuts.resolve(event, 'editor');
if (match) { /**
// scale keys must not fire while typing — but the editor's own chords * The editor's keys come from the shared keymap (`editor` scope); they fire
// (save/find/wrap) are WANTED mid-typing, so the guard is per-shortcut * mid-typing because they are all chords or F-keys. Escape and Tab stay here:
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return; * their meaning is this window's state (prompt up? find bar open?).
event.preventDefault(); */
switch (match.id) { private handleKey(event: KeyboardEvent): void {
case 'save': if (event.defaultPrevented) return;
void this.save(); const id = this.shortcuts.claim(event, 'editor');
break; if (this.closePrompt()) {
case 'find': // the unsaved-changes prompt owns the keyboard: ⌘S is its Save, Esc its
this.openFind(); // Cancel, Tab walks its buttons; other commands wait
break; if (id === 'save') void this.saveAndClose();
case 'close-window': else if (id === 'scale-up' || id === 'scale-down' || id === 'scale-reset') this.scale(id);
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W else if (!id && event.key === 'Escape') {
// and bun routes it back as editorCloseRequest event.preventDefault();
this.requestClose(); this.cancelClose();
break; } else if (!id) {
case 'toggle-wrap': const prompt = this.prompt()?.nativeElement;
this.toggleWrap(); if (prompt) cycleFocus(prompt, event);
break;
case 'scale-up':
this.settings.setUiScale(this.settings.uiScale() + 0.1);
break;
case 'scale-down':
this.settings.setUiScale(this.settings.uiScale() - 0.1);
break;
case 'scale-reset':
this.settings.setUiScale(1);
break;
} }
return; return;
} }
if (event.key === 'Escape' && this.closePrompt()) { switch (id) {
event.preventDefault(); case 'save':
this.cancelClose(); void this.save();
} else if (event.key === 'Escape' && this.findOpen()) { return;
case 'find':
this.openFind();
return;
case 'find-next':
this.findNext(1);
return;
case 'find-prev':
this.findNext(-1);
return;
case 'close-window':
this.requestClose();
return;
case 'toggle-wrap':
this.toggleWrap();
return;
case 'scale-up':
case 'scale-down':
case 'scale-reset':
this.scale(id);
return;
}
if (event.key === 'Escape' && this.findOpen()) {
event.preventDefault(); event.preventDefault();
this.closeFind(); this.closeFind();
} else if (event.key === 'Tab' && event.target === this.box()?.nativeElement) { } else if (
event.key === 'Tab' &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
event.target === this.box()?.nativeElement
) {
// keep Tab as an input character inside the editor // keep Tab as an input character inside the editor
event.preventDefault(); event.preventDefault();
this.insertTab(); this.insertTab();
} }
} }
private scale(id: 'scale-up' | 'scale-down' | 'scale-reset'): void {
if (id === 'scale-reset') this.settings.setUiScale(1);
else this.settings.setUiScale(this.settings.uiScale() + (id === 'scale-up' ? 0.1 : -0.1));
}
private insertTab(): void { private insertTab(): void {
const el = this.box()!.nativeElement; const el = this.box()!.nativeElement;
// execCommand keeps the native undo stack intact where supported // execCommand keeps the native undo stack intact where supported
@@ -1,24 +1,28 @@
<!-- session strip --> <!-- session strip -->
<div class="relative flex items-center gap-1.5 border-b border-border px-2 py-1"> <div
class="relative flex items-center gap-1.5 border-b border-border bg-toolbar px-2 py-1"
[style.box-shadow]="activeMarker()"
>
<button <button
(click)="toggleMenu()" #sourceBtn
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent" (click)="openSourcePicker(false)"
[title]="i18n.t('panel.choosePanel')" class="flex min-w-0 items-center gap-1.5 px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent"
[title]="shortcuts.tip(i18n.t('panel.choosePanel'), 'source-picker')"
> >
@if (panel().connecting()) { @if (panel().connecting()) {
<ng-icon name="mynaSpinner" class="shrink-0 animate-spin text-primary" size="var(--action-icon)" /> <ng-icon name="tdesignLoading" class="shrink-0 animate-spin text-primary" size="var(--icon-size-sm)" />
} @else { } @else {
<ng-icon <ng-icon
[name]="isRemote() ? 'mynaServers' : 'mynaHardDrive'" [name]="isRemote() ? 'server14' : 'hardDrive14'"
[class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'" [class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
size="var(--action-icon)" size="var(--icon-size-sm)"
/> />
} }
<span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span> <span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span>
<ng-icon <ng-icon
name="mynaChevronDown" name="chevronDown14"
class="shrink-0 text-muted-foreground" class="shrink-0 text-muted-foreground"
size="calc(var(--action-icon) - 0.1rem)" size="var(--icon-size-sm)"
/> />
</button> </button>
<div class="flex-1"></div> <div class="flex-1"></div>
@@ -26,167 +30,96 @@
<!-- panel actions: act on THIS panel. Frequent/destructive verbs grow text <!-- panel actions: act on THIS panel. Frequent/destructive verbs grow text
labels when the panel is wide enough (container query). --> labels when the panel is wide enough (container query). -->
<button <button
(click)="copyToOther()" (click)="run('send')"
[title]="side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft')" [title]="shortcuts.tip(side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft'), 'send')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-primary" class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-primary"
> >
<ng-icon [name]="copyIcon()" size="var(--action-icon)" /> <ng-icon [name]="copyIcon()" size="var(--icon-size-sm)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.copy') }}</span> <span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.copy') }}</span>
</button> </button>
<button <button
(click)="run('edit')" (click)="run('edit')"
[title]="i18n.t('panel.editFile')" [title]="shortcuts.tip(i18n.t('panel.editFile'), 'edit')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground" class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaFileText" size="var(--action-icon)" /> <ng-icon name="fileText14" size="var(--icon-size-sm)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span> <span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span>
</button> </button>
<button <button
(click)="run('rename')" (click)="run('rename')"
[title]="i18n.t('panel.rename')" [title]="shortcuts.tip(i18n.t('panel.rename'), 'rename')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground" class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaEdit" size="var(--action-icon)" /> <ng-icon name="edit14" size="var(--icon-size-sm)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span> <span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span>
</button> </button>
<button <button
(click)="run('mkdir')" (click)="run('new-folder')"
[title]="i18n.t('panel.newFolder')" [title]="shortcuts.tip(i18n.t('panel.newFolder'), 'new-folder')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaFolderPlus" size="var(--action-icon)" /> <ng-icon name="folderAdd14" size="var(--icon-size-sm)" />
</button> </button>
<button <button
(click)="run('createFile')" (click)="run('new-file')"
[title]="i18n.t('panel.newFile')" [title]="shortcuts.tip(i18n.t('panel.newFile'), 'new-file')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaFilePlus" size="var(--action-icon)" /> <ng-icon name="fileAdd14" size="var(--icon-size-sm)" />
</button> </button>
@if (chmodAvailable()) { @if (chmodAvailable()) {
<button <button
(click)="run('chmod')" (click)="run('chmod')"
[title]="i18n.t('panel.permissions')" [title]="shortcuts.tip(i18n.t('panel.permissions'), 'chmod')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaLock" size="var(--action-icon)" /> <ng-icon name="lockOn14" size="var(--icon-size-sm)" />
</button> </button>
} }
<button <button
(click)="run('delete')" (click)="run('delete')"
[title]="i18n.t('panel.deleteHint')" [title]="shortcuts.tip(i18n.t('common.delete'), 'delete')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-destructive" class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-destructive"
> >
<ng-icon name="mynaTrash" size="var(--action-icon)" /> <ng-icon name="delete14" size="var(--icon-size-sm)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span> <span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span>
</button> </button>
@if (menuOpen()) {
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
<div
class="absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl"
>
<button
(click)="pickLocal()"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1">{{ i18n.t('common.local') }}</span>
@if (!isRemote()) {
<ng-icon name="mynaCheck" size="0.85rem" class="shrink-0 text-primary" />
}
</button>
<!-- Windows drive roots (empty list elsewhere) -->
@for (drive of drives(); track drive) {
<button
(click)="pickDrive(drive)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 font-mono">{{ drive }}</span>
</button>
}
@if (connections.grouped().loose.length || connections.grouped().stacks.length) {
<div class="my-1 border-t border-border"></div>
}
@for (connection of connections.grouped().loose; track connection.id) {
<button
(click)="pickConnection(connection)"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ connection.name }}</span>
</button>
}
@for (group of connections.grouped().stacks; track group.stack.id) {
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
{{ group.stack.name }}
</div>
@for (connection of group.connections; track connection.id) {
<button
(click)="pickConnection(connection)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ connection.name }}</span>
</button>
}
}
@if (connections.sshHosts().length) {
<div class="my-1 border-t border-border"></div>
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
~/.ssh/config
</div>
@for (host of connections.sshHosts(); track host.name) {
<button
(click)="pickSshHost(host)"
[disabled]="host.unsupported"
[title]="sshHostTitle(host)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent disabled:opacity-60 disabled:hover:bg-transparent"
>
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ host.name }}</span>
</button>
}
}
</div>
}
</div> </div>
<!-- path bar --> <!-- path bar -->
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5"> <div class="flex items-center gap-1 border-b border-border bg-toolbar-2 px-2 py-1.5">
<button <button
(click)="panel().up()" (click)="panel().up()"
[disabled]="blocked()" [disabled]="blocked()"
[title]="i18n.t('panel.parentDir')" [title]="shortcuts.tip(i18n.t('panel.parentDir'), 'parent')"
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent" class="grid size-(--path-ctl-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
> >
<ng-icon name="mynaArrowUp" size="1rem" /> <ng-icon name="arrowUp16" size="var(--icon-size-md)" />
</button> </button>
<!-- no blanket stopPropagation: the window handler lets only ⌘/Ctrl chords
through from a text field, and its WebView2 reload guard must see F5 -->
<input <input
#pathBox
[value]="pathDraft()" [value]="pathDraft()"
(input)="pathDraft.set($any($event.target).value)" (input)="pathDraft.set($any($event.target).value)"
(keydown.enter)="commitPath()" (keydown.enter)="commitPath()"
(keydown.escape)="resetPath()" (keydown.escape)="resetPath()"
(keydown)="$event.stopPropagation()"
[disabled]="blocked()" [disabled]="blocked()"
[title]="shortcuts.tip(i18n.t('keys.focus-path'), 'focus-path')"
spellcheck="false" spellcheck="false"
class="h-(--path-ctl-h) min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50" class="h-(--path-ctl-h) min-w-0 flex-1 border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
/> />
<button <button
(click)="panel().refresh()" (click)="run('refresh')"
[disabled]="blocked()" [disabled]="blocked()"
[title]="i18n.t('panel.refresh')" [title]="shortcuts.tip(i18n.t('panel.refresh'), 'refresh')"
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent" class="grid size-(--path-ctl-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
> >
@if (panel().loading()) { @if (panel().loading()) {
<ng-icon name="mynaSpinner" size="1rem" class="animate-spin" /> <ng-icon name="tdesignLoading" size="var(--icon-size-md)" class="animate-spin" />
} @else { } @else {
<ng-icon name="mynaRefresh" size="1rem" /> <ng-icon name="refresh16" size="var(--icon-size-md)" />
} }
</button> </button>
</div> </div>
@@ -195,9 +128,13 @@
<div <div
(contextmenu)="headerContextMenu($event)" (contextmenu)="headerContextMenu($event)"
[style.grid-template-columns]="gridTemplate()" [style.grid-template-columns]="gridTemplate()"
class="grid border-b border-border text-[0.6875rem] font-medium text-muted-foreground" class="grid border-b border-border bg-toolbar-3 text-[0.6875rem] font-medium text-muted-foreground"
> >
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')"> <button
class="px-2 py-1 text-left hover:text-foreground"
[title]="sortTip('name')"
(click)="panel().toggleSort('name')"
>
{{ i18n.t('columns.name') }} {{ sortIndicator('name') }} {{ i18n.t('columns.name') }} {{ sortIndicator('name') }}
</button> </button>
@for (col of visibleColumns(); track col.key) { @for (col of visibleColumns(); track col.key) {
@@ -205,6 +142,7 @@
class="px-2 py-1 hover:text-foreground" class="px-2 py-1 hover:text-foreground"
[class.text-right]="col.key === 'size'" [class.text-right]="col.key === 'size'"
[class.text-left]="col.key !== 'size'" [class.text-left]="col.key !== 'size'"
[title]="sortTip(col.key)"
(click)="panel().toggleSort(col.key)" (click)="panel().toggleSort(col.key)"
> >
{{ i18n.t('columns.' + col.key) }} {{ sortIndicator(col.key) }} {{ i18n.t('columns.' + col.key) }} {{ sortIndicator(col.key) }}
@@ -215,7 +153,7 @@
<!-- type-to-filter bar (appears while a query is active) --> <!-- type-to-filter bar (appears while a query is active) -->
@if (panel().filter(); as query) { @if (panel().filter(); as query) {
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1"> <div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="search14" size="var(--icon-size-sm)" class="shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span> <span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
<span class="shrink-0 text-[0.6875rem] text-muted-foreground"> <span class="shrink-0 text-[0.6875rem] text-muted-foreground">
{{ i18n.t('panel.matches', { count: filterMatches() }) }} {{ i18n.t('panel.matches', { count: filterMatches() }) }}
@@ -223,9 +161,9 @@
<button <button
(click)="clearFilter()" (click)="clearFilter()"
[title]="i18n.t('panel.clearFilter')" [title]="i18n.t('panel.clearFilter')"
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-5 shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaX" size="0.8rem" /> <ng-icon name="close14" size="var(--icon-size-sm)" />
</button> </button>
</div> </div>
} }
@@ -243,13 +181,13 @@
@if (panel().connecting()) { @if (panel().connecting()) {
<div class="panel-fade-in grid h-full place-items-center"> <div class="panel-fade-in grid h-full place-items-center">
<div class="flex flex-col items-center gap-3"> <div class="flex flex-col items-center gap-3">
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" /> <ng-icon name="tdesignLoading" size="var(--icon-size-xl)" class="animate-spin text-primary" />
<span class="text-xs text-muted-foreground"> <span class="text-xs text-muted-foreground">
{{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }} {{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }}
</span> </span>
<button <button
(click)="cancelConnect()" (click)="cancelConnect()"
class="rounded-md border border-border px-3 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" class="border border-border px-3 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
{{ i18n.t('common.cancel') }} {{ i18n.t('common.cancel') }}
</button> </button>
@@ -264,6 +202,7 @@
(contextmenu)="rowContextMenu($event, i)" (contextmenu)="rowContextMenu($event, i)"
[style.grid-template-columns]="gridTemplate()" [style.grid-template-columns]="gridTemplate()"
class="grid h-(--row-h) items-center text-(length:--list-font)" class="grid h-(--row-h) items-center text-(length:--list-font)"
[class.bg-row-alt]="i % 2 === 1 && i !== panel().cursor() && !isSelected(entry)"
[class.bg-cursor-tint]="i === panel().cursor() && !isSelected(entry)" [class.bg-cursor-tint]="i === panel().cursor() && !isSelected(entry)"
[class.bg-primary/15]="isSelected(entry)" [class.bg-primary/15]="isSelected(entry)"
[class.ring-1]="i === panel().cursor() && isActive()" [class.ring-1]="i === panel().cursor() && isActive()"
@@ -271,7 +210,7 @@
[class.ring-ring]="i === panel().cursor() && isActive()" [class.ring-ring]="i === panel().cursor() && isActive()"
> >
<div class="flex min-w-0 items-center gap-1.5 px-2"> <div class="flex min-w-0 items-center gap-1.5 px-2">
<ng-icon [name]="iconFor(entry)" size="0.95rem" [class]="iconClass(entry)" /> <ng-icon [name]="iconFor(entry)" size="var(--icon-size-md)" [class]="iconClass(entry)" />
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span> <span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
</div> </div>
@for (col of visibleColumns(); track col.key) { @for (col of visibleColumns(); track col.key) {
@@ -321,7 +260,7 @@
</div> </div>
@if (panel().slowLoad() && !panel().connecting()) { @if (panel().slowLoad() && !panel().connecting()) {
<div class="panel-fade-in absolute inset-0 z-10 grid place-items-center bg-background/60"> <div class="panel-fade-in absolute inset-0 z-10 grid place-items-center bg-background/60">
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" /> <ng-icon name="tdesignLoading" size="var(--icon-size-xl)" class="animate-spin text-primary" />
</div> </div>
} }
</div> </div>
+262 -118
View File
@@ -9,20 +9,22 @@ import {
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { isRepeatable } from '@shared/keymap';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types'; import type { ColumnKey, FileEntry, SshConfigHost } from '@shared/types';
import { ConnectionsService } from '@core/connections.service'; import { ConnectionsService } from '@core/connections.service';
import { ContextMenuService } from '@core/context-menu.service'; import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
import { DialogsService } from '@core/dialogs.service'; import { DialogsService } from '@core/dialogs.service';
import { FileActionsService } from '@core/file-actions.service'; import { FileActionsService, type FileCommand, type FileTarget } from '@core/file-actions.service';
import { fileGlyph, fileIconClass, fileKindKey } from '@core/file-icons'; import { fileGlyph, fileIconClass, fileKindKey } from '@core/file-icons';
import { formatMode } from '@core/format'; import { formatMode } from '@core/format';
import type { Icon14, Icon16 } from '@core/icons';
import { remPx } from '@core/metrics';
import { PanelsService, type Side, type SortKey } from '@core/panels.service'; import { PanelsService, type Side, type SortKey } from '@core/panels.service';
import { IS_WINDOWS } from '@core/platform'; import { IS_WINDOWS } from '@core/platform';
import { RpcService } from '@core/rpc.service'; import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service'; import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service'; import { ShortcutsService, type ShortcutId } from '@core/shortcuts.service';
import { TransfersService } from '@core/transfers.service';
/** Optional columns in fixed display order, with their track widths. */ /** Optional columns in fixed display order, with their track widths. */
const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [ const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
@@ -34,6 +36,33 @@ const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
{ key: 'group', label: 'Group', width: '6rem' }, { key: 'group', label: 'Group', width: '6rem' },
]; ];
/** Rows a PageUp / PageDown jumps. */
const PAGE = 20;
/** Cursor movements: with Shift they extend the selection instead. */
const MOVES = new Set<ShortcutId>([
'cursor-up',
'cursor-down',
'page-up',
'page-down',
'cursor-first',
'cursor-last',
]);
/** The same keydown with Shift lifted — what a Shift+movement spells underneath. */
function withoutShift(event: KeyboardEvent) {
return {
key: event.key,
code: event.code,
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
altKey: event.altKey,
shiftKey: false,
target: event.target,
getModifierState: (key: string) => event.getModifierState(key),
};
}
@Component({ @Component({
selector: 'app-file-panel', selector: 'app-file-panel',
imports: [NgIcon], imports: [NgIcon],
@@ -46,29 +75,34 @@ export class FilePanel {
readonly panels = inject(PanelsService); readonly panels = inject(PanelsService);
readonly actions = inject(FileActionsService); readonly actions = inject(FileActionsService);
readonly transfers = inject(TransfersService);
readonly connections = inject(ConnectionsService); readonly connections = inject(ConnectionsService);
readonly dialogs = inject(DialogsService); readonly dialogs = inject(DialogsService);
readonly shortcuts = inject(ShortcutsService);
private readonly menu = inject(ContextMenuService); private readonly menu = inject(ContextMenuService);
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly shortcuts = inject(ShortcutsService);
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
readonly panel = computed(() => this.panels.get(this.side())); readonly panel = computed(() => this.panels.get(this.side()));
readonly isActive = computed(() => this.panels.activeSide() === this.side()); readonly isActive = computed(() => this.panels.activeSide() === this.side());
/** The active panel's marker: an accent line along its top edge. Inset, so
* switching panels never shifts the layout. */
readonly activeMarker = computed(() =>
this.isActive() ? 'inset 0 0.125rem 0 var(--primary)' : null,
);
readonly isRemote = computed(() => this.panel().sessionId() !== 'local'); readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
/** Input is ignored while the panel is connecting or a listing is in flight. */ /** Input is ignored while the panel is connecting or a listing is in flight. */
readonly blocked = computed(() => this.panel().busy()); readonly blocked = computed(() => this.panel().busy());
/** The copy button points toward the other panel. */ /** The copy button points toward the other panel. */
readonly copyIcon = computed(() => (this.side() === 'left' ? 'mynaArrowRight' : 'mynaArrowLeft')); readonly copyIcon = computed<Icon14>(() => (this.side() === 'left' ? 'arrowRight14' : 'arrowLeft14'));
readonly pathDraft = signal(''); readonly pathDraft = signal('');
readonly menuOpen = signal(false);
/** Windows drive roots for the source picker; always empty elsewhere. */ /** Windows drive roots for the source picker; always empty elsewhere. */
readonly drives = signal<string[]>([]); readonly drives = signal<string[]>([]);
/** chmod on Windows local files only flips the read-only bit — hide it. */ /** chmod on Windows local files only flips the read-only bit — hide it. */
readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote())); readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote()));
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list'); private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
private readonly sourceBtn = viewChild.required<ElementRef<HTMLButtonElement>>('sourceBtn');
private readonly pathBox = viewChild.required<ElementRef<HTMLInputElement>>('pathBox');
private lastClickedIndex = 0; private lastClickedIndex = 0;
// locale-reactive wrappers: reading i18n.locale() inside makes the file-list // locale-reactive wrappers: reading i18n.locale() inside makes the file-list
@@ -90,17 +124,6 @@ export class FilePanel {
['minmax(0,1fr)', ...this.visibleColumns().map((c) => c.width)].join(' '), ['minmax(0,1fr)', ...this.visibleColumns().map((c) => c.width)].join(' '),
); );
headerContextMenu(event: MouseEvent): void {
event.preventDefault();
this.menu.open(event, this.menu.columnItems(this.settings.columns()));
}
pickDrive(drive: string): void {
this.menuOpen.set(false);
this.activate();
void this.panel().load(drive);
}
constructor() { constructor() {
if (IS_WINDOWS && this.rpc.available) { if (IS_WINDOWS && this.rpc.available) {
void this.rpc.request.listDrives({}).then((res) => { void this.rpc.request.listDrives({}).then((res) => {
@@ -109,11 +132,23 @@ export class FilePanel {
} }
// keep the editable path bar in sync with the actual cwd // keep the editable path bar in sync with the actual cwd
effect(() => this.pathDraft.set(this.panel().cwd())); effect(() => this.pathDraft.set(this.panel().cwd()));
// focus follows the active side (Tab switches panels, dialogs refocus on close) // focus follows the active side (Tab switches panels, dialogs and menus
// refocus on close) — but never while a dialog or menu is up: a run of
// overwrite questions reuses one dialog, and stealing focus back to the
// list behind it would hand Enter and Delete to the panel
effect(() => { effect(() => {
this.panels.focusTick(); this.panels.focusTick();
if (this.isActive()) this.listEl().nativeElement.focus(); if (this.isActive() && !this.dialogs.anyOpen && !this.menu.request()) {
this.listEl().nativeElement.focus();
}
}); });
// keys that land on <body> (a click dropped focus) still reach this list
effect(() => this.panels.registerListKeys(this.side(), (event) => this.onKeydown(event)));
}
headerContextMenu(event: MouseEvent): void {
event.preventDefault();
this.menu.open(event, this.menu.columnItems(this.settings.columns()));
} }
activate(): void { activate(): void {
@@ -124,38 +159,95 @@ export class FilePanel {
this.panels.cancelConnect(this.side()); this.panels.cancelConnect(this.side());
} }
copyToOther(): void { /** A toolbar button: the same command the keyboard and the menus run. */
run(id: FileCommand): void {
if (this.blocked()) return; if (this.blocked()) return;
this.activate(); this.activate();
void this.transfers.copyToOtherPanel(this.side()); this.actions.run(id, this.cursorTarget());
} }
run(action: 'edit' | 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void { private cursorTarget(): FileTarget {
if (this.blocked()) return; const panel = this.panel();
return { side: this.side(), panel, entry: panel.sorted()[panel.cursor()] ?? null };
}
// ---- source picker: what this panel shows ----
/** The session button's menu (also ⌘K): Local, drives, saved connections by
* stack, ~/.ssh/config hosts — through the shared context menu, so it gets
* the same keyboard handling and look. */
async openSourcePicker(keyboard: boolean): Promise<void> {
this.activate(); this.activate();
void this.actions[action](this.panel()); // re-read ~/.ssh/config on each open so external edits show up — BEFORE
// the items are built, the menu holds a snapshot
await this.connections.reloadSshHosts();
const rect = this.sourceBtn().nativeElement.getBoundingClientRect();
this.menu.open({ clientX: rect.left, clientY: rect.bottom + remPx(0.25) }, this.sourceItems(), {
keyboard,
onPick: (action) => this.pickSource(action),
});
} }
pickLocal(): void { private sourceItems(): MenuItem[] {
this.menuOpen.set(false); const items: MenuItem[] = [
void this.panels.disconnectPanel(this.side()); {
label: this.i18n.t('common.local'),
action: 'local',
icon: 'hardDrive14',
checked: !this.isRemote(),
},
];
// Windows drive roots (empty list elsewhere)
for (const drive of this.drives()) {
items.push({ label: drive, action: `drive:${drive}`, icon: 'hardDrive14' });
}
const { loose, stacks } = this.connections.grouped();
if (loose.length || stacks.length) items.push({ type: 'separator' });
const server = (connection: { id: string; name: string }): MenuItem => ({
label: connection.name,
action: `conn:${connection.id}`,
icon: 'server14',
});
items.push(...loose.map(server));
for (const group of stacks) {
items.push({ type: 'header', label: group.stack.name }, ...group.connections.map(server));
}
const hosts = this.connections.sshHosts();
if (hosts.length) {
items.push({ type: 'separator' }, { type: 'header', label: this.i18n.t('panel.sshConfig') });
for (const host of hosts) {
items.push({
label: host.name,
action: `ssh:${host.name}`,
icon: 'server14',
disabled: !!host.unsupported,
tooltip: this.sshHostTitle(host),
});
}
}
return items;
} }
pickConnection(connection: SavedConnection): void { private pickSource(action: string): void {
this.menuOpen.set(false); const [kind, ...rest] = action.split(':');
const value = rest.join(':'); // a drive root ("C:\") has a colon of its own
this.activate(); this.activate();
void this.panels.connectPanel(this.side(), connection, connection.name, connection.localDir); if (kind === 'local') {
void this.panels.disconnectPanel(this.side());
} else if (kind === 'drive') {
void this.panel().load(value);
} else if (kind === 'conn') {
const connection = this.connections.connections().find((c) => c.id === value);
if (connection) {
void this.panels.connectPanel(this.side(), connection, connection.name, connection.localDir);
}
} else if (kind === 'ssh') {
const host = this.connections.sshHosts().find((h) => h.name === value);
if (host) this.connectSshHost(host);
}
} }
toggleMenu(): void { private connectSshHost(host: SshConfigHost): void {
this.menuOpen.set(!this.menuOpen());
// re-read ~/.ssh/config on each open so external edits show up
if (this.menuOpen()) void this.connections.reloadSshHosts();
}
pickSshHost(host: SshConfigHost): void {
this.menuOpen.set(false);
this.activate();
void this.panels.connectPanel( void this.panels.connectPanel(
this.side(), this.side(),
{ {
@@ -176,12 +268,12 @@ export class FilePanel {
return `${host.username}@${host.hostname}:${host.port}`; return `${host.username}@${host.hostname}:${host.port}`;
} }
iconFor(entry: FileEntry): string { iconFor(entry: FileEntry): Icon16 {
return fileGlyph(entry, this.settings.iconColors() === 'multi'); return fileGlyph(entry);
} }
iconClass(entry: FileEntry): string { iconClass(entry: FileEntry): string {
return fileIconClass(entry, this.settings.iconColors() === 'multi'); return fileIconClass(entry);
} }
isSelected(entry: FileEntry): boolean { isSelected(entry: FileEntry): boolean {
@@ -204,6 +296,12 @@ export class FilePanel {
return this.panel().sortAsc() ? '↑' : '↓'; return this.panel().sortAsc() ? '↑' : '↓';
} }
/** The sort header's tooltip: its keymap command and keys. */
sortTip(key: SortKey): string {
const id = `sort-${key}` as const;
return this.shortcuts.tip(this.i18n.t(`keys.${id}`), id);
}
commitPath(): void { commitPath(): void {
if (this.blocked()) return; if (this.blocked()) return;
const value = this.pathDraft().trim(); const value = this.pathDraft().trim();
@@ -216,6 +314,12 @@ export class FilePanel {
this.listEl().nativeElement.focus(); this.listEl().nativeElement.focus();
} }
private focusPath(): void {
const el = this.pathBox().nativeElement;
el.focus();
el.select();
}
rowClick(event: MouseEvent, index: number): void { rowClick(event: MouseEvent, index: number): void {
if (this.blocked()) return; if (this.blocked()) return;
this.activate(); this.activate();
@@ -252,105 +356,145 @@ export class FilePanel {
this.actions.openContextMenu(event, { side: this.side(), panel: this.panel(), entry: null }); this.actions.openContextMenu(event, { side: this.side(), panel: this.panel(), entry: null });
} }
openAt(index: number): void { /** ⇧F10 / Menu / ⌃↩: the cursor row's menu, under the row. The selection is
if (this.blocked()) return; * kept — the menu's commands act on it, like the F-keys do. */
const entry = this.panel().sorted()[index]; private openKeyboardMenu(): void {
if (entry) this.panel().open(entry); const panel = this.panel();
const index = panel.cursor();
const entry = panel.sorted()[index] ?? null;
const row = this.listEl().nativeElement.querySelector<HTMLElement>(`[data-index="${index}"]`);
row?.scrollIntoView({ block: 'nearest' });
const rect = (row ?? this.listEl().nativeElement).getBoundingClientRect();
this.actions.openContextMenu(
{ clientX: rect.left + remPx(1.75), clientY: row ? rect.bottom : rect.top + remPx(0.5) },
{ side: this.side(), panel, entry },
true,
);
} }
onKeydown(event: KeyboardEvent): void { openAt(index: number): void {
if (this.blocked()) return; if (this.blocked()) return;
const panel = this.panel(); const panel = this.panel();
const key = event.key; const entry = panel.sorted()[index];
if (entry) this.actions.run('open', { side: this.side(), panel, entry });
}
if (key === 'Tab') { /**
event.preventDefault(); * The list's keys, in order:
this.panels.setActiveSide(this.panels.otherSide(this.side())); * 1. while a query is being typed, printable keys (Space too) extend it,
* Backspace trims and Esc clears it — fixed, ahead of every binding;
* 2. the `panel` keymap (Shift + a movement extends the selection);
* 3. a `main` binding (⌘T, Ctrl+Tab…) is left to bubble to App;
* 4. any other printable key starts a type-to-filter query;
* 5. Esc clears the selection.
*/
onKeydown(event: KeyboardEvent): void {
const panel = this.panel();
if (this.blocked()) {
// a busy panel takes no input — except Esc, which cancels a connect; its
// own keys are swallowed so Tab can't wander out of the list mid-load
if (event.key === 'Escape' && panel.connecting()) {
event.preventDefault();
this.cancelConnect();
} else if (this.shortcuts.resolve(event, 'panel')) {
event.preventDefault();
}
return; return;
} }
if (this.shortcuts.isMod(event) && key.toLowerCase() === 'a') { const plain = !event.metaKey && !event.ctrlKey && !event.altKey;
event.preventDefault(); const printable = plain && event.key.length === 1 && !event.isComposing;
panel.selectAll();
return; if (panel.filter() !== '' && plain) {
if (printable) return this.setFilter(event, panel.filter() + event.key);
if (event.key === 'Backspace' && !event.shiftKey) return this.setFilter(event, panel.filter().slice(0, -1));
if (event.key === 'Escape') return this.setFilter(event, '');
} }
// type-to-filter: printable characters build the query (Space only once a const command = this.panelCommand(event);
// query exists — an empty-filter Space keeps its toggle-select meaning) if (command) {
if (
key.length === 1 &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
(key !== ' ' || panel.filter() !== '')
) {
event.preventDefault(); event.preventDefault();
panel.setFilter(panel.filter() + key); if (event.repeat && !isRepeatable(command.id)) return;
this.runKey(command.id, command.extend);
this.scrollCursorIntoView(); this.scrollCursorIntoView();
return; return;
} }
if (key === 'Backspace' && panel.filter() !== '') { if (this.shortcuts.resolve(event, 'main')) return;
event.preventDefault();
panel.setFilter(panel.filter().slice(0, -1));
return;
}
if (key === 'Escape' && panel.filter() !== '') {
event.preventDefault();
panel.setFilter('');
return;
}
switch (key) { if (printable && event.key !== ' ') return this.setFilter(event, event.key);
case 'ArrowDown': if (event.key === 'Escape' && plain && panel.selected().size) {
panel.moveCursor(1); event.preventDefault();
break; panel.clearSelection();
case 'ArrowUp': }
}
/** The panel command a keydown spells. Shift + a movement key is that
* movement extending the selection — a fixed rule, so it follows remaps. */
private panelCommand(event: KeyboardEvent): { id: ShortcutId; extend: boolean } | null {
const id = this.shortcuts.resolve(event, 'panel');
if (id) return { id, extend: false };
if (!event.shiftKey) return null;
const base = this.shortcuts.resolve(withoutShift(event), 'panel');
return base && MOVES.has(base) ? { id: base, extend: true } : null;
}
private runKey(id: ShortcutId, extend: boolean): void {
const panel = this.panel();
const from = panel.cursor();
switch (id) {
case 'cursor-up':
panel.moveCursor(-1); panel.moveCursor(-1);
break; break;
case 'PageDown': case 'cursor-down':
panel.moveCursor(20); panel.moveCursor(1);
break; break;
case 'PageUp': case 'page-up':
panel.moveCursor(-20); panel.moveCursor(-PAGE);
break; break;
case 'Home': case 'page-down':
panel.moveCursor(PAGE);
break;
case 'cursor-first':
panel.cursor.set(0); panel.cursor.set(0);
break; break;
case 'End': case 'cursor-last':
panel.cursor.set(Math.max(0, panel.sorted().length - 1)); panel.cursor.set(Math.max(0, panel.sorted().length - 1));
break; break;
case ' ': case 'toggle-select': {
{ const entry = panel.sorted()[from];
const entry = panel.sorted()[panel.cursor()]; if (entry) panel.toggleSelect(entry.name);
if (entry) panel.toggleSelect(entry.name); panel.moveCursor(1);
panel.moveCursor(1); return;
} }
break; case 'select-all':
case 'Enter': panel.selectAll();
this.openAt(panel.cursor()); return;
return void event.preventDefault(); case 'parent':
case 'Backspace':
panel.up(); panel.up();
break; return;
case 'F2': case 'switch-panel':
this.actions.rename(panel); this.panels.setActiveSide(this.panels.otherSide(this.side()));
break; return;
case 'F4': case 'row-menu':
void this.actions.edit(panel); this.openKeyboardMenu();
break; return;
case 'F5': case 'focus-path':
void this.transfers.copyToOtherPanel(this.side()); this.focusPath();
break; return;
case 'F7': case 'source-picker':
this.actions.mkdir(panel); void this.openSourcePicker(true);
break; return;
case 'F8':
case 'Delete':
this.actions.delete(panel);
break;
default: default:
return; // let unhandled keys through // every other panel-scope command acts on files (keymap.ts)
this.actions.run(id as FileCommand, this.cursorTarget());
return;
} }
// Shift + movement: select every row the cursor passed over
if (extend) panel.selectRange(from, panel.cursor());
}
private setFilter(event: KeyboardEvent, query: string): void {
event.preventDefault(); event.preventDefault();
this.panel().setFilter(query);
this.scrollCursorIntoView(); this.scrollCursorIntoView();
} }
+5 -6
View File
@@ -1,17 +1,16 @@
import { App } from './app'; import { App } from './app';
import { ConnectionManager } from './connection-manager/connection-manager'; import { ConnectionManager } from './connection-manager/connection-manager';
import { EditorWindow } from './editor-window/editor-window'; import { EditorWindow } from './editor-window/editor-window';
import { SettingsWindow } from './settings-window/settings-window';
/** /**
* The three window roots of the modern (shadcn-style) skin, keyed by APP_MODE. * The four window roots, keyed by APP_MODE — main.ts picks one. There is no
* * title-bar component: the native window frame is kept (hiddenInset on macOS,
* Same shape as SEVEN_ROOTS, which is the point: main.ts picks a tree by skin * which is what IS_MAC_CHROME accounts for).
* and then a root by mode, without knowing anything about either skin. This
* skin has no title-bar component — it defers to the native window frame
* (hiddenInset on macOS), which is exactly what IS_MAC_CHROME accounts for.
*/ */
export const SHAD_ROOTS = { export const SHAD_ROOTS = {
main: App, main: App,
manager: ConnectionManager, manager: ConnectionManager,
editor: EditorWindow, editor: EditorWindow,
settings: SettingsWindow,
} as const; } as const;

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