Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-23 16:17:38 +02:00
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
100 changed files with 1946 additions and 8561 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/
+35 -31
View File
@@ -36,7 +36,7 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu
- **Menu-bar (tray) icon**: template icon that adapts to light/dark; click it - **Menu-bar (tray) icon**: template icon that adapts to light/dark; click it
to bring the app to the front. to bring the app to the front.
- **Edit files** (F4 / toolbar / File menu): a built-in editor works on local - **Edit files** (F4 / toolbar / File menu): a built-in editor works on local
and remote files directly; or pick an external app (Options → Editor → and remote files directly; or pick an external app (Settings → Editor →
Custom → Choose…). External edits of remote files download to a temp folder, Custom → Choose…). External edits of remote files download to a temp folder,
are watched, and every save auto-uploads (visible in the transfer list) until are watched, and every save auto-uploads (visible in the transfer list) until
the session disconnects. Leftover temp files from a crash are offered for the session disconnects. Leftover temp files from a crash are offered for
@@ -53,10 +53,10 @@ Dense reference material lives in [docs/](docs/): [architecture](docs/architectu
| Layer | Choice | | Layer | Choice |
| ---------- | ------------------------------------------------------------- | | ---------- | ------------------------------------------------------------- |
| Runtime | Bun (main process, all SSH/SFTP + file IO) | | Runtime | Bun (main process, all SSH/SFTP + file IO) |
| Desktop | Electrobun 1.18 (native webview, typed RPC bridge) | | Desktop | Electrobun 2.0 via Hutch (native webview, typed RPC bridge) |
| Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` | | Frontend | Angular 22 (standalone, signals, zoneless) in `ui/` |
| Styling | Tailwind CSS v4, shadcn-style tokens (no component library) | | Styling | Tailwind CSS v4, Oxide-derived tokens (no component library) |
| Icons | @ng-icons/mynaui | | Icons | @ng-icons/tdesign-icons |
| SSH/SFTP | `ssh2` (pure-JS under Bun) | | SSH/SFTP | `ssh2` (pure-JS under Bun) |
The repo is a Bun workspace: the Electrobun app at the root, the Angular app in The repo is a Bun workspace: the Electrobun app at the root, the Angular app in
@@ -83,6 +83,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 +111,19 @@ Versioning is Sublime-style: **Build n**, where n is the commit count
`src/shared/version.gen.ts` by every build script (and `postinstall`). `src/shared/version.gen.ts` by every build script (and `postinstall`).
One artifact per invocation, on the matching host — electrobun cannot One artifact per invocation, on the matching host — electrobun cannot
cross-compile between OSes: cross-compile (Hutch builds for its own OS and arch):
```sh ```sh
bun run release -- --target mac-arm64 # on the Mac (native) bun run release -- --target mac-arm64 # on an Apple Silicon Mac
bun run release -- --target mac-x64 # on the same arm64 Mac, via Rosetta 2
bun run release -- --target win-x64 # on a Windows 11 x64 machine bun run release -- --target win-x64 # on a Windows 11 x64 machine
bun run release -- --target linux-x64 # on a Linux x64 machine 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 +138,7 @@ compiled from `assets/icon.iconset` into `AppIcon.icns` automatically.
For a distributable (optimized, with auto-update channel) build, use For a distributable (optimized, with auto-update channel) build, use
`bunx electrobun build --env=stable` (see the `bunx electrobun build --env=stable` (see the
[Electrobun docs](https://framework.blackboard.sh/electrobun/) for signing, [Electrobun docs](https://electrobun.dev/electrobun/) for signing,
notarization, and release hosting). notarization, and release hosting).
## Rebranding ## Rebranding
@@ -181,7 +182,8 @@ Saved connections live in a plain JSON file in the OS app-data directory
`Space` select · `Enter` open folder · `Backspace` parent · `Space` select · `Enter` open folder · `Backspace` parent ·
`Ctrl/Cmd+A` select all · `F5` send to other panel · `F2` rename · `Ctrl/Cmd+A` select all · `F5` send to other panel · `F2` rename ·
`F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab · `F7` new folder · `F8`/`Delete` delete · `Cmd/Ctrl+T` new tab ·
`Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab. `Cmd/Ctrl+W` close tab · `Ctrl+Tab` next tab · `Cmd/Ctrl+,` settings ·
`Cmd/Ctrl+=`/`-`/`0` interface bigger / smaller / 100%.
(On Macs, function keys may need `fn` — every action also has a toolbar button.) (On Macs, function keys may need `fn` — every action also has a toolbar button.)
@@ -220,7 +222,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 +234,12 @@ electrobun APIs the app uses are implemented on Windows; known issues are
upstream: upstream:
- **HiDPI blur** ([electrobun#324](https://github.com/blackboardsh/electrobun/issues/324)): - **HiDPI blur** ([electrobun#324](https://github.com/blackboardsh/electrobun/issues/324)):
WebView2 renders at 1x on scaled displays until upstream adds DPI awareness. WebView2 rendered at 1x on scaled displays on Electrobun 1.18; 2.x ships
per-monitor DPI setup — to re-check on the first 2.x Windows build.
- **Notifications** use legacy balloon style, not Windows toast. - **Notifications** use legacy balloon style, not Windows toast.
- **Unsigned binaries**: both the Setup.exe installer and the portable exe - **Unsigned binaries**: both the Setup.exe installer and the portable exe
trigger SmartScreen/Mark-of-the-Web warnings on first run. No Start Menu trigger SmartScreen/Mark-of-the-Web warnings on first run. The installer
entry or uninstaller (installer unpacks to `%LOCALAPPDATA%`). unpacks to `%LOCALAPPDATA%` and, since Electrobun 2.x, ships an uninstaller.
- `connections.json` is protected with an `icacls` current-user ACL instead of - `connections.json` is protected with an `icacls` current-user ACL instead of
POSIX 0600 (which is meaningless on Windows). POSIX 0600 (which is meaningless on Windows).
@@ -248,7 +251,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 +263,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 +276,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=="],
+126 -275
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,15 +68,16 @@ has **no `off()`** — `track()` closures capture only `(this, key)` so closed
records can be garbage collected. A future "window follows window" grouping records can be garbage collected. A future "window follows window" grouping
belongs here (`anchor.on('move')` + `setPosition`). belongs here (`anchor.on('move')` + `setPosition`).
### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/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
`will-close` for the titlebar close, not used yet — see gotchas):
1. ⌘W (routed via the app menu → `editorCloseRequest`) / Close button → 1. ⌘W (routed via the app menu → `editorCloseRequest`) / Close button →
styled in-window Save/Don't Save/Cancel; the confirm path calls styled in-window Save/Don't Save/Cancel; the confirm path calls
`editorClose` which sets `suppressPrompt` before `win.close()`. `editorClose` which sets `suppressPrompt` before `win.close()`.
@@ -94,7 +101,9 @@ not `findInPage`, which is a shim on WebView2).
### Connection-manager window (`src/bun/manager-window.ts`) ### Connection-manager window (`src/bun/manager-window.ts`)
Single instance, centered over main, **modal to main only** (editors stay Single instance, centered over main, **modal to main only** (editors stay
usable). No native modality exists, so three layers approximate it: bun usable). No native modality exists (re-checked on 2.0.2: no parent/owner/sheet
option or input lock in any SDK or the core's exports; `styleMask.DocModalWindow`
is only the cosmetic NSWindow bit), so three layers approximate it: bun
bounces main's `focus` event back to the manager; the main webview renders a bounces main's `focus` event back to the manager; the main webview renders a
click-swallowing overlay (`managerState` message); main drops its keyboard click-swallowing overlay (`managerState` message); main drops its keyboard
shortcuts and bun drops main-targeted menu actions while open. Connect / shortcuts and bun drops main-targeted menu actions while open. Connect /
@@ -105,6 +114,20 @@ main's `ConnectionsService` reloads; a reload-retry in `App.findConnection`
covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it
would float over editor windows). would float over editor windows).
### Settings window (`src/bun/settings-window.ts` + `ui/src/app/skins/shadlike/settings-window/`)
Single instance, centered over main, **not modal**: every control is a plain
`SettingsService` setter, so each edit is an ordinary save that bun
rebroadcasts — the other windows follow live while it stays open. Opened by
the tab-bar gear and Ctrl+, (`openSettings` from main) and, on macOS, the app
menu's "Settings…" (⌘,, which the menu intercepts first). Its RPC serves only
`getSettings`/`saveSettings`/`pickEditorApp`/`showAbout`. ⌘W reaches bun as
the main window's `close-tab` menu action, so bun checks the Settings window's
focus FIRST and closes it (see gotchas). Sidebar sections: Appearance,
Transfers, Editor, File list (visible columns — the column-header right-click
menu toggles the same setting), About (opens the native About box). The
native title is `options.title`, retitled on a locale switch.
## Bun modules ## Bun modules
- `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops + - `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops +
@@ -154,8 +177,9 @@ 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; "Settings…" carries ⌘,). ⌘W is intercepted
routes `close-tab` to the focused editor window first. Labels go through by the menu, so bun routes `close-tab` to a focused Settings or editor window
first. Labels go through
backend `i18n.t` and the menu is rebuilt on a locale change too. backend `i18n.t` and the menu is rebuilt on a locale change too.
- `i18n.ts` — backend translator (native menus + thrown error messages, which - `i18n.ts` — backend translator (native menus + thrown error messages, which
no Angular service can reach). `setLocale(setting)` caches the resolved no Angular service can reach). `setLocale(setting)` caches the resolved
@@ -171,39 +195,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 +219,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,11 +244,15 @@ 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. New setting
= five touches here + types.ts (backend merges automatically).
- `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the - `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the
main window's panels listen. Exists so dialogs don't inject PanelsService main window's panels listen. Exists so dialogs don't inject PanelsService
(whose constructor spawns a workspace — fatal in secondary windows). (whose constructor spawns a workspace — fatal in secondary windows).
@@ -249,14 +262,14 @@ one look → that skin's folder; tokens both skins consume → `ui/src/styles.cs
(dotted lookup, `{param}` interpolation, `{one,other}` plural maps via (dotted lookup, `{param}` interpolation, `{one,other}` plural maps via
`Intl.PluralRules`); missing keys fall back to English then the raw key. `Intl.PluralRules`); missing keys fall back to English then the raw key.
**Reactive by design**: `t()` reads the `locale` signal, so every template **Reactive by design**: `t()` reads the `locale` signal, so every template
binding that calls it re-renders live on a language switch (same call-stack binding that calls it re-renders live on a language switch (call-stack
tracking as `iconClass`) — no pipe. Also exposes locale-aware `date()`/`size()` tracking, see gotchas.md) — no pipe. Also exposes locale-aware `date()`/`size()`
/`speed()` so the file list reformats on switch. `SettingsService.applyLocale` /`speed()` so the file list reformats on switch. `SettingsService.applyLocale`
resolves the `locale` setting (`system` → `navigator.language`) into it, on resolves the `locale` setting (`system` → `navigator.language`) into it, on
boot and on the `settingsChanged` broadcast (so editor/manager windows follow). boot and on the `settingsChanged` broadcast (so editor/manager windows follow).
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always - `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
against an explicit panel; `menuTarget` remembers the right-clicked row. against an explicit panel; `menuTarget` remembers the right-clicked row.
- `context-menu.service.ts` + `context-menu.ts` — the panel and tab right-click - `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,
@@ -274,13 +287,16 @@ one look → that skin's folder; tokens both skins consume → `ui/src/styles.cs
`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 (TDesign, `@ng-icons/tdesign-icons`), passed to
`provideIcons` in `app.config.ts`. Icon names are plain strings in templates and
the build never checks them: an unregistered name renders nothing (console error
only), so every name used anywhere must be listed here.
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`, - `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
`EDITOR_CONTEXT`, `IS_MAC`/`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 `MOD_LABEL`/`ALT_LABEL`) + `blockNativeContextMenu` (kills WebKit's
@@ -296,217 +312,52 @@ one look → that skin's folder; tokens both skins consume → `ui/src/styles.cs
## Theming ## Theming
`ui/src/styles.css`: shadcn-style OKLCH tokens; dark values are duplicated in `ui/src/styles.css`: OKLCH tokens adapted from **Oxide's design system**
`:root[data-theme='dark']` AND a `prefers-color-scheme` media block (system (docs.oxide.computer: a cool neutral scale at hue 260 with almost no chroma,
mode) — every dark token change goes in BOTH. Light is the zinc family; the one green accent, opaque strokes; the comments carry Oxide's step names
dark neutrals are a **warm gray family (hue 107)** anchored on `#1a1a19` n0…n1300 / green-800). Dark values are duplicated in `:root[data-theme='dark']`
(`--background`) — retoning one dark neutral means retoning them all, or warm AND a `prefers-color-scheme` media block (system mode) — every dark token
and cool surfaces clash. Surface elevation ramp, both themes: `--chrome` change goes in BOTH; retone neutrals along the one scale, or warm and cool
(frame strips) sits *below* `--background` (content panes) < `--card` surfaces clash. **There is no accent setting**: `--primary` is Oxide green per
(popovers/dialogs) < `--accent` (hover). Dark ramp `#151514` → `#1a1a19` → theme (green-800 dark, green-500 light), so selection (`bg-primary/15`), the
`#20201f` → `#2e2e2d`. The "darker" tone is true-black AMOLED: `--chrome` AND cursor row (`--cursor-tint`, a `color-mix` of `--primary`), focus rings
`--background` are both `#000` (the frame/content step collapses on purpose), (green-600) and `--success` all read green. **Surface layers**, each its own
elevation survives only via `--card`/`--accent`, and `--border`/`--input` go to shade: `--chrome` (frame — tab/transfer/status bars, the editor/manager/settings
16%/18% white because those lines then carry the whole layout. header+footer strips and sidebars), the file panel's header strip **stepping
`bg-chrome` is on the tab bar, status bar, transfer bar, the editor/manager toward the list** — session strip `--toolbar`, path bar `--toolbar-2`, column
header+footer strips and the manager's connection sidebar; panel-internal headers `--toolbar-3` (`color-mix` steps declared once in `:root`, so every
toolbars deliberately stay on `--background`. Accent tones: one theme follows) — then `--background` (file lists, editor text, forms;
`--tone-h/--tone-c` line per tone + three formula blocks derive `--primary`, `--row-alt` stripes odd file rows, skipped on the cursor/selected row so two
`--ring`, `--cursor-tint`; graphite = attribute removed = pristine zinc. `bg-*` utilities never fight) and `--card` (popovers, dialogs, toasts). The
right-click menu is the exception: the darkest surface (`bg-chrome`) behind a
faint `border-border/60`, and its highlighted item takes Oxide's selected style —
`--primary-surface` fill (Oxide surface-accent: green-200 dark, green-1200
light) with `text-primary`; mouse and keyboard share that one highlight
(`activeIndex`). The
ACTIVE panel is marked by a 0.125rem `--primary` line along its top edge (an
inset box-shadow on the session strip, so switching panels never shifts
layout). Dark: frame 0.162 < list 0.195 < header 0.275 → 0.248 → 0.221 < card
0.29; light steps greys out from near-white content: frame 0.925 < header 0.95
→ 0.965 → 0.98 < list = card 0.995. **`--accent` is translucent** (n0 at 5%
light, white 7% dark): hover/selected states sit on every layer, and an opaque
hover vanishes on a surface of its own lightness. Strokes are opaque
(`--border` n300 dark / n900 light, `--input` one step stronger). The
"darker" tone is true-black AMOLED: chrome, background, toolbar (+ its steps)
and row-alt are all `#000` (the layers collapse on purpose); elevation
survives only via `--card`/`--muted`/`--accent` (opaque there). **Square
design**: no `rounded-*` utilities and no radius tokens; an `@layer base`
block restyles native controls to match — checkboxes/radios (`appearance:
none`, a drawn check / square dot), selects (a `currentColor` caret; use
`pl-*`, never `px-*`, on a select), hidden number spinners, and square
`::-webkit-scrollbar`s (which make macOS scrollbars always visible).
`@theme inline` maps tokens to Tailwind utilities (`bg-primary`, `@theme inline` maps tokens to Tailwind utilities (`bg-primary`,
`text-icon-folder`, `bg-cursor-tint`). Selected rows are `bg-primary/15` `text-icon-folder`, `bg-toolbar-2`, `bg-cursor-tint`). File-type icon colors
(follow the accent); the cursor row uses `--cursor-tint` so graphite stays come from Oxide's hue scales (800 steps dark, 600 light). **Interface scale** (`settings.uiScale`,
pixel-identical to the old neutral hover. **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: a bottom-right grip drags the
frame through `windowGetFrame`/`windowSetFrame` → `BrowserWindow.setFrame`.
Mounted by all three win7 roots, hidden while maximized by a CSS `:has()` rule
(no second `windowState` subscription), and the grip carries
`electrobun-webkit-app-region-no-drag` or the drag region would move the window
instead. **One corner, not eight handles**: the handles are invisible and paint
above the frame, so a north strip or top corner overlays the caption buttons and
steals their `:hover`, which reads as the Aero glow flickering on those buttons.
The bottom-right corner is the only one that overlaps nothing (it sits in the
6px gutter `.window-body`'s margin leaves). The frame math is a dependency-free module so
`scripts/resize-math-probe.ts` can assert all eight edges without a window.
**This is meant to be deleted**: the component header carries the removal
checklist and `scripts/check-electrobun-resize.ts` (wired into `postinstall`)
prints it when upstream ships the primitive. See platform-notes.md.
**Window controls**: new `windowMinimize` / `windowToggleMaximize` /
`windowClose` / `windowIsMaximized` requests, built by
`windowControlHandlers(getWindow, onClose?)` in `rpc-handlers.ts` and served by
all three window kinds (each passing a late-bound getter for its own window —
the window takes the rpc in its constructor, same reason `events` is
late-bound). `onClose` is overridden per window kind so the X reuses the
existing close path: editors run their dirty prompt, the manager runs
`managerClose`, main falls through to the cancellable `before-quit` guard.
electrobun emits **no maximize event**, so `trackMaximizeState` derives it from
`resize` + the synchronous `isMaximized()` and pushes `windowState` — which
also covers Aero Snap and Win+Up. Double-click-to-maximize is likewise absent
from electrobun's drag regions and is implemented in the title-bar component.
+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.
+169 -247
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,39 @@ 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.
## The interface scale (root font-size lever) ## The interface scale (root font-size lever)
@@ -64,149 +87,66 @@ 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.
- **⌘W in any secondary window arrives as the MAIN window's `close-tab`
menu action** (macOS: the app menu intercepts it before the webview sees
the key). bun must check the focused secondary window first — the Settings
window, then editors — and close THAT; a new window kind that skips this
check makes ⌘W close a main-window tab behind it.
- **One rpc instance per window** — BrowserView binding steals the rpc's - **One rpc instance per window** — BrowserView binding steals the rpc's
transport; a shared instance silently sends to the wrong webview. transport; a shared instance silently sends to the wrong webview.
- **RPC before socket open is silently lost.** Everything gates on the - **RPC before socket open was silently lost on 1.18.1.** 2.x Electroview queues
socket-open promise (RpcService `ready`). sends while its `hostSocket` (v1: `bunSocket`) is CONNECTING and falls back to
the native bridge; RpcService still gates everything on the socket-open
promise (`ready`) as belt-and-braces.
- The emitter has no `off()`. Window event closures must not capture records - The emitter has no `off()`. Window event closures must not capture records
you want garbage collected. you want garbage collected.
- `electrobun build` defaults to the **dev** channel; release builds need - `electrobun build` defaults to the **dev** channel; release builds need
@@ -218,12 +158,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 +236,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,100 +262,13 @@ 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 grip 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).
**An invisible resize handle over a control steals that control's hover.** A
full eight-handle set shipped briefly and was reverted: the north strip ran
the whole width across the caption buttons and the north-east square sat on
the close button's corner, so crossing one dropped the button's `:hover`
mid-glow and looked like a rendering artifact in 7.css — nothing in the CSS
was wrong. Only the bottom-right corner overlaps nothing (it lands in the 6px
gutter `.window-body { margin: 0 6px 6px }` leaves). Any new handle must be
checked against what sits under it; south/east/west are innocent, north and
both top corners are not.
Three more traps live in that code, all verified against
`dist/api/bun/preload/dragRegions.ts`:
- It starts a window **move** from a `mousedown` listener on `document`, in
the **bubble** phase. It never reads `defaultPrevented`, so
`event.preventDefault()` does **nothing** to stop it.
- A `pointerdown` handler does not stop it either: the compatibility
`mousedown` still fires afterwards, and stopping propagation on a *pointer*
event has no effect on a *mouse* event. This is the trap that looks fixed
in every test until the window jumps.
- `isAppRegionDrag` checks `.electrobun-webkit-app-region-no-drag` **first**
and short-circuits, so that class — on the handle itself — is the only
reliable suppressor. Without it, every resize drag also drags the window.
Also: deltas must come from `screenX/screenY`. North/west resizes move the
window's origin, which moves the webview's client origin under a stationary
pointer, so `clientX` feeds that movement back into the next delta and the
window runs away. And the minimum-size clamp must re-anchor the opposite
edge — clamping the width alone lets `x` keep tracking the pointer, walking
the window off-screen while it looks frozen (`scripts/resize-math-probe.ts`
asserts exactly that, and was verified to catch it).
- **Prefer the headless probes over launching the app.** `bun run start` opens a
real window that takes focus, which interrupts whatever the user is doing;
there is no Xvfb/xvfb-run on this machine and Wayland offers no
launch-without-focus. `scripts/shot.ts` (+ `--dark`), `hover-probe.ts` and
`menu-probe.ts` all render through headless Firefox and open nothing. Launch
the real app only for what Gecko genuinely cannot answer — WebKitGTK
behaviour, the native frame, transparency, live RPC — ask first, and batch
every live check into a single launch.
- **X11 capture cannot answer "is this pixel transparent?"** for the frameless
window. `import -window` grabs the window's own pixels with no compositing,
so a transparent corner and an opaque black one both come out black;
`png32:` just fills alpha with 1 and proves nothing. XWayland's root window
is not the composited desktop, so `import -window root` fails too. Frame
transparency and the maximized corners need a human looking at the screen —
say so rather than reporting a screenshot as confirmation.
- Benign startup noise on Linux — non-fatal, the app runs normally: - 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
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
fallback), (2) the bun preload bakes the persisted `data-theme`/tone/accent
attributes 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 skin adds a **fourth value to keep in sync**: `buildPreload` also
bakes the `.win7` class, and index.html carries a matching `html.win7` arm
for all four theme states. Miss it and every win7 window flashes white.
- **The skin is a LAUNCH-TIME choice. Never apply one to a running window.**
This shipped broken once: `SettingsService.applySkin` toggled the `.win7`
class the moment the setting changed, which was harmless when there was one
component tree and became a broken window as soon as there were two — 7.css
painted onto shadlike markup that has no `.window.glass`, no `.title-bar`, no
`menu[role=tablist]` and no `<table>` ListView. Each skin's CSS is written
against its OWN tree's markup; the class must only ever be written by
`buildPreload`, for the tree that window actually booted.
- **A reload does not rescue it.** The preload is a persistent
`WebKitUserScript` injected at DOCUMENT_START, so its string is frozen at
webview construction — `webview.loadURL()` re-runs it with the OLD
`__APP_SKIN`. (`updatePreloadScriptToWebView` exists in the FFI but is
reachable only from electrobun's internal `<electrobun-webview>` handler.)
- **`titleBarStyle`/`transparent` are construction-time only** — no setters
exist, so a live window can never be re-framed. win7 → modern is the
dangerous direction: the modern tree draws no title bar, so it would land
in a frameless transparent window with no way to move, minimize or close.
- Don't "fix" this by recreating the main window either: `exitOnLastWindowClosed`
is true, the main `rpc` is captured by six push closures, the tray captures
`win`, listeners cannot be removed (no `off()`) — and it would drop every
session and tab.
- Bun pins it: `initSessionSkin` freezes the skin before any window exists,
and `buildPreload`/`chromeOptions` read that, never the live settings. Skip
the pin and a window opened after a pending change boots the other skin.
- Dev vs prod URL: `getMainViewUrl()` resolves the ng-serve dev server only
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.
## Domain logic ## Domain logic
@@ -378,6 +287,12 @@ touching the adjacent code.
- Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so - Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so
new keys auto-default, but the UI SettingsService reads field-by-field — new keys auto-default, but the UI SettingsService reads field-by-field —
a new setting silently stays at default until wired in all five spots. a new setting silently stays at default until wired in all five spots.
- **Every window's RPC must serve `saveSettings`.** The scale shortcuts
shipped in editor and manager windows while only the main RPC served it:
the request went unanswered, the change applied locally, and main's next
broadcast snapped it back. All three factories now share
`saveSettingsHandler`, and `settingsSaved()` in index.ts rebroadcasts to
every window but the sender — an echo would drag a held Cmd+ backwards.
- The editor draft mirror sends full content per tick (2 MB cap upstream); - The editor draft mirror sends full content per tick (2 MB cap upstream);
don't shrink the 400 ms debounce. don't shrink the 400 ms debounce.
- `panel.touched` gates localDir auto-navigation; `connectToken` guards - `panel.touched` gates localDir auto-navigation; `connectToken` guards
@@ -416,7 +331,14 @@ touching the adjacent code.
breaks `ng`. breaks `ng`.
- ssh2's optional native deps must stay `external: ["cpu-features", "*.node"]` - ssh2's optional native deps must stay `external: ["cpu-features", "*.node"]`
in `electrobun.config.ts` — bundling them breaks the build; ssh2 falls back in `electrobun.config.ts` — bundling them breaks the build; ssh2 falls back
to pure JS at runtime. to pure JS at runtime. (Inert without `mainProcess: "bun"` — see Electrobun.)
- **2.x installers dropped the `stable-` prefix; the updater feed kept it.**
`macos-arm64-miniSCP.dmg` / `win-x64-miniSCP-Setup.zip` /
`linux-x64-miniSCP-Setup.tar.gz` next to `stable-<os>-<arch>-update.json`,
`.tar.zst` and `.patch`. Anything that globs `artifacts/stable-*` misses the
installers — release.ts collects by `<os>-<arch>-`, deploy.sh publishes
`artifacts/*` verbatim (release.ts empties `artifacts/` first), and the site
links the new names.
- `bun run --cwd ui build` (flag order matters; `bun --cwd ui run build` does - `bun run --cwd ui build` (flag order matters; `bun --cwd ui run build` does
not do what you want). not do what you want).
- `version.gen.ts` is gitignored and regenerated by postinstall + every build - `version.gen.ts` is gitignored and regenerated by postinstall + every build
+42 -57
View File
@@ -1,16 +1,16 @@
# 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` |
@@ -19,13 +19,13 @@ macOS is the primary target; Windows 11+ x64 and Linux x64 are **beta** targets
| 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 | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ |
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored (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 | 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,29 +33,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**: a bottom-right grip in the webview drags the native frame
over RPC (`ui/src/app/skins/7like/window-resize/`). Only that one corner — an
invisible handle anywhere else overlays a control and steals its hover, which is
what a full edge set did to the caption buttons.
That is a **stopgap** — each drag tick is an RPC hop plus an FFI call, where a
real WM grab is compositor-side. `scripts/check-electrobun-resize.ts` watches the
installed electrobun for the missing primitives and prints the removal checklist
when they appear; it runs from `postinstall`, so an electrobun bump surfaces it
without anyone remembering to look. The modern skin keeps the native frame and
needs none of this.
## Memory footprint (expected, not a bug) ## 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
@@ -105,7 +82,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
@@ -116,23 +94,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
@@ -154,21 +135,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
@@ -179,10 +163,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.
@@ -197,7 +182,7 @@ Code paths written blind (no Windows box was available); verify in order:
7. Ctrl+T/W/F/S and Ctrl+digit tab jumps; tooltips read "Ctrl+…". 7. Ctrl+T/W/F/S and Ctrl+digit tab jumps; tooltips read "Ctrl+…".
8. connections.json ACL: `icacls connections.json` shows only the current 8. connections.json ACL: `icacls connections.json` shows only the current
user after saving a connection. user after saving a connection.
9. Editor/manager windows open, modal bounce works, About shows from the 9. Editor/manager/Settings windows open, modal bounce works, Ctrl+, opens
options popover. Settings and About shows from its About section.
10. Known-issue confirmation: HiDPI blur on a scaled display (expected until 10. Known-issue check: HiDPI blur on a scaled display (seen on 1.18.1 — 2.x's
upstream fixes it). per-monitor DPI setup may have fixed it).
+3
View File
@@ -16,6 +16,9 @@ export default {
baseUrl: "https://miniscp.com/dl", baseUrl: "https://miniscp.com/dl",
}, },
build: { build: {
// Load-bearing: 2.x defaults to Cottontail, which ignores the whole
// `bun` block below — the ssh2 externals included.
mainProcess: "bun",
bun: { bun: {
entrypoint: "src/bun/index.ts", entrypoint: "src/bun/index.ts",
// ssh2's optional native accelerators must never be bundled; its // ssh2's optional native accelerators must never be bundled; its
+5 -5
View File
@@ -7,16 +7,15 @@
"ui" "ui"
], ],
"scripts": { "scripts": {
"dev": "bun scripts/gen-version.ts && bun scripts/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");
+34 -85
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;
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"]); 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
// layout is confirmed by the first real build on each OS.
if (requested === "win-x64" || requested === "linux-x64") {
const appDir = path.join(ROOT, "build", `stable-${osName}-${archName}`, BRANDING.appName);
if (existsSync(appDir)) {
if (requested === "win-x64") { if (requested === "win-x64") {
const appDir = path.join(ROOT, "build", "stable-win-x64", BRANDING.appName);
if (existsSync(appDir)) {
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`); 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}`);
} }
-126
View File
@@ -1,126 +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.
//
// This covers MORE than the UI currently mounts, deliberately: only the
// bottom-right grip is wired up (an invisible handle over a caption button
// steals its hover — see the component), but the math stays general so that
// restoring a side later is one line against already-proven arithmetic.
import {
MIN_HEIGHT,
MIN_WIDTH,
RESIZE_EDGES,
type Frame,
type ResizeEdge,
resizeFrame,
} from "../ui/src/app/skins/7like/window-resize/resize-math";
const START: Frame = { x: 100, y: 200, width: 1000, height: 800 };
interface Case {
name: string;
edge: ResizeEdge;
dx: number;
dy: number;
want: Frame;
}
// Every expectation is written out in full rather than computed, so a bug in
// resizeFrame cannot be mirrored into the expectation by sharing its algebra.
const CASES: Case[] = [
// --- edges: each moves only what it owns ---
{ name: "east grows width only", edge: "e", dx: 50, dy: 999, want: { x: 100, y: 200, width: 1050, height: 800 } },
{ name: "south grows height only", edge: "s", dx: 999, dy: 40, want: { x: 100, y: 200, width: 1000, height: 840 } },
{ name: "west moves x AND width", edge: "w", dx: -30, dy: 0, want: { x: 70, y: 200, width: 1030, height: 800 } },
{ name: "north moves y AND height", edge: "n", dx: 0, dy: -25, want: { x: 100, y: 175, width: 1000, height: 825 } },
// --- corners: two axes at once ---
{ name: "south-east", edge: "se", dx: 10, dy: 20, want: { x: 100, y: 200, width: 1010, height: 820 } },
{ name: "north-west", edge: "nw", dx: -10, dy: -20, want: { x: 90, y: 180, width: 1010, height: 820 } },
{ name: "north-east", edge: "ne", dx: 10, dy: -20, want: { x: 100, y: 180, width: 1010, height: 820 } },
{ name: "south-west", edge: "sw", dx: -10, dy: 20, want: { x: 90, y: 200, width: 1010, height: 820 } },
// --- the anchored edge must not drift once clamped ---
// west dragged far east: width parks at MIN, and x parks where the EAST edge
// (100 + 1000 = 1100) minus MIN puts it. If x kept tracking dx, the window
// would slide away while appearing frozen.
{
name: "west clamps, east edge holds",
edge: "w",
dx: 5000,
dy: 0,
want: { x: 1100 - MIN_WIDTH, y: 200, width: MIN_WIDTH, height: 800 },
},
{
name: "north clamps, south edge holds",
edge: "n",
dx: 0,
dy: 5000,
want: { x: 100, y: 1000 - MIN_HEIGHT, width: 1000, height: MIN_HEIGHT },
},
// east/south clamp the size but never touch the origin
{ name: "east clamps, origin holds", edge: "e", dx: -5000, dy: 0, want: { x: 100, y: 200, width: MIN_WIDTH, height: 800 } },
{ name: "south clamps, origin holds", edge: "s", dx: 0, dy: -5000, want: { x: 100, y: 200, width: 1000, height: MIN_HEIGHT } },
// a corner clamping on BOTH axes at once
{
name: "north-west clamps both",
edge: "nw",
dx: 5000,
dy: 5000,
want: { x: 1100 - MIN_WIDTH, y: 1000 - MIN_HEIGHT, width: MIN_WIDTH, height: MIN_HEIGHT },
},
// a zero drag must be a no-op, or a click on a handle would nudge the window
{ name: "zero drag is identity", edge: "se", dx: 0, dy: 0, want: START },
];
const fmt = (f: Frame) => `${f.x},${f.y} ${f.width}x${f.height}`;
console.log("[resize-math] frame arithmetic:\n");
console.log(" case edge got verdict");
let failed = 0;
for (const c of CASES) {
const got = resizeFrame(c.edge, START, c.dx, c.dy);
const ok = fmt(got) === fmt(c.want);
if (!ok) failed++;
console.log(
` ${c.name.padEnd(32)} ${c.edge.padEnd(5)} ${fmt(got).padEnd(22)} ${ok ? "ok" : `FAIL: want ${fmt(c.want)}`}`,
);
}
// Structural check: every edge must be exercised above, so adding a ninth
// handle without a case cannot slip through.
const covered = new Set(CASES.map((c) => c.edge));
const missing = RESIZE_EDGES.filter((e) => !covered.has(e));
if (missing.length) {
console.log(`\n[resize-math] no case covers: ${missing.join(", ")}`);
failed++;
}
// The clamp must never produce a frame smaller than the minimum, whatever the
// drag — a cheap sweep over every edge and a range of deltas.
for (const edge of RESIZE_EDGES) {
for (const d of [-9999, -1000, -1, 0, 1, 1000, 9999]) {
const got = resizeFrame(edge, START, d, d);
if (got.width < MIN_WIDTH || got.height < MIN_HEIGHT) {
console.log(`\n[resize-math] ${edge} at delta ${d} produced ${fmt(got)} — under the minimum`);
failed++;
}
}
}
if (failed) {
console.log(`\n[resize-math] ${failed} failure(s)`);
process.exit(2);
}
console.log("\n[resize-math] clean");
-106
View File
@@ -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.
+67 -38
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,
@@ -131,20 +109,45 @@ const manager = new ManagerWindow({
menuAction: (info) => rpc.send.menuAction(info), menuAction: (info) => rpc.send.menuAction(info),
}, },
rebuildMenu: () => void applyApplicationMenu(store), rebuildMenu: () => void applyApplicationMenu(store),
onSettingsSaved: (s) => settingsSaved(s, "manager"),
});
const settingsWin = new SettingsWindow({
settings,
url,
currentSettings: () => currentSettings,
mainFrame: () => win.getFrame(),
onSettingsSaved: (s) => settingsSaved(s, "settings"),
showAbout: () => showAbout(),
}); });
events.openEditor = (ctx) => editors.open(ctx); events.openEditor = (ctx) => editors.open(ctx);
events.settingsChanged = (s) => { /**
* Every settings save lands here, whichever window made it (editor and manager
* windows save too — the scale shortcuts — and the Settings window is nothing
* but saves): side effects run once, then every OTHER window follows. The
* sender is skipped — it already shows the new values, and an echo arriving
* behind a newer local change (a held Cmd+) would drag it backwards.
*/
function settingsSaved(
s: AppSettings,
sender: "main" | "manager" | "settings" | { editor: string },
): void {
engine.setConcurrency(effectiveConcurrency(s));
const localeChanged = s.locale !== currentSettings.locale; const localeChanged = s.locale !== currentSettings.locale;
currentSettings = s; // future windows' preloads bake the fresh theme currentSettings = s; // future windows' preloads bake the fresh theme
if (localeChanged) { if (localeChanged) {
setBackendLocale(s.locale); setBackendLocale(s.locale);
void applyApplicationMenu(store); // relabel the native menu in the new language void applyApplicationMenu(store); // relabel the native menu in the new language
settingsWin.retitle();
} }
editors.broadcastSettings(s); if (sender !== "main") rpc.send.settingsChanged({ settings: s });
manager.broadcastSettings(s); if (sender !== "manager") manager.broadcastSettings(s);
}; if (sender !== "settings") settingsWin.broadcastSettings(s);
editors.broadcastSettings(s, typeof sender === "object" ? sender.editor : undefined);
}
events.settingsChanged = (s) => settingsSaved(s, "main");
events.openConnectionManager = () => manager.open(); events.openConnectionManager = () => manager.open();
events.openSettings = () => settingsWin.open();
function showAbout(): void { function showAbout(): void {
const { build } = BRANDING; const { build } = BRANDING;
@@ -163,6 +166,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 +191,7 @@ engine.onUpdate = (snapshot) => {
if ( if (
!windowFocused && !windowFocused &&
!editors.anyFocused() && !editors.anyFocused() &&
!settingsWin.isFocused() &&
(snapshot.state === "completed" || snapshot.state === "failed") (snapshot.state === "completed" || snapshot.state === "failed")
) { ) {
Utils.showNotification({ Utils.showNotification({
@@ -207,11 +212,24 @@ engine.onUpdate = (snapshot) => {
// don't quit out from under running transfers or unsaved editors // don't quit out from under running transfers or unsaved editors
let quitConfirmed = false; let quitConfirmed = false;
// Updater.applyUpdate() asks this guard first (2.x), so remember the quit is
// update-initiated: "Quit Anyway" must re-run the apply, or a plain quit would
// drop the downloaded update.
let applyingUpdate = false;
events.applyUpdate = async () => {
applyingUpdate = true;
try {
await Updater.applyUpdate();
} finally {
applyingUpdate = false;
}
};
Electrobun.events.on("before-quit", (event) => { Electrobun.events.on("before-quit", (event) => {
const transfers = engine.activeJobCount(); const transfers = engine.activeJobCount();
const dirtyEditors = editors.dirtyCount(); const dirtyEditors = editors.dirtyCount();
if (quitConfirmed || (transfers === 0 && dirtyEditors === 0)) return; if (quitConfirmed || (transfers === 0 && dirtyEditors === 0)) return;
(event as { response: { allow: boolean } }).response = { allow: false }; (event as { response: { allow: boolean } }).response = { allow: false };
const updating = applyingUpdate; // read now: it clears once the vetoed apply returns
void (async () => { void (async () => {
const parts = [ const parts = [
transfers > 0 && `${transfers} transfer${transfers === 1 ? " is" : "s are"} still running`, transfers > 0 && `${transfers} transfer${transfers === 1 ? " is" : "s are"} still running`,
@@ -229,7 +247,10 @@ Electrobun.events.on("before-quit", (event) => {
}); });
if (response === 0) { if (response === 0) {
quitConfirmed = true; quitConfirmed = true;
Utils.quit(); // the trailing quit is a no-op once the apply is quitting, and still
// quits if it bails (no prepared download)
if (updating) void Updater.applyUpdate().finally(() => Utils.quit());
else Utils.quit();
} }
})(); })();
}); });
@@ -244,10 +265,14 @@ void applyApplicationMenu(store);
ApplicationMenu.on("application-menu-clicked", (event) => { ApplicationMenu.on("application-menu-clicked", (event) => {
const action = (event as { data?: { action?: string } }).data?.action; const action = (event as { data?: { action?: string } }).data?.action;
if (!action) return; if (!action) return;
// ⌘W while an editor window is focused closes that editor (with its dirty // ⌘W while the Settings window or an editor window is focused closes THAT
// prompt), not a main-window tab. Other menu actions deliberately still // window (an editor with its dirty prompt), not a main-window tab. Other
// target the main window. // menu actions deliberately still target the main window.
if (action === "close-tab") { if (action === "close-tab") {
if (settingsWin.isFocused()) {
settingsWin.close();
return;
}
const focusedEditor = editors.focusedKey(); const focusedEditor = editors.focusedKey();
if (focusedEditor) { if (focusedEditor) {
editors.requestClose(focusedEditor); editors.requestClose(focusedEditor);
@@ -258,6 +283,10 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
manager.open(); manager.open();
return; return;
} }
if (action === "settings") {
settingsWin.open();
return;
}
if (action === "about") { if (action === "about") {
showAbout(); showAbout();
return; return;
+4 -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,
}, },
); );
+3 -1
View File
@@ -1,4 +1,4 @@
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/bun"; import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/main";
import { BRANDING } from "../shared/branding"; import { BRANDING } from "../shared/branding";
import type { SavedConnection } from "../shared/types"; import type { SavedConnection } from "../shared/types";
import { t } from "./i18n"; import { t } from "./i18n";
@@ -36,6 +36,8 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
submenu: [ submenu: [
{ label: t("menu.about", { app: BRANDING.appName }), action: "about" }, { label: t("menu.about", { app: BRANDING.appName }), action: "about" },
{ type: "separator" }, { type: "separator" },
{ label: t("menu.settings"), action: "settings", accelerator: "," },
{ type: "separator" },
{ label: t("menu.checkForUpdates"), action: "check-for-updates" }, { label: t("menu.checkForUpdates"), action: "check-for-updates" },
{ type: "separator" }, { type: "separator" },
{ role: "hide" }, { role: "hide" },
+84 -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,31 @@ 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;
}
/** RPC for the single Settings window (per-window instance). */
export function createSettingsRPC(deps: { settings: SettingsStore }, hooks: SettingsRpcHooks) {
return BrowserView.defineRPC<AppRPC>({
maxRequestTime: MAX_REQUEST_TIME_MS,
handlers: {
requests: {
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
saveSettings: saveSettingsHandler(deps.settings, hooks.onSettingsSaved),
pickEditorApp: pickEditorAppHandler,
showAbout: () =>
wrap(async () => {
hooks.showAbout();
return {};
}),
}, },
}, },
}); });
+91
View File
@@ -0,0 +1,91 @@
import type { BrowserWindow } from "electrobun/main";
import type { AppSettings } from "../shared/types";
import { t } from "./i18n";
import { createSettingsRPC } from "./rpc-handlers";
import type { SettingsStore } from "./store/settings";
import { openSecondaryWindow } from "./windows";
const SETTINGS_WIDTH = 680;
const SETTINGS_HEIGHT = 480;
export interface SettingsWindowDeps {
settings: SettingsStore;
/** The once-resolved dev/prod view url. */
url: string;
/** Live settings snapshot — baked into the window's preload (theme flash). */
currentSettings: () => AppSettings;
mainFrame: () => { x: number; y: number; width: number; height: number };
/** This window saved settings — rebroadcast them to every other window. */
onSettingsSaved: (settings: AppSettings) => void;
showAbout: () => void;
}
/**
* The single Settings window. Unlike the connection manager it is NOT modal:
* every edit is an ordinary settings save, so the other windows follow live
* while it stays open. On macOS ⌘W reaches bun as the main window's close-tab
* menu action, so index.ts asks isFocused() first and closes this window
* instead of a main-window tab.
*/
export class SettingsWindow {
private win: BrowserWindow | null = null;
private rpc: ReturnType<typeof createSettingsRPC> | null = null;
private focused = false;
constructor(private deps: SettingsWindowDeps) {}
isFocused(): boolean {
return this.focused;
}
open(): void {
if (this.win) {
this.win.activate();
return;
}
this.rpc = createSettingsRPC(
{ settings: this.deps.settings },
{
onSettingsSaved: (next) => this.deps.onSettingsSaved(next),
showAbout: () => this.deps.showAbout(),
},
);
const main = this.deps.mainFrame();
this.win = openSecondaryWindow({
title: t("options.title"),
url: this.deps.url,
mode: "settings",
settings: this.deps.currentSettings(),
frame: {
x: Math.max(0, main.x + (main.width - SETTINGS_WIDTH) / 2),
y: Math.max(0, main.y + (main.height - SETTINGS_HEIGHT) / 2),
width: SETTINGS_WIDTH,
height: SETTINGS_HEIGHT,
},
rpc: this.rpc,
});
this.win.on("focus", () => (this.focused = true));
this.win.on("blur", () => (this.focused = false));
this.win.on("close", () => {
this.win = null;
this.rpc = null;
this.focused = false;
});
console.log("[settings] window opened");
}
close(): void {
this.win?.close(); // the close event does the cleanup
}
/** The native title follows a language switch (the header row is the UI's own). */
retitle(): void {
this.win?.setTitle(t("options.title"));
}
broadcastSettings(settings: AppSettings): void {
this.rpc?.send.settingsChanged({ settings });
}
}
+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,
+12 -21
View File
@@ -23,36 +23,26 @@ export const de = {
manageConnections: "Verbindungen verwalten", manageConnections: "Verbindungen verwalten",
newTab: "Neuer Tab ({mod}T)", newTab: "Neuer Tab ({mod}T)",
closeTab: "Tab schließen ({mod}W)", closeTab: "Tab schließen ({mod}W)",
options: "Optionen", options: "Einstellungen",
update: "Update verfügbar", update: "Update verfügbar",
updating: "Aktualisiere", updating: "Aktualisiere",
restartToUpdate: "Zum Anwenden neu starten", restartToUpdate: "Zum Anwenden neu starten",
updateTooltip: "Update verfügbar: miniSCP {version}", updateTooltip: "Update verfügbar: miniSCP {version}",
}, },
options: { options: {
title: "Optionen", title: "Einstellungen",
sections: {
appearance: "Darstellung",
transfers: "Übertragungen",
editor: "Editor",
fileList: "Dateiliste",
about: "Über",
},
columns: "Sichtbare Spalten",
language: "Sprache", language: "Sprache",
system: "System", system: "System",
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" },
@@ -255,6 +245,7 @@ export const de = {
}, },
menu: { menu: {
about: "Über {app}", about: "Über {app}",
settings: "Einstellungen…",
checkForUpdates: "Nach Updates suchen…", checkForUpdates: "Nach Updates suchen…",
quit: "Beenden", quit: "Beenden",
file: "Datei", file: "Datei",
+12 -21
View File
@@ -27,36 +27,26 @@ export const en = {
manageConnections: "Manage connections", manageConnections: "Manage connections",
newTab: "New tab ({mod}T)", newTab: "New tab ({mod}T)",
closeTab: "Close tab ({mod}W)", closeTab: "Close tab ({mod}W)",
options: "Options", options: "Settings",
update: "Update available", update: "Update available",
updating: "Updating", updating: "Updating",
restartToUpdate: "Restart to apply", restartToUpdate: "Restart to apply",
updateTooltip: "Update available: miniSCP {version}", updateTooltip: "Update available: miniSCP {version}",
}, },
options: { options: {
title: "Options", title: "Settings",
sections: {
appearance: "Appearance",
transfers: "Transfers",
editor: "Editor",
fileList: "File list",
about: "About",
},
columns: "Visible columns",
language: "Language", language: "Language",
system: "System", system: "System",
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" },
@@ -259,6 +249,7 @@ export const en = {
}, },
menu: { menu: {
about: "About {app}", about: "About {app}",
settings: "Settings…",
checkForUpdates: "Check for Updates…", checkForUpdates: "Check for Updates…",
quit: "Quit", quit: "Quit",
file: "File", file: "File",
+12 -21
View File
@@ -23,36 +23,26 @@ export const es = {
manageConnections: "Gestionar conexiones", manageConnections: "Gestionar conexiones",
newTab: "Nueva pestaña ({mod}T)", newTab: "Nueva pestaña ({mod}T)",
closeTab: "Cerrar pestaña ({mod}W)", closeTab: "Cerrar pestaña ({mod}W)",
options: "Opciones", options: "Ajustes",
update: "Actualización disponible", update: "Actualización disponible",
updating: "Actualizando", updating: "Actualizando",
restartToUpdate: "Reiniciar para aplicar", restartToUpdate: "Reiniciar para aplicar",
updateTooltip: "Actualización disponible: miniSCP {version}", updateTooltip: "Actualización disponible: miniSCP {version}",
}, },
options: { options: {
title: "Opciones", title: "Ajustes",
sections: {
appearance: "Apariencia",
transfers: "Transferencias",
editor: "Editor",
fileList: "Lista de archivos",
about: "Acerca de",
},
columns: "Columnas visibles",
language: "Idioma", language: "Idioma",
system: "Sistema", system: "Sistema",
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" },
@@ -255,6 +245,7 @@ export const es = {
}, },
menu: { menu: {
about: "Acerca de {app}", about: "Acerca de {app}",
settings: "Ajustes…",
checkForUpdates: "Buscar actualizaciones…", checkForUpdates: "Buscar actualizaciones…",
quit: "Salir", quit: "Salir",
file: "Archivo", file: "Archivo",
+12 -21
View File
@@ -25,36 +25,26 @@ export const eu = {
manageConnections: "Kudeatu konexioak", manageConnections: "Kudeatu konexioak",
newTab: "Fitxa berria ({mod}T)", newTab: "Fitxa berria ({mod}T)",
closeTab: "Itxi fitxa ({mod}W)", closeTab: "Itxi fitxa ({mod}W)",
options: "Aukerak", options: "Ezarpenak",
update: "Eguneratzea eskuragarri", update: "Eguneratzea eskuragarri",
updating: "Eguneratzen", updating: "Eguneratzen",
restartToUpdate: "Berrabiarazi aplikatzeko", restartToUpdate: "Berrabiarazi aplikatzeko",
updateTooltip: "Eguneratzea eskuragarri: miniSCP {version}", updateTooltip: "Eguneratzea eskuragarri: miniSCP {version}",
}, },
options: { options: {
title: "Aukerak", title: "Ezarpenak",
sections: {
appearance: "Itxura",
transfers: "Transferentziak",
editor: "Editorea",
fileList: "Fitxategi-zerrenda",
about: "Honi buruz",
},
columns: "Zutabe ikusgaiak",
language: "Hizkuntza", language: "Hizkuntza",
system: "Sistema", system: "Sistema",
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" },
@@ -257,6 +247,7 @@ export const eu = {
}, },
menu: { menu: {
about: "{app} (r)i buruz", about: "{app} (r)i buruz",
settings: "Ezarpenak…",
checkForUpdates: "Bilatu eguneratzeak…", checkForUpdates: "Bilatu eguneratzeak…",
quit: "Irten", quit: "Irten",
file: "Fitxategia", file: "Fitxategia",
+12 -21
View File
@@ -23,36 +23,26 @@ export const fr = {
manageConnections: "Gérer les connexions", manageConnections: "Gérer les connexions",
newTab: "Nouvel onglet ({mod}T)", newTab: "Nouvel onglet ({mod}T)",
closeTab: "Fermer l'onglet ({mod}W)", closeTab: "Fermer l'onglet ({mod}W)",
options: "Options", options: "Réglages",
update: "Mise à jour disponible", update: "Mise à jour disponible",
updating: "Mise à jour", updating: "Mise à jour",
restartToUpdate: "Redémarrer pour appliquer", restartToUpdate: "Redémarrer pour appliquer",
updateTooltip: "Mise à jour disponible : miniSCP {version}", updateTooltip: "Mise à jour disponible : miniSCP {version}",
}, },
options: { options: {
title: "Options", title: "Réglages",
sections: {
appearance: "Apparence",
transfers: "Transferts",
editor: "Éditeur",
fileList: "Liste des fichiers",
about: "À propos",
},
columns: "Colonnes visibles",
language: "Langue", language: "Langue",
system: "Système", system: "Système",
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" },
@@ -255,6 +245,7 @@ export const fr = {
}, },
menu: { menu: {
about: "À propos de {app}", about: "À propos de {app}",
settings: "Réglages…",
checkForUpdates: "Rechercher des mises à jour…", checkForUpdates: "Rechercher des mises à jour…",
quit: "Quitter", quit: "Quitter",
file: "Fichier", file: "Fichier",
+12 -21
View File
@@ -24,36 +24,26 @@ export const ja = {
manageConnections: "接続を管理", manageConnections: "接続を管理",
newTab: "新しいタブ ({mod}T)", newTab: "新しいタブ ({mod}T)",
closeTab: "タブを閉じる ({mod}W)", closeTab: "タブを閉じる ({mod}W)",
options: "オプション", options: "設定",
update: "アップデートあり", update: "アップデートあり",
updating: "更新中", updating: "更新中",
restartToUpdate: "再起動して適用", restartToUpdate: "再起動して適用",
updateTooltip: "アップデートがあります: miniSCP {version}", updateTooltip: "アップデートがあります: miniSCP {version}",
}, },
options: { options: {
title: "オプション", title: "設定",
sections: {
appearance: "外観",
transfers: "転送",
editor: "エディタ",
fileList: "ファイル一覧",
about: "情報",
},
columns: "表示する列",
language: "言語", language: "言語",
system: "システム", system: "システム",
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: "複数" },
@@ -255,6 +245,7 @@ export const ja = {
}, },
menu: { menu: {
about: "{app} について", about: "{app} について",
settings: "設定…",
checkForUpdates: "アップデートを確認…", checkForUpdates: "アップデートを確認…",
quit: "終了", quit: "終了",
file: "ファイル", file: "ファイル",
+12 -21
View File
@@ -24,36 +24,26 @@ export const zh = {
manageConnections: "管理连接", manageConnections: "管理连接",
newTab: "新建标签页 ({mod}T)", newTab: "新建标签页 ({mod}T)",
closeTab: "关闭标签页 ({mod}W)", closeTab: "关闭标签页 ({mod}W)",
options: "选项", options: "设置",
update: "有可用更新", update: "有可用更新",
updating: "正在更新", updating: "正在更新",
restartToUpdate: "重启以应用", restartToUpdate: "重启以应用",
updateTooltip: "有可用更新:miniSCP {version}", updateTooltip: "有可用更新:miniSCP {version}",
}, },
options: { options: {
title: "选项", title: "设置",
sections: {
appearance: "外观",
transfers: "传输",
editor: "编辑器",
fileList: "文件列表",
about: "关于",
},
columns: "显示的列",
language: "语言", language: "语言",
system: "系统", system: "系统",
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: "多个" },
@@ -255,6 +245,7 @@ export const zh = {
}, },
menu: { menu: {
about: "关于 {app}", about: "关于 {app}",
settings: "设置…",
checkForUpdates: "检查更新…", checkForUpdates: "检查更新…",
quit: "退出", quit: "退出",
file: "文件", file: "文件",
+7 -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,9 @@ export type AppRPC = {
managerConnect: { params: { id: string }; response: Result }; managerConnect: { params: { id: string }; response: Result };
managerOpenStack: { params: { id: string }; response: Result }; managerOpenStack: { params: { id: string }; response: Result };
managerClose: { params: Record<string, never>; response: Result }; managerClose: { params: Record<string, never>; response: Result };
// 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. */
windowToggleMaximize: {
params: Record<string, never>;
response: Result<{ maximized: boolean }>;
};
windowClose: { params: Record<string, never>; response: Result };
/** First paint: the title bar needs the state before any resize fires. */
windowIsMaximized: {
params: Record<string, never>;
response: Result<{ maximized: boolean }>;
};
/**
* STOPGAP — the two halves of the synthesized resize. A frameless GTK
* window has no resize borders and electrobun exposes no native resize
* gesture, so the webview drags its own handles and moves the frame
* itself. Delete both with the rest of the hack; see the removal
* checklist in ui/src/app/skins/7like/window-resize/window-resize.ts.
*/
windowGetFrame: {
params: Record<string, never>;
response: Result<{ x: number; y: number; width: number; height: number }>;
};
windowSetFrame: {
params: { x: number; y: number; width: number; height: number };
response: Result;
};
// auto-updater (electrobun Updater) // 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 +187,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>;
/** /**
+1 -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;
@@ -125,11 +99,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,
-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"
}, },
+8 -8
View File
@@ -5,7 +5,7 @@ import { IS_MAC, IS_WINDOWS, MOD_LABEL } from './platform';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.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'; type?: 'separator';
@@ -16,8 +16,8 @@ export interface MenuItem {
tooltip?: string; tooltip?: string;
/** Shortcut hint, rendered right-aligned. */ /** Shortcut hint, rendered right-aligned. */
accelerator?: string; accelerator?: string;
/** Greyed and unclickable. Win7 menus disable rather than hide, so the set /** Greyed and unclickable. Menus disable rather than hide, so the set of
* of commands stays in the same place from one right-click to the next. */ * commands stays in the same place from one right-click to the next. */
disabled?: boolean; disabled?: boolean;
} }
@@ -53,10 +53,10 @@ const trashKey = (): string => (IS_WINDOWS ? 'ctx.moveToRecycleBin' : 'ctx.moveT
/** /**
* Builds and opens the panel right-click menus. This used to be a native * Builds and opens the panel right-click menus. This used to be a native
* electrobun ContextMenu on the bun side, but `showContextMenu` is a documented * 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 — and still emits the exact same
* the dispatcher in app.ts is unchanged. * action strings, so the dispatcher in app.ts is unchanged.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ContextMenuService { export class ContextMenuService {
@@ -133,7 +133,7 @@ export class ContextMenuService {
* assume `activeWorkspace`. * assume `activeWorkspace`.
* *
* 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 explains
* why the last tab cannot be closed better than its absence does. * 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
+13 -17
View File
@@ -1,4 +1,4 @@
// File-list glyphs + per-category colors ("Multi" icon mode). Category colors // File-list glyphs + per-category colors. Category colors
// are fixed tokens (never follow the accent) so a folder is always folder-blue. // are fixed tokens (never follow the accent) so a folder is always folder-blue.
// Only the icon glyph is colored; row text stays neutral. Docs and unknown // Only the icon glyph is colored; row text stays neutral. Docs and unknown
// files stay muted on purpose — sparing color is what keeps it scannable. // files stay muted on purpose — sparing color is what keeps it scannable.
@@ -8,33 +8,32 @@ type Category = 'image' | 'audio' | 'video' | 'archive' | 'code' | 'doc';
// Class strings are literals so the Tailwind scanner picks them up. // Class strings are literals so the Tailwind scanner picks them up.
const MUTED = 'shrink-0 text-muted-foreground'; const MUTED = 'shrink-0 text-muted-foreground';
const PRIMARY = 'shrink-0 text-primary';
const FOLDER = 'shrink-0 text-icon-folder'; const FOLDER = 'shrink-0 text-icon-folder';
const LINK = 'shrink-0 text-icon-link'; const LINK = 'shrink-0 text-icon-link';
const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: string[] }> = { const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: string[] }> = {
image: { image: {
glyph: 'mynaImage', glyph: 'tdesignImage',
class: 'shrink-0 text-icon-image', class: 'shrink-0 text-icon-image',
extensions: 'png jpg jpeg gif webp avif svg ico icns bmp tiff tif heic heif raw'.split(' '), extensions: 'png jpg jpeg gif webp avif svg ico icns bmp tiff tif heic heif raw'.split(' '),
}, },
audio: { audio: {
glyph: 'mynaMusic', glyph: 'tdesignMusic',
class: 'shrink-0 text-icon-media', class: 'shrink-0 text-icon-media',
extensions: 'mp3 wav flac aac ogg oga m4a opus wma aiff aif mid midi'.split(' '), extensions: 'mp3 wav flac aac ogg oga m4a opus wma aiff aif mid midi'.split(' '),
}, },
video: { video: {
glyph: 'mynaVideo', glyph: 'tdesignVideo',
class: 'shrink-0 text-icon-media', class: 'shrink-0 text-icon-media',
extensions: 'mp4 mkv mov avi webm m4v mpg mpeg wmv flv 3gp'.split(' '), extensions: 'mp4 mkv mov avi webm m4v mpg mpeg wmv flv 3gp'.split(' '),
}, },
archive: { archive: {
glyph: 'mynaArchive', glyph: 'tdesignFileZip',
class: 'shrink-0 text-icon-archive', class: 'shrink-0 text-icon-archive',
extensions: 'zip tar gz tgz bz2 xz zst 7z rar dmg iso jar deb rpm pkg apk'.split(' '), extensions: 'zip tar gz tgz bz2 xz zst 7z rar dmg iso jar deb rpm pkg apk'.split(' '),
}, },
code: { code: {
glyph: 'mynaCode', glyph: 'tdesignCode',
class: 'shrink-0 text-icon-code', class: 'shrink-0 text-icon-code',
// ".ts" is TypeScript here, not an MPEG transport stream (deliberate) // ".ts" is TypeScript here, not an MPEG transport stream (deliberate)
extensions: ( extensions: (
@@ -46,7 +45,7 @@ const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: s
).split(' '), ).split(' '),
}, },
doc: { doc: {
glyph: 'mynaFileText', glyph: 'tdesignFile1',
class: MUTED, // docs keep the neutral color; only the glyph changes class: MUTED, // docs keep the neutral color; only the glyph changes
extensions: 'md markdown txt rtf pdf doc docx xls xlsx ppt pptx csv tsv log odt ods odp epub'.split(' '), extensions: 'md markdown txt rtf pdf doc docx xls xlsx ppt pptx csv tsv log odt ods odp epub'.split(' '),
}, },
@@ -85,18 +84,15 @@ export function fileKindKey(entry: FileEntry): string {
return category ? KIND_KEYS[category] : 'file'; return category ? KIND_KEYS[category] : 'file';
} }
export function fileGlyph(entry: FileEntry, multi: boolean): string { export function fileGlyph(entry: FileEntry): string {
if (entry.name === '..') return 'mynaArrowUp'; if (entry.name === '..') return 'tdesignArrowUp';
if (entry.type === 'dir') return 'mynaFolder'; if (entry.type === 'dir') return 'tdesignFolder';
if (!multi) return 'mynaFile'; if (entry.type === 'symlink') return 'tdesignLink';
if (entry.type === 'symlink') return 'mynaLink';
const category = categoryOf(entry.name); const category = categoryOf(entry.name);
return category ? CATEGORIES[category].glyph : 'mynaFile'; return category ? CATEGORIES[category].glyph : 'tdesignFile';
} }
export function fileIconClass(entry: FileEntry, multi: boolean): string { export function fileIconClass(entry: FileEntry): string {
// plain mode: exactly the pre-color look ('..' has type 'dir' -> primary)
if (!multi) return entry.type === 'dir' ? PRIMARY : MUTED;
if (entry.name === '..') return MUTED; if (entry.name === '..') return MUTED;
if (entry.type === 'dir') return FOLDER; if (entry.type === 'dir') return FOLDER;
if (entry.type === 'symlink') return LINK; if (entry.type === 'symlink') return LINK;
+68 -71
View File
@@ -1,76 +1,73 @@
// 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 // TDesign's package root exports every icon; named imports tree-shake. Names are
// (or /solid, where names get a `Solid` suffix). // plain strings in templates — the build never checks them, so every name used
// anywhere must be listed here (an unregistered one renders nothing).
import { import {
mynaArchive, tdesignAdd,
mynaArrowLeft, tdesignArrowLeft,
mynaArrowLeftRight, tdesignArrowRight,
mynaArrowRight, tdesignArrowUp,
mynaArrowUp, tdesignCheck,
mynaCheck, tdesignCheckCircle,
mynaCheckCircle, tdesignChevronDown,
mynaChevronDown, tdesignChevronRight,
mynaChevronRight, tdesignChevronUp,
mynaChevronUp, tdesignClose,
mynaCode, tdesignCloseCircle,
mynaCog, tdesignCode,
mynaDots, tdesignDelete,
mynaDownload, tdesignDownload,
mynaEdit, tdesignEdit,
mynaFile, tdesignFile,
mynaFilePlus, tdesignFile1,
mynaFileText, tdesignFileAdd,
mynaFolder, tdesignFileZip,
mynaFolderPlus, tdesignFolder,
mynaHardDrive, tdesignFolderAdd,
mynaImage, tdesignHardDrive,
mynaLink, tdesignImage,
mynaLock, tdesignLink,
mynaMusic, tdesignLoading,
mynaPlus, tdesignLockOn,
mynaRefresh, tdesignMusic,
mynaSearch, tdesignRefresh,
mynaServers, tdesignSearch,
mynaSpinner, tdesignServer,
mynaTrash, tdesignSetting,
mynaVideo, tdesignVideo,
mynaX, } from "@ng-icons/tdesign-icons";
mynaXCircle,
} from "@ng-icons/mynaui/outline";
export const APP_ICONS = { export const APP_ICONS = {
mynaArchive, tdesignAdd,
mynaArrowLeft, tdesignArrowLeft,
mynaArrowLeftRight, tdesignArrowRight,
mynaArrowRight, tdesignArrowUp,
mynaArrowUp, tdesignCheck,
mynaCheck, tdesignCheckCircle,
mynaCheckCircle, tdesignChevronDown,
mynaChevronDown, tdesignChevronRight,
mynaChevronRight, tdesignChevronUp,
mynaChevronUp, tdesignClose,
mynaCode, tdesignCloseCircle,
mynaCog, tdesignCode,
mynaDots, tdesignDelete,
mynaDownload, tdesignDownload,
mynaEdit, tdesignEdit,
mynaFile, tdesignFile,
mynaFilePlus, tdesignFile1,
mynaFileText, tdesignFileAdd,
mynaFolder, tdesignFileZip,
mynaFolderPlus, tdesignFolder,
mynaHardDrive, tdesignFolderAdd,
mynaImage, tdesignHardDrive,
mynaLink, tdesignImage,
mynaLock, tdesignLink,
mynaMusic, tdesignLoading,
mynaPlus, tdesignLockOn,
mynaRefresh, tdesignMusic,
mynaSearch, tdesignRefresh,
mynaServers, tdesignSearch,
mynaSpinner, tdesignServer,
mynaTrash, tdesignSetting,
mynaVideo, tdesignVideo,
mynaX,
mynaXCircle,
}; };
+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;
}
+6 -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,19 @@ 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 /** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
* platform — the keyboard does not change with the window chrome. */
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+'; export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+'; export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
export const APP_MODE: 'main' | 'editor' | 'manager' = export const APP_MODE: 'main' | 'editor' | 'manager' | 'settings' =
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main'; typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
export const EDITOR_CONTEXT = export const EDITOR_CONTEXT =
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined; typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
/** /** True when macOS traffic lights overlay the app's own header row (the fused
* 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
* `hiddenInset` layout, which reserves `pl-24` for them. False under the win7
* skin even on macOS: `titleBarStyle: 'hidden'` removes the traffic lights
* altogether, so reserving space would leave a gap. Only the shadlike tree
* reads this; 7like draws its own title bar and never defers to native chrome.
*/
export const IS_MAC_CHROME = IS_MAC && !IS_WIN7_CHROME;
/** /**
* Kill the webview's default right-click menu (Reload, Inspect Element…) * 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;
+1 -67
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);
@@ -39,8 +36,6 @@ export class SettingsService {
window window
.matchMedia('(prefers-color-scheme: dark)') .matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => this.systemDark.set(e.matches)); .addEventListener('change', (e) => this.systemDark.set(e.matches));
// the default accent is not the CSS default — apply before first paint
this.applyAccent();
if (!this.rpc.available) return; if (!this.rpc.available) return;
void this.rpc.request.getSettings({}).then((res) => { void this.rpc.request.getSettings({}).then((res) => {
if (res.ok) this.applyAll(res.settings); if (res.ok) this.applyAll(res.settings);
@@ -51,11 +46,8 @@ export class SettingsService {
private applyAll(settings: AppSettings): void { private applyAll(settings: AppSettings): void {
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 +56,8 @@ 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
// settingsChanged push, so flipping the class here would break every OPEN
// editor and manager window too, not just the one that changed the setting.
this.applyTheme(); this.applyTheme();
this.applyDarkTone(); this.applyDarkTone();
this.applyAccent();
this.applyUiScale(); this.applyUiScale();
this.applyLocale(); this.applyLocale();
} }
@@ -80,30 +68,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 +80,6 @@ export class SettingsService {
void this.persist(); void this.persist();
} }
setAccent(tone: AppSettings['accent']): void {
this.accent.set(tone);
this.applyAccent();
void this.persist();
}
setIconColors(mode: AppSettings['iconColors']): void {
// no apply step — the file panels read the signal reactively
this.iconColors.set(mode);
void this.persist();
}
toggleColumn(key: ColumnKey): void { toggleColumn(key: ColumnKey): void {
this.columns.update((list) => this.columns.update((list) =>
list.includes(key) ? list.filter((c) => c !== key) : [...list, key], list.includes(key) ? list.filter((c) => c !== key) : [...list, key],
@@ -171,12 +123,6 @@ export class SettingsService {
void this.persist(); void this.persist();
} }
// There is deliberately no applySkin(). The `.win7` class is written once by
// buildPreload at DOCUMENT_START for the skin this window actually booted,
// and must never change afterwards — see setSkin for why. Every other apply*
// below is safe to run live because it only re-points tokens, and both skins
// consume the same token system.
private applyTheme(): void { private applyTheme(): void {
const theme = this.theme(); const theme = this.theme();
if (theme === 'system') { if (theme === 'system') {
@@ -195,17 +141,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 +162,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(),
+8 -5
View File
@@ -13,6 +13,7 @@ export type ShortcutId =
| 'cycle-tab' | 'cycle-tab'
| 'cycle-tab-back' | 'cycle-tab-back'
| 'go-to-tab' // 1-9; the digit rides in ShortcutMatch.digit | 'go-to-tab' // 1-9; the digit rides in ShortcutMatch.digit
| 'open-settings'
// editor window // editor window
| 'save' | 'save'
| 'find' | 'find'
@@ -29,11 +30,11 @@ export interface ShortcutMatch {
digit?: number; digit?: number;
} }
/** Which keymap a window consults. 'manager' answers only to the any-window /** Which keymap a window consults. 'manager' and 'settings' answer only to the
* shortcuts (scale). Escape/Tab stay component-local: their meaning is * any-window shortcuts (scale). Escape/Tab stay component-local: their meaning
* contextual state (find bar open? close prompt up? which pane?), not a * is contextual state (find bar open? close prompt up? which pane?), not a
* global binding. */ * global binding. */
export type ShortcutScope = 'main' | 'editor' | 'manager'; export type ShortcutScope = 'main' | 'editor' | 'manager' | 'settings';
/** /**
* The keyboard layer: modifier helpers + one declarative keymap. * The keyboard layer: modifier helpers + one declarative keymap.
@@ -83,9 +84,11 @@ export class ShortcutsService {
if (event.code === 'Digit0' || event.code === 'Numpad0') return { id: 'scale-reset' }; if (event.code === 'Digit0' || event.code === 'Numpad0') return { id: 'scale-reset' };
} }
if (scope === 'manager') return null; // any-window shortcuts only if (scope === 'manager' || scope === 'settings') return null; // any-window shortcuts only
if (scope === 'main') { if (scope === 'main') {
// on macOS the app menu's ⌘, intercepts first; this is the Windows/Linux path
if (mod && key === ',') return { id: 'open-settings' };
if (mod && key === 't') return { id: 'new-tab' }; if (mod && key === 't') return { id: 'new-tab' };
if (mod && key === 'w') return { id: 'close-tab' }; if (mod && key === 'w') return { id: 'close-tab' };
// ctrlKey only, never meta: ⌘Tab is the macOS app switcher // ctrlKey only, never meta: ⌘Tab is the macOS app switcher
-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,92 +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 edge the arithmetic supports — NOT what the UI mounts. The window
* currently offers only the bottom-right grip (`window-resize.ts` explains why
* the others were removed: an invisible handle over a caption button steals its
* hover). This stays complete because the origin-moving cases are the subtle
* ones and the probe is where they are proven; restoring a side is then one
* entry in the component, against already-tested math.
*/
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,199 +0,0 @@
import { Component, inject } from '@angular/core';
import { RpcService } from '@core/rpc.service';
import { type Frame, 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:
* a bottom-right grip → pointer drag → `windowSetFrame` RPC →
* `BrowserWindow.setFrame`. One corner, not eight handles — see GRIP_BOX for
* why the others had to go.
*
* 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. The grip occupies real estate INSIDE the window that a
* native frame would have provided outside it — which is also why there is only
* one of them: an invisible handle over a control steals that control's hover
* (see GRIP_BOX). A real WM resize border has neither problem.
*
* ────────────────────────────────────────────────────────────────────────────
* 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.
*/
/**
* ONE handle: the bottom-right grip, exactly where Win7 put its resize gripper.
*
* A full eight-handle set was tried and reverted. The handles are invisible and
* sit ON TOP of the frame (the overlay is above the title bar in paint order),
* so the north strip ran the full width across the caption buttons and the
* north-east square sat on the close button's own top-right corner. Crossing
* one stole `:hover` from the button underneath, and since 7.css cross-fades
* the caption glow, a stolen hover reads as the glow flickering or sticking —
* "weird artifacts on the buttons", with no visual bug anywhere in the CSS.
*
* The bottom-right corner is the one place that overlaps nothing: it lands in
* the 6px gutter that `.window-body { margin: 0 6px 6px }` leaves below and
* right of the content. `resizeFrame` stays general (and the probe still covers
* all eight edges) so restoring a side is one entry here — but any new handle
* must be checked against what it covers first. South, east and west are the
* innocent ones; north and both top corners are not.
*
* 1rem, not the 0.75rem the corners had: this is now the only way to resize the
* window, so the target earns the extra pixels. Rem, so it scales with the
* interface scale like everything else.
*/
const GRIP_EDGE: ResizeEdge = 'se';
const GRIP_BOX = 'right-0 bottom-0 size-4 cursor-nwse-resize';
@Component({
selector: 'app-window-resize',
template: `
<div
class="pointer-events-auto absolute touch-none electrobun-webkit-app-region-no-drag {{ gripBox }}"
(pointerdown)="onPointerDown($event)"
(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 gripBox = GRIP_BOX;
private readonly rpc = inject(RpcService);
/** Null except during a drag. Set only once the frame read resolves. */
private drag: { 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): 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 = {
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(
GRIP_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;
}
}
}
+5 -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="tdesignCloseCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p> <p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground"> <p class="mt-1 text-xs text-muted-foreground">
{{ i18n.t('app.notConnectedBody') }} {{ i18n.t('app.notConnectedBody') }}
@@ -27,11 +27,11 @@
class="fixed inset-0 z-60 flex items-center justify-center bg-black/40" class="fixed inset-0 z-60 flex items-center justify-center bg-black/40"
(mousedown)="focusManager()" (mousedown)="focusManager()"
> >
<div class="rounded-lg border border-border bg-card px-5 py-4 text-center shadow-xl"> <div class="border border-border bg-card px-5 py-4 text-center shadow-xl">
<ng-icon name="mynaServers" size="1.25rem" class="text-muted-foreground" /> <ng-icon name="tdesignServer" size="1.25rem" class="text-muted-foreground" />
<p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p> <p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p>
<button <button
class="mt-3 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90" class="mt-3 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
> >
{{ i18n.t('app.showConnections') }} {{ i18n.t('app.showConnections') }}
</button> </button>
+3
View File
@@ -188,6 +188,9 @@ export class App {
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox // ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1); this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1);
break; break;
case 'open-settings':
void this.rpc.request.openSettings({});
break;
case 'scale-up': case 'scale-up':
this.settings.setUiScale(this.settings.uiScale() + 0.1); this.settings.setUiScale(this.settings.uiScale() + 0.1);
break; break;
@@ -9,19 +9,19 @@
[class.h-9]="!isMacChrome" [class.h-9]="!isMacChrome"
[class.pl-3]="!isMacChrome" [class.pl-3]="!isMacChrome"
> >
<ng-icon name="mynaServers" size="0.95rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="tdesignServer" size="0.95rem" class="shrink-0 text-muted-foreground" />
<h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1> <h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1>
<div class="flex-1"></div> <div class="flex-1"></div>
<button <button
(click)="importWinScp()" (click)="importWinScp()"
[disabled]="importing()" [disabled]="importing()"
[title]="i18n.t('connMgr.importWinscpHint')" [title]="i18n.t('connMgr.importWinscpHint')"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-[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="0.85rem" class="animate-spin" />
} @else { } @else {
<!-- <ng-icon name="mynaArchive" size="0.85rem" /> --> <!-- <ng-icon name="tdesignFileZip" size="0.85rem" /> -->
} }
{{ i18n.t('connMgr.importWinscp') }} {{ i18n.t('connMgr.importWinscp') }}
</button> </button>
@@ -34,16 +34,16 @@
<button <button
(click)="newConnection()" (click)="newConnection()"
[title]="i18n.t('connMgr.newConnectionHint')" [title]="i18n.t('connMgr.newConnectionHint')"
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaPlus" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }} <ng-icon name="tdesignAdd" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }}
</button> </button>
<button <button
(click)="newStack()" (click)="newStack()"
[title]="i18n.t('connMgr.newStackHint')" [title]="i18n.t('connMgr.newStackHint')"
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaFolderPlus" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }} <ng-icon name="tdesignFolderAdd" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }}
</button> </button>
</div> </div>
@@ -54,7 +54,6 @@
(dragover)="onDragOver($event, group.stack.id)" (dragover)="onDragOver($event, group.stack.id)"
(dragleave)="dropTarget() === group.stack.id && dropTarget.set(null)" (dragleave)="dropTarget() === group.stack.id && dropTarget.set(null)"
(drop)="onDrop($event, group.stack.id)" (drop)="onDrop($event, group.stack.id)"
class="rounded-md"
[class.bg-primary/10]="dropTarget() === group.stack.id" [class.bg-primary/10]="dropTarget() === group.stack.id"
[class.ring-1]="dropTarget() === group.stack.id" [class.ring-1]="dropTarget() === group.stack.id"
[class.ring-inset]="dropTarget() === group.stack.id" [class.ring-inset]="dropTarget() === group.stack.id"
@@ -63,11 +62,11 @@
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5"> <div class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
<button <button
(click)="toggleStack(group.stack)" (click)="toggleStack(group.stack)"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground" class="grid size-4 shrink-0 place-items-center text-muted-foreground hover:text-foreground"
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')" [title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
> >
<ng-icon <ng-icon
[name]="group.stack.collapsed ? 'mynaChevronRight' : 'mynaChevronDown'" [name]="group.stack.collapsed ? 'tdesignChevronRight' : 'tdesignChevronDown'"
size="0.8rem" size="0.8rem"
/> />
</button> </button>
@@ -86,24 +85,24 @@
<button <button
(click)="renameStack(group.stack)" (click)="renameStack(group.stack)"
[title]="i18n.t('connMgr.renameStack')" [title]="i18n.t('connMgr.renameStack')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-foreground" class="grid size-4 place-items-center text-muted-foreground hover:text-foreground"
> >
<ng-icon name="mynaEdit" size="0.75rem" /> <ng-icon name="tdesignEdit" size="0.75rem" />
</button> </button>
<button <button
(click)="deleteStack(group.stack)" (click)="deleteStack(group.stack)"
[title]="i18n.t('connMgr.deleteStack')" [title]="i18n.t('connMgr.deleteStack')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-destructive" class="grid size-4 place-items-center text-muted-foreground hover:text-destructive"
> >
<ng-icon name="mynaTrash" size="0.75rem" /> <ng-icon name="tdesignDelete" size="0.75rem" />
</button> </button>
@if (group.connections.length) { @if (group.connections.length) {
<button <button
(click)="openStack(group.stack.id)" (click)="openStack(group.stack.id)"
[title]="i18n.t('connMgr.openStack')" [title]="i18n.t('connMgr.openStack')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-primary" class="grid size-4 place-items-center text-muted-foreground hover:text-primary"
> >
<ng-icon name="mynaArrowRight" size="0.8rem" /> <ng-icon name="tdesignArrowRight" size="0.8rem" />
</button> </button>
} }
</div> </div>
@@ -119,7 +118,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="tdesignServer" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span> <span class="truncate">{{ connection.name }}</span>
</button> </button>
} @empty { } @empty {
@@ -134,7 +133,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'"
@@ -155,7 +154,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="tdesignServer" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span> <span class="truncate">{{ connection.name }}</span>
</button> </button>
} }
@@ -183,7 +182,7 @@
(input)="patch('name', $any($event.target).value)" (input)="patch('name', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.namePlaceholder')" [placeholder]="i18n.t('connMgr.namePlaceholder')"
spellcheck="false" spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
@@ -194,7 +193,7 @@
(input)="patch('host', $any($event.target).value)" (input)="patch('host', $any($event.target).value)"
placeholder="192.168.1.10" placeholder="192.168.1.10"
spellcheck="false" spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
@@ -204,14 +203,14 @@
type="number" type="number"
[value]="d.port" [value]="d.port"
(input)="patch('port', +$any($event.target).value)" (input)="patch('port', +$any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
<label class="flex flex-col gap-1 text-xs"> <label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.stack') }}</span> <span class="text-muted-foreground">{{ i18n.t('connMgr.stack') }}</span>
<select <select
(change)="patch('stackId', $any($event.target).value || null)" (change)="patch('stackId', $any($event.target).value || null)"
class="h-7 rounded-md border border-input bg-background px-1 outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-background pl-1 outline-none focus:ring-2 focus:ring-ring"
> >
<!-- [selected] per option: binding [value] on the <select> runs <!-- [selected] per option: binding [value] on the <select> runs
before the options render and silently shows "(none)" --> before the options render and silently shows "(none)" -->
@@ -231,7 +230,7 @@
[value]="d.username" [value]="d.username"
(input)="patch('username', $any($event.target).value)" (input)="patch('username', $any($event.target).value)"
spellcheck="false" spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
<label class="flex flex-col gap-1 text-xs"> <label class="flex flex-col gap-1 text-xs">
@@ -241,7 +240,7 @@
(input)="patch('initialPath', $any($event.target).value)" (input)="patch('initialPath', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.remotePathPlaceholder')" [placeholder]="i18n.t('connMgr.remotePathPlaceholder')"
spellcheck="false" spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
@@ -253,7 +252,6 @@
name="authKind" name="authKind"
[checked]="d.authKind === 'password'" [checked]="d.authKind === 'password'"
(change)="patch('authKind', 'password')" (change)="patch('authKind', 'password')"
class="accent-primary"
/> />
{{ i18n.t('connMgr.authPassword') }} {{ i18n.t('connMgr.authPassword') }}
</label> </label>
@@ -263,7 +261,6 @@
name="authKind" name="authKind"
[checked]="d.authKind === 'key'" [checked]="d.authKind === 'key'"
(change)="patch('authKind', 'key')" (change)="patch('authKind', 'key')"
class="accent-primary"
/> />
{{ i18n.t('connMgr.authKey') }} {{ i18n.t('connMgr.authKey') }}
</label> </label>
@@ -276,7 +273,7 @@
type="password" type="password"
[value]="d.password" [value]="d.password"
(input)="patch('password', $any($event.target).value)" (input)="patch('password', $any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
} @else { } @else {
@@ -288,12 +285,12 @@
(input)="patch('privateKeyPath', $any($event.target).value)" (input)="patch('privateKeyPath', $any($event.target).value)"
placeholder="~/.ssh/id_ed25519" placeholder="~/.ssh/id_ed25519"
spellcheck="false" spellcheck="false"
class="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring" class="h-7 min-w-0 flex-1 border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/> />
<button <button
type="button" type="button"
(click)="pickKeyFile()" (click)="pickKeyFile()"
class="h-7 shrink-0 rounded-md border border-border px-2 text-[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 +302,7 @@
type="password" type="password"
[value]="d.passphrase" [value]="d.passphrase"
(input)="patch('passphrase', $any($event.target).value)" (input)="patch('passphrase', $any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring" class="h-7 border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/> />
</label> </label>
} }
@@ -318,7 +315,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 +327,13 @@
<div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5"> <div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5">
<button <button
(click)="deleteCurrent()" (click)="deleteCurrent()"
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive" class="flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
> >
<ng-icon name="mynaTrash" size="0.85rem" /> {{ i18n.t('common.delete') }} <ng-icon name="tdesignDelete" size="0.85rem" /> {{ i18n.t('common.delete') }}
</button> </button>
<button <button
(click)="moreOpen.set(!moreOpen())" (click)="moreOpen.set(!moreOpen())"
class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" class="border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
{{ moreOpen() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }} {{ moreOpen() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }}
</button> </button>
@@ -344,17 +341,17 @@
<button <button
(click)="save()" (click)="save()"
[disabled]="!valid()" [disabled]="!valid()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50" class="px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
> >
{{ i18n.t('common.save') }} {{ i18n.t('common.save') }}
</button> </button>
<button <button
(click)="saveAndConnect()" (click)="saveAndConnect()"
[disabled]="!valid() || busy()" [disabled]="!valid() || busy()"
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" class="flex items-center gap-1.5 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
@if (busy()) { @if (busy()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" /> <ng-icon name="tdesignLoading" size="0.85rem" class="animate-spin" />
} }
{{ i18n.t('common.connect') }} {{ i18n.t('common.connect') }}
</button> </button>
@@ -362,7 +359,7 @@
} @else { } @else {
<div class="grid flex-1 place-items-center"> <div class="grid flex-1 place-items-center">
<div class="text-center"> <div class="text-center">
<ng-icon name="mynaServers" size="1.75rem" class="text-muted-foreground" /> <ng-icon name="tdesignServer" size="1.75rem" class="text-muted-foreground" />
<p class="mt-2 text-xs text-muted-foreground"> <p class="mt-2 text-xs text-muted-foreground">
{{ i18n.t('connMgr.selectPrompt') }} {{ i18n.t('connMgr.selectPrompt') }}
</p> </p>
+13 -18
View File
@@ -10,6 +10,7 @@ import {
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { ContextMenuService, type MenuItem } from '@core/context-menu.service'; import { ContextMenuService, 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';
/** 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
@@ -38,28 +39,31 @@ 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" class="fixed z-50 w-56 border border-border/60 bg-chrome py-1 text-foreground shadow-xl outline-none"
> >
@for (item of req.items; track $index) { @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 { } @else {
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
[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"> <span class="w-3.5 shrink-0">
@if (item.checked) { @if (item.checked) {
<ng-icon name="mynaCheck" size="0.85rem" class="text-primary" /> <ng-icon name="tdesignCheck" size="0.85rem" class="text-primary" />
} }
</span> </span>
<span class="flex-1 truncate">{{ item.label }}</span> <span class="flex-1 truncate">{{ item.label }}</span>
@@ -163,18 +167,9 @@ export class ContextMenu {
} }
} }
/** Move the highlight by one selectable item, skipping separators and /** Move the highlight by one selectable item (see stepIndex). */
* disabled entries, wrapping. */ private step(items: MenuItem[], delta: 1 | -1): void {
private step(items: MenuItem[], delta: number): void { const selectable = items.map((i) => i.type !== 'separator' && !i.disabled);
const count = items.length; this.activeIndex.set(stepIndex(selectable, this.activeIndex(), delta));
if (!count) return;
let index = this.activeIndex();
for (let hops = 0; hops < count; hops++) {
index = (index + delta + count) % count;
if (items[index].type !== 'separator' && !items[index].disabled) {
this.activeIndex.set(index);
return;
}
}
} }
} }
@@ -11,7 +11,7 @@ const PERMS = ['r', 'w', 'x'] as const;
template: ` template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()"> <div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div <div
class="w-80 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl" class="w-80 border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()" (mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()" (keydown.escape)="cancel()"
> >
@@ -37,7 +37,7 @@ const PERMS = ['r', 'w', 'x'] as const;
type="checkbox" type="checkbox"
[checked]="hasBit(r, p)" [checked]="hasBit(r, p)"
(change)="toggleBit(r, p)" (change)="toggleBit(r, p)"
class="size-3.5 accent-primary" class="size-3.5"
/> />
</td> </td>
} }
@@ -53,20 +53,20 @@ const PERMS = ['r', 'w', 'x'] as const;
(input)="setOctal($any($event.target).value)" (input)="setOctal($any($event.target).value)"
maxlength="4" maxlength="4"
spellcheck="false" spellcheck="false"
class="h-7 w-20 rounded-md border border-input bg-transparent px-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring" class="h-7 w-20 border border-input bg-transparent px-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring"
/> />
</div> </div>
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<button <button
(click)="cancel()" (click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent" class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
> >
{{ i18n.t('common.cancel') }} {{ i18n.t('common.cancel') }}
</button> </button>
<button <button
(click)="submit()" (click)="submit()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90" class="bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
> >
{{ i18n.t('common.apply') }} {{ i18n.t('common.apply') }}
</button> </button>
@@ -8,7 +8,7 @@ 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" class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()" (mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()" (keydown.escape)="cancel()"
> >
@@ -17,7 +17,7 @@ import { FocusService } from '@core/focus.service';
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<button <button
(click)="cancel()" (click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent" class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
> >
{{ i18n.t('common.cancel') }} {{ i18n.t('common.cancel') }}
</button> </button>
@@ -26,8 +26,8 @@ import { FocusService } from '@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 }}
@@ -21,7 +21,7 @@ import { FocusService } from '@core/focus.service';
(keydown.escape)="pick('cancel')" (keydown.escape)="pick('cancel')"
> >
<div <div
class="w-[26rem] rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl" class="w-[26rem] border border-border bg-card p-4 text-card-foreground shadow-xl"
> >
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<h2 class="min-w-0 text-sm font-semibold"> <h2 class="min-w-0 text-sm font-semibold">
@@ -30,7 +30,7 @@ import { FocusService } from '@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,20 +47,20 @@ 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() ? 'tdesignFolder' : 'tdesignFile'"
size="0.9rem" size="0.9rem"
class="shrink-0 text-muted-foreground" class="shrink-0 text-muted-foreground"
/> />
@@ -79,7 +79,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 +87,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 +101,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>
@@ -16,7 +16,7 @@ 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" class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()" (mousedown)="$event.stopPropagation()"
> >
<h2 class="text-sm font-semibold">{{ request().title }}</h2> <h2 class="text-sm font-semibold">{{ request().title }}</h2>
@@ -28,19 +28,19 @@ import { FocusService } from '@core/focus.service';
(keydown.escape)="cancel()" (keydown.escape)="cancel()"
[placeholder]="request().placeholder ?? ''" [placeholder]="request().placeholder ?? ''"
spellcheck="false" spellcheck="false"
class="mt-3 h-8 w-full rounded-md border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring" class="mt-3 h-8 w-full border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring"
/> />
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<button <button
(click)="cancel()" (click)="cancel()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent" class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
> >
{{ i18n.t('common.cancel') }} {{ i18n.t('common.cancel') }}
</button> </button>
<button <button
(click)="submit()" (click)="submit()"
[disabled]="!value().trim()" [disabled]="!value().trim()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" class="bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{{ request().confirmLabel }} {{ request().confirmLabel }}
</button> </button>
@@ -1,8 +1,8 @@
<div class="flex h-screen flex-col bg-background text-foreground"> <div class="flex h-screen flex-col bg-background text-foreground">
@if (!ctx) { @if (!ctx) {
<div class="flex flex-1 items-center justify-center"> <div class="flex flex-1 items-center justify-center">
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm"> <div class="max-w-sm border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" /> <ng-icon name="tdesignCloseCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p> <p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground"> <p class="mt-1 text-xs text-muted-foreground">
{{ i18n.t('editor.noFileBody') }} {{ i18n.t('editor.noFileBody') }}
@@ -20,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="tdesignFile1" size="0.95rem" class="shrink-0 text-muted-foreground" />
<h1 class="min-w-0 truncate font-mono text-sm font-semibold"> <h1 class="min-w-0 truncate font-mono text-sm font-semibold">
{{ ctx.name }}{{ dirty() ? ' •' : '' }} {{ ctx.name }}{{ dirty() ? ' •' : '' }}
</h1> </h1>
@@ -29,7 +29,7 @@
<button <button
(click)="toggleWrap()" (click)="toggleWrap()"
[title]="i18n.t('editor.wrapHint', { alt: altLabel })" [title]="i18n.t('editor.wrapHint', { alt: altLabel })"
class="rounded-md px-2 py-1 text-[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()"
@@ -39,15 +39,15 @@
<button <button
(click)="openFind()" (click)="openFind()"
[title]="i18n.t('editor.findHint', { mod: modLabel })" [title]="i18n.t('editor.findHint', { mod: modLabel })"
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground" class="grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaSearch" size="0.85rem" /> <ng-icon name="tdesignSearch" size="0.85rem" />
</button> </button>
</header> </header>
@if (findOpen()) { @if (findOpen()) {
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome px-3 py-1.5"> <div class="flex shrink-0 items-center gap-2 border-b border-border bg-toolbar px-3 py-1.5">
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" /> <ng-icon name="tdesignSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
<input <input
#findBox #findBox
[value]="findQuery()" [value]="findQuery()"
@@ -55,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"
@@ -67,24 +67,24 @@
<button <button
(click)="findStep(-1)" (click)="findStep(-1)"
[title]="i18n.t('editor.prevMatch')" [title]="i18n.t('editor.prevMatch')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaChevronUp" size="0.8rem" /> <ng-icon name="tdesignChevronUp" size="0.8rem" />
</button> </button>
<button <button
(click)="findStep(1)" (click)="findStep(1)"
[title]="i18n.t('editor.nextMatch')" [title]="i18n.t('editor.nextMatch')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaChevronDown" size="0.8rem" /> <ng-icon name="tdesignChevronDown" size="0.8rem" />
</button> </button>
<div class="flex-1"></div> <div class="flex-1"></div>
<button <button
(click)="closeFind()" (click)="closeFind()"
[title]="i18n.t('editor.closeHint')" [title]="i18n.t('editor.closeHint')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaX" size="0.8rem" /> <ng-icon name="tdesignClose" size="0.8rem" />
</button> </button>
</div> </div>
} }
@@ -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="tdesignCloseCircle" size="0.85rem" class="shrink-0" />
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }} {{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
</div> </div>
} }
@if (loading()) { @if (loading()) {
<div class="flex flex-1 items-center justify-center"> <div class="flex flex-1 items-center justify-center">
<ng-icon name="mynaSpinner" size="1.25rem" class="animate-spin text-primary" /> <ng-icon name="tdesignLoading" size="1.25rem" class="animate-spin text-primary" />
</div> </div>
} @else if (loadError(); as error) { } @else if (loadError(); as error) {
<div class="flex flex-1 items-center justify-center"> <div class="flex flex-1 items-center justify-center">
<div class="max-w-md rounded-lg border border-border bg-card p-6 text-center shadow-sm"> <div class="max-w-md border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" /> <ng-icon name="tdesignCloseCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p> <p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ error }}</p> <p class="mt-1 text-xs text-muted-foreground">{{ error }}</p>
<button <button
(click)="discardAndClose()" (click)="discardAndClose()"
class="mt-4 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent" class="mt-4 border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
> >
{{ i18n.t('editor.closeWindow') }} {{ i18n.t('editor.closeWindow') }}
</button> </button>
@@ -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="0.85rem" class="animate-spin" />
} }
{{ i18n.t('common.save') }} {{ i18n.t('common.save') }}
</button> </button>
@@ -171,7 +171,7 @@
@if (closePrompt()) { @if (closePrompt()) {
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()"> <div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()">
<div <div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl" class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()" (mousedown)="$event.stopPropagation()"
> >
<h2 class="text-sm font-semibold">{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}</h2> <h2 class="text-sm font-semibold">{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}</h2>
@@ -181,21 +181,21 @@
<div class="mt-4 flex items-center gap-2"> <div class="mt-4 flex items-center gap-2">
<button <button
(click)="discardAndClose()" (click)="discardAndClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent" class="px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent"
> >
{{ i18n.t('editor.dontSave') }} {{ i18n.t('editor.dontSave') }}
</button> </button>
<div class="flex-1"></div> <div class="flex-1"></div>
<button <button
(click)="cancelClose()" (click)="cancelClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent" class="px-3 py-1.5 text-xs font-medium hover:bg-accent"
> >
{{ i18n.t('common.cancel') }} {{ i18n.t('common.cancel') }}
</button> </button>
<button <button
(click)="saveAndClose()" (click)="saveAndClose()"
[disabled]="saving()" [disabled]="saving()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" class="bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
{{ i18n.t('common.save') }} {{ i18n.t('common.save') }}
</button> </button>
@@ -1,22 +1,25 @@
<!-- session strip --> <!-- session strip -->
<div class="relative flex items-center gap-1.5 border-b border-border px-2 py-1"> <div
class="relative flex items-center gap-1.5 border-b border-border bg-toolbar px-2 py-1"
[style.box-shadow]="activeMarker()"
>
<button <button
(click)="toggleMenu()" (click)="toggleMenu()"
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent" class="flex min-w-0 items-center gap-1.5 px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent"
[title]="i18n.t('panel.choosePanel')" [title]="i18n.t('panel.choosePanel')"
> >
@if (panel().connecting()) { @if (panel().connecting()) {
<ng-icon name="mynaSpinner" class="shrink-0 animate-spin text-primary" size="var(--action-icon)" /> <ng-icon name="tdesignLoading" class="shrink-0 animate-spin text-primary" size="var(--action-icon)" />
} @else { } @else {
<ng-icon <ng-icon
[name]="isRemote() ? 'mynaServers' : 'mynaHardDrive'" [name]="isRemote() ? 'tdesignServer' : 'tdesignHardDrive'"
[class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'" [class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
size="var(--action-icon)" size="var(--action-icon)"
/> />
} }
<span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span> <span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span>
<ng-icon <ng-icon
name="mynaChevronDown" name="tdesignChevronDown"
class="shrink-0 text-muted-foreground" class="shrink-0 text-muted-foreground"
size="calc(var(--action-icon) - 0.1rem)" size="calc(var(--action-icon) - 0.1rem)"
/> />
@@ -28,7 +31,7 @@
<button <button
(click)="copyToOther()" (click)="copyToOther()"
[title]="side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft')" [title]="side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-primary" class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-primary"
> >
<ng-icon [name]="copyIcon()" size="var(--action-icon)" /> <ng-icon [name]="copyIcon()" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.copy') }}</span> <span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.copy') }}</span>
@@ -36,64 +39,64 @@
<button <button
(click)="run('edit')" (click)="run('edit')"
[title]="i18n.t('panel.editFile')" [title]="i18n.t('panel.editFile')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground" class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaFileText" size="var(--action-icon)" /> <ng-icon name="tdesignFile1" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span> <span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span>
</button> </button>
<button <button
(click)="run('rename')" (click)="run('rename')"
[title]="i18n.t('panel.rename')" [title]="i18n.t('panel.rename')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground" class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaEdit" size="var(--action-icon)" /> <ng-icon name="tdesignEdit" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span> <span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span>
</button> </button>
<button <button
(click)="run('mkdir')" (click)="run('mkdir')"
[title]="i18n.t('panel.newFolder')" [title]="i18n.t('panel.newFolder')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaFolderPlus" size="var(--action-icon)" /> <ng-icon name="tdesignFolderAdd" size="var(--action-icon)" />
</button> </button>
<button <button
(click)="run('createFile')" (click)="run('createFile')"
[title]="i18n.t('panel.newFile')" [title]="i18n.t('panel.newFile')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaFilePlus" size="var(--action-icon)" /> <ng-icon name="tdesignFileAdd" size="var(--action-icon)" />
</button> </button>
@if (chmodAvailable()) { @if (chmodAvailable()) {
<button <button
(click)="run('chmod')" (click)="run('chmod')"
[title]="i18n.t('panel.permissions')" [title]="i18n.t('panel.permissions')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground" class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaLock" size="var(--action-icon)" /> <ng-icon name="tdesignLockOn" size="var(--action-icon)" />
</button> </button>
} }
<button <button
(click)="run('delete')" (click)="run('delete')"
[title]="i18n.t('panel.deleteHint')" [title]="i18n.t('panel.deleteHint')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-destructive" class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-destructive"
> >
<ng-icon name="mynaTrash" size="var(--action-icon)" /> <ng-icon name="tdesignDelete" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span> <span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span>
</button> </button>
@if (menuOpen()) { @if (menuOpen()) {
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div> <div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
<div <div
class="absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl" class="absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto border border-border bg-card py-1 text-card-foreground shadow-xl"
> >
<button <button
(click)="pickLocal()" (click)="pickLocal()"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent" class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
> >
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" /> <ng-icon name="tdesignHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1">{{ i18n.t('common.local') }}</span> <span class="flex-1">{{ i18n.t('common.local') }}</span>
@if (!isRemote()) { @if (!isRemote()) {
<ng-icon name="mynaCheck" size="0.85rem" class="shrink-0 text-primary" /> <ng-icon name="tdesignCheck" size="0.85rem" class="shrink-0 text-primary" />
} }
</button> </button>
@@ -103,7 +106,7 @@
(click)="pickDrive(drive)" (click)="pickDrive(drive)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent" class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent"
> >
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" /> <ng-icon name="tdesignHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 font-mono">{{ drive }}</span> <span class="flex-1 font-mono">{{ drive }}</span>
</button> </button>
} }
@@ -116,7 +119,7 @@
(click)="pickConnection(connection)" (click)="pickConnection(connection)"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent" class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
> >
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" /> <ng-icon name="tdesignServer" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ connection.name }}</span> <span class="flex-1 truncate">{{ connection.name }}</span>
</button> </button>
} }
@@ -129,7 +132,7 @@
(click)="pickConnection(connection)" (click)="pickConnection(connection)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent" class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent"
> >
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" /> <ng-icon name="tdesignServer" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ connection.name }}</span> <span class="flex-1 truncate">{{ connection.name }}</span>
</button> </button>
} }
@@ -147,7 +150,7 @@
[title]="sshHostTitle(host)" [title]="sshHostTitle(host)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent disabled:opacity-60 disabled:hover:bg-transparent" class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent disabled:opacity-60 disabled:hover:bg-transparent"
> >
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" /> <ng-icon name="tdesignServer" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ host.name }}</span> <span class="flex-1 truncate">{{ host.name }}</span>
</button> </button>
} }
@@ -158,14 +161,14 @@
</div> </div>
<!-- path bar --> <!-- path bar -->
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5"> <div class="flex items-center gap-1 border-b border-border bg-toolbar-2 px-2 py-1.5">
<button <button
(click)="panel().up()" (click)="panel().up()"
[disabled]="blocked()" [disabled]="blocked()"
[title]="i18n.t('panel.parentDir')" [title]="i18n.t('panel.parentDir')"
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent" class="grid size-(--path-ctl-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
> >
<ng-icon name="mynaArrowUp" size="1rem" /> <ng-icon name="tdesignArrowUp" size="1rem" />
</button> </button>
<input <input
[value]="pathDraft()" [value]="pathDraft()"
@@ -175,18 +178,18 @@
(keydown)="$event.stopPropagation()" (keydown)="$event.stopPropagation()"
[disabled]="blocked()" [disabled]="blocked()"
spellcheck="false" spellcheck="false"
class="h-(--path-ctl-h) min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50" class="h-(--path-ctl-h) min-w-0 flex-1 border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
/> />
<button <button
(click)="panel().refresh()" (click)="panel().refresh()"
[disabled]="blocked()" [disabled]="blocked()"
[title]="i18n.t('panel.refresh')" [title]="i18n.t('panel.refresh')"
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent" class="grid size-(--path-ctl-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
> >
@if (panel().loading()) { @if (panel().loading()) {
<ng-icon name="mynaSpinner" size="1rem" class="animate-spin" /> <ng-icon name="tdesignLoading" size="1rem" class="animate-spin" />
} @else { } @else {
<ng-icon name="mynaRefresh" size="1rem" /> <ng-icon name="tdesignRefresh" size="1rem" />
} }
</button> </button>
</div> </div>
@@ -195,7 +198,7 @@
<div <div
(contextmenu)="headerContextMenu($event)" (contextmenu)="headerContextMenu($event)"
[style.grid-template-columns]="gridTemplate()" [style.grid-template-columns]="gridTemplate()"
class="grid border-b border-border text-[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" (click)="panel().toggleSort('name')">
{{ i18n.t('columns.name') }} {{ sortIndicator('name') }} {{ i18n.t('columns.name') }} {{ sortIndicator('name') }}
@@ -215,7 +218,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="tdesignSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span> <span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
<span class="shrink-0 text-[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 +226,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="tdesignClose" size="0.8rem" />
</button> </button>
</div> </div>
} }
@@ -243,13 +246,13 @@
@if (panel().connecting()) { @if (panel().connecting()) {
<div class="panel-fade-in grid h-full place-items-center"> <div class="panel-fade-in grid h-full place-items-center">
<div class="flex flex-col items-center gap-3"> <div class="flex flex-col items-center gap-3">
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" /> <ng-icon name="tdesignLoading" size="1.4rem" class="animate-spin text-primary" />
<span class="text-xs text-muted-foreground"> <span class="text-xs text-muted-foreground">
{{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }} {{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }}
</span> </span>
<button <button
(click)="cancelConnect()" (click)="cancelConnect()"
class="rounded-md border border-border px-3 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" class="border border-border px-3 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
{{ i18n.t('common.cancel') }} {{ i18n.t('common.cancel') }}
</button> </button>
@@ -264,6 +267,7 @@
(contextmenu)="rowContextMenu($event, i)" (contextmenu)="rowContextMenu($event, i)"
[style.grid-template-columns]="gridTemplate()" [style.grid-template-columns]="gridTemplate()"
class="grid h-(--row-h) items-center text-(length:--list-font)" class="grid h-(--row-h) items-center text-(length:--list-font)"
[class.bg-row-alt]="i % 2 === 1 && i !== panel().cursor() && !isSelected(entry)"
[class.bg-cursor-tint]="i === panel().cursor() && !isSelected(entry)" [class.bg-cursor-tint]="i === panel().cursor() && !isSelected(entry)"
[class.bg-primary/15]="isSelected(entry)" [class.bg-primary/15]="isSelected(entry)"
[class.ring-1]="i === panel().cursor() && isActive()" [class.ring-1]="i === panel().cursor() && isActive()"
@@ -321,7 +325,7 @@
</div> </div>
@if (panel().slowLoad() && !panel().connecting()) { @if (panel().slowLoad() && !panel().connecting()) {
<div class="panel-fade-in absolute inset-0 z-10 grid place-items-center bg-background/60"> <div class="panel-fade-in absolute inset-0 z-10 grid place-items-center bg-background/60">
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" /> <ng-icon name="tdesignLoading" size="1.4rem" class="animate-spin text-primary" />
</div> </div>
} }
</div> </div>
@@ -55,11 +55,16 @@ export class FilePanel {
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
readonly panel = computed(() => this.panels.get(this.side())); readonly panel = computed(() => this.panels.get(this.side()));
readonly isActive = computed(() => this.panels.activeSide() === this.side()); readonly isActive = computed(() => this.panels.activeSide() === this.side());
/** The active panel's marker: an accent line along its top edge. Inset, so
* switching panels never shifts the layout. */
readonly activeMarker = computed(() =>
this.isActive() ? 'inset 0 0.125rem 0 var(--primary)' : null,
);
readonly isRemote = computed(() => this.panel().sessionId() !== 'local'); readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
/** Input is ignored while the panel is connecting or a listing is in flight. */ /** Input is ignored while the panel is connecting or a listing is in flight. */
readonly blocked = computed(() => this.panel().busy()); readonly blocked = computed(() => this.panel().busy());
/** The copy button points toward the other panel. */ /** The copy button points toward the other panel. */
readonly copyIcon = computed(() => (this.side() === 'left' ? 'mynaArrowRight' : 'mynaArrowLeft')); readonly copyIcon = computed(() => (this.side() === 'left' ? 'tdesignArrowRight' : 'tdesignArrowLeft'));
readonly pathDraft = signal(''); readonly pathDraft = signal('');
readonly menuOpen = signal(false); readonly menuOpen = signal(false);
@@ -177,11 +182,11 @@ export class FilePanel {
} }
iconFor(entry: FileEntry): string { iconFor(entry: FileEntry): string {
return fileGlyph(entry, this.settings.iconColors() === 'multi'); return fileGlyph(entry);
} }
iconClass(entry: FileEntry): string { iconClass(entry: FileEntry): string {
return fileIconClass(entry, this.settings.iconColors() === 'multi'); return fileIconClass(entry);
} }
isSelected(entry: FileEntry): boolean { isSelected(entry: FileEntry): boolean {
+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;
@@ -0,0 +1,284 @@
<div class="flex h-screen flex-col bg-background text-foreground">
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
elsewhere the native titlebar sits above and this is a compact strip -->
<header
class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
[class.h-10]="isMacChrome"
[class.pl-24]="isMacChrome"
[class.electrobun-webkit-app-region-drag]="isMacChrome"
[class.h-9]="!isMacChrome"
[class.pl-3]="!isMacChrome"
>
<ng-icon name="tdesignSetting" size="0.95rem" class="shrink-0 text-muted-foreground" />
<h1 class="text-sm font-semibold">{{ i18n.t('options.title') }}</h1>
</header>
<div class="flex min-h-0 flex-1">
<!-- section list -->
<nav class="flex w-44 shrink-0 flex-col gap-0.5 border-r border-border bg-chrome p-2">
@for (s of sections; track s) {
<button
(click)="section.set(s)"
class="px-2.5 py-1.5 text-left text-xs hover:bg-accent hover:text-foreground"
[class.bg-accent]="section() === s"
[class.font-medium]="section() === s"
[class.text-foreground]="section() === s"
[class.text-muted-foreground]="section() !== s"
>
{{ i18n.t('options.sections.' + s) }}
</button>
}
</nav>
<!-- the chosen section's rows -->
<main class="min-w-0 flex-1 overflow-y-auto px-6 py-5">
<h2 class="text-sm font-semibold">{{ i18n.t('options.sections.' + section()) }}</h2>
<div class="mt-4 flex max-w-md flex-col gap-3">
@switch (section()) {
@case ('appearance') {
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.language') }}</span>
<select
(change)="settings.setLocale($any($event.target).value)"
class="h-7 border border-input bg-background pl-1.5 text-[0.6875rem] outline-none focus:ring-2 focus:ring-ring"
>
<option value="system" [selected]="settings.locale() === 'system'">
{{ i18n.t('options.system') }}
</option>
@for (loc of localeOptions; track loc) {
<option [value]="loc" [selected]="settings.locale() === loc">
{{ localeLabels[loc] }}
</option>
}
</select>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.theme.label') }}</span>
<div class="flex border border-border p-0.5">
@for (theme of themes; track theme) {
<button
(click)="settings.setTheme(theme)"
class="px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.theme() === theme"
[class.text-foreground]="settings.theme() === theme"
[class.text-muted-foreground]="settings.theme() !== theme"
>
{{ i18n.t('options.theme.' + theme) }}
</button>
}
</div>
</div>
@if (settings.effectiveDark()) {
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.tone.label') }}</span>
<div class="flex border border-border p-0.5">
@for (tone of darkTones; track tone) {
<button
(click)="settings.setDarkTone(tone)"
class="px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.darkTone() === tone"
[class.text-foreground]="settings.darkTone() === tone"
[class.text-muted-foreground]="settings.darkTone() !== tone"
>
{{ i18n.t('options.tone.' + tone) }}
</button>
}
</div>
</div>
}
<!-- Interface scale: a continuous stepper, 50%..400% in 10-point
steps. One lever moves the whole UI (root font-size; everything
is rem), this window included. -->
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.interface.label') }}</span>
<div class="flex items-center border border-border p-0.5">
<button
(click)="settings.setUiScale(settings.uiScale() - 0.1)"
[disabled]="settings.uiScale() <= 0.5"
[title]="i18n.t('options.interface.smaller')"
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
−
</button>
<button
(click)="settings.setUiScale(1)"
[title]="i18n.t('options.interface.reset')"
class="w-11 px-1 py-0.5 text-center text-[0.6875rem] tabular-nums hover:bg-accent"
>
{{ scalePercent() }}
</button>
<button
(click)="settings.setUiScale(settings.uiScale() + 0.1)"
[disabled]="settings.uiScale() >= 4"
[title]="i18n.t('options.interface.larger')"
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
+
</button>
</div>
</div>
}
@case ('transfers') {
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.speedUnit.label') }}</span>
<div class="flex border border-border p-0.5">
@for (unit of speedUnits; track unit.value) {
<button
(click)="settings.setSpeedUnit(unit.value)"
class="px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.speedUnit() === unit.value"
[class.text-foreground]="settings.speedUnit() === unit.value"
[class.text-muted-foreground]="settings.speedUnit() !== unit.value"
>
{{ unit.label }}
</button>
}
</div>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.transfers.label') }}</span>
<div class="flex border border-border p-0.5">
@for (mode of transferModes; track mode) {
<button
(click)="settings.setTransferMode(mode)"
class="px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.transferMode() === mode"
[class.text-foreground]="settings.transferMode() === mode"
[class.text-muted-foreground]="settings.transferMode() !== mode"
>
{{ i18n.t('options.transfers.' + mode) }}
</button>
}
</div>
</div>
@if (settings.transferMode() === 'multi') {
<div class="flex items-center justify-between gap-2 pl-3">
<span class="text-xs text-muted-foreground">{{ i18n.t('options.atOnce') }}</span>
<div class="flex border border-border p-0.5">
@for (count of concurrencyChoices; track count) {
<button
(click)="settings.setTransferConcurrency(count)"
class="px-2 py-0.5 text-[0.6875rem] tabular-nums"
[class.bg-accent]="settings.transferConcurrency() === count"
[class.text-foreground]="settings.transferConcurrency() === count"
[class.text-muted-foreground]="settings.transferConcurrency() !== count"
>
{{ count }}
</button>
}
</div>
</div>
}
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.finishedTransfers.label') }}</span>
<div class="flex border border-border p-0.5">
<button
(click)="settings.setAutoClearCompleted(true)"
class="px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.autoClearCompleted()"
[class.text-foreground]="settings.autoClearCompleted()"
[class.text-muted-foreground]="!settings.autoClearCompleted()"
>
{{ i18n.t('options.finishedTransfers.autoClear') }}
</button>
<button
(click)="settings.setAutoClearCompleted(false)"
class="px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="!settings.autoClearCompleted()"
[class.text-foreground]="!settings.autoClearCompleted()"
[class.text-muted-foreground]="settings.autoClearCompleted()"
>
{{ i18n.t('options.finishedTransfers.keep') }}
</button>
</div>
</div>
}
@case ('editor') {
<div class="flex items-center justify-between gap-2">
<span class="text-xs">{{ i18n.t('options.editor.label') }}</span>
<div class="flex border border-border p-0.5">
<button
(click)="settings.setEditor('builtin')"
class="px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.editor() === 'builtin'"
[class.text-foreground]="settings.editor() === 'builtin'"
[class.text-muted-foreground]="settings.editor() !== 'builtin'"
>
{{ i18n.t('options.editor.builtin') }}
</button>
<button
(click)="settings.setEditor('custom')"
class="px-2 py-0.5 text-[0.6875rem]"
[class.bg-accent]="settings.editor() === 'custom'"
[class.text-foreground]="settings.editor() === 'custom'"
[class.text-muted-foreground]="settings.editor() !== 'custom'"
>
{{ i18n.t('options.editor.custom') }}
</button>
</div>
</div>
@if (settings.editor() === 'custom') {
<div class="flex items-center justify-between gap-2 pl-3">
<span class="min-w-0 truncate text-xs text-muted-foreground" [title]="settings.editorApp()">
{{ editorAppName() }}
</span>
<div class="flex shrink-0 items-center gap-1">
<button
(click)="pickEditorApp()"
class="border border-border px-2 py-0.5 text-[0.6875rem] hover:bg-accent"
>
{{ i18n.t('common.choose') }}
</button>
@if (settings.editorApp()) {
<button
(click)="settings.setEditorApp('')"
[title]="i18n.t('options.resetDefault')"
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="tdesignClose" size="0.7rem" />
</button>
}
</div>
</div>
}
}
@case ('fileList') {
<span class="text-xs">{{ i18n.t('options.columns') }}</span>
<div class="flex flex-col gap-2 pl-3">
@for (key of columnKeys; track key) {
<label class="flex items-center gap-2 text-xs">
<input
type="checkbox"
[checked]="settings.columns().includes(key)"
(change)="settings.toggleColumn(key)"
/>
{{ i18n.t('columns.' + key) }}
</label>
}
</div>
}
@case ('about') {
<!-- the old popover footer: opens the native About box -->
<button
(click)="showAbout()"
class="self-start border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ appName }} · {{ buildLabel }}
</button>
}
}
</div>
</main>
</div>
</div>
@@ -0,0 +1,90 @@
import { Component, computed, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { BRANDING } from '@shared/branding';
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
import { COLUMN_KEYS } from '@core/context-menu.service';
import { I18nService } from '@core/i18n.service';
import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { ShortcutsService } from '@core/shortcuts.service';
const SECTIONS = ['appearance', 'transfers', 'editor', 'fileList', 'about'] as const;
type Section = (typeof SECTIONS)[number];
const THEMES = ['system', 'light', 'dark'] as const;
const DARK_TONES = ['dark', 'darker'] as const;
const SPEED_UNITS = [
{ value: 'MBps', label: 'MB/s' },
{ value: 'Mbps', label: 'Mbps' },
] as const;
const TRANSFER_MODES = ['single', 'multi'] as const;
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
/**
* Root of the Settings window (APP_MODE 'settings'). Every control is a plain
* SettingsService setter: the save goes to bun, which rebroadcasts it, so the
* other windows follow live while this one stays open.
*/
@Component({
selector: 'app-root',
imports: [NgIcon],
templateUrl: './settings-window.html',
host: {
'(window:keydown)': 'onWindowKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)',
},
})
export class SettingsWindow {
readonly settings = inject(SettingsService);
readonly i18n = inject(I18nService);
private readonly rpc = inject(RpcService);
private readonly shortcuts = inject(ShortcutsService);
readonly isMacChrome = IS_MAC_CHROME;
readonly sections = SECTIONS;
readonly section = signal<Section>('appearance');
readonly localeOptions = SUPPORTED;
readonly localeLabels = LOCALE_LABELS;
readonly themes = THEMES;
readonly darkTones = DARK_TONES;
readonly speedUnits = SPEED_UNITS;
readonly transferModes = TRANSFER_MODES;
readonly concurrencyChoices = CONCURRENCY_CHOICES;
readonly columnKeys = COLUMN_KEYS;
readonly appName = BRANDING.appName;
readonly buildLabel = BRANDING.buildLabel;
/** "100%" for the stepper readout; the value itself lives in settings. */
readonly scalePercent = computed(() => `${Math.round(this.settings.uiScale() * 100)}%`);
/** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */
editorAppName(): string {
const path = this.settings.editorApp();
if (!path) return this.i18n.t('options.systemDefault');
const base = path.split('/').filter(Boolean).pop() ?? path;
return base.replace(/\.app$/i, '');
}
async pickEditorApp(): Promise<void> {
const res = await this.rpc.request.pickEditorApp({});
if (res.ok && res.path) this.settings.setEditorApp(res.path);
}
showAbout(): void {
void this.rpc.request.showAbout({});
}
/** Any-window shortcuts (interface scale); ⌘W arrives as a menu action bun-side. */
onWindowKeydown(event: KeyboardEvent): void {
const match = this.shortcuts.resolve(event, 'settings');
if (!match || this.shortcuts.isTypingTarget(event)) return;
event.preventDefault();
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
else if (match.id === 'scale-reset') this.settings.setUiScale(1);
}
blockContextMenu(event: Event): void {
blockNativeContextMenu(event);
}
}
-27
View File
@@ -1,27 +0,0 @@
/* ==========================================================================
shadlike skin — the modern, shadcn-style look
==========================================================================
Intentionally near-empty, and that is not an oversight.
This skin IS the shared foundation: the OKLCH token system, `@theme inline`,
the panel-chrome metrics and the accent axis all live in ui/src/styles.css
because both skins consume them, and everything else the modern look needs is
a Tailwind utility written inline in its templates. There is no third-party
framework to bridge and no cascade inversion to undo, so there is simply
nothing left for a skin-level stylesheet to say.
Compare app/skins/7like/7like.css, which is 630 lines: all of it exists to
import 7.css above `utilities`, re-point the app's semantic tokens at Win7
values, and hand individual properties back where 7.css's dialog metrics are
wrong for this app.
This file exists so the folder is self-contained and symmetrical with the
other skin — modern-only rules that should not leak into the win7 cascade
have a home here. Rules that BOTH skins need belong in ui/src/styles.css
instead; anything added here is dead weight under .win7.
Scoping note: 7like scopes itself with the `.win7` class that 7.scoped.css
requires. This skin is the default (no marker class), so any rule added here
must NOT be written so broadly that it also matches under .win7 — prefer
scoping to the component, not to bare elements. */
@@ -21,7 +21,7 @@ import { TransfersService } from '@core/transfers.service';
[title]="i18n.t('transfer.showDetails')" [title]="i18n.t('transfer.showDetails')"
> >
@if (transfers.currentJob(); as job) { @if (transfers.currentJob(); as job) {
<ng-icon name="mynaSpinner" size="0.8rem" class="shrink-0 animate-spin text-primary" /> <ng-icon name="tdesignLoading" size="0.8rem" class="shrink-0 animate-spin text-primary" />
<span class="truncate"> <span class="truncate">
{{ i18n.t('transfer.copying', { name: job.name, percent: percent(job), speed: speedSuffix(job) }) }} {{ i18n.t('transfer.copying', { name: job.name, percent: percent(job), speed: speedSuffix(job) }) }}
@if (transfers.queuedCount(); as queued) { @if (transfers.queuedCount(); as queued) {
@@ -29,12 +29,12 @@ import { TransfersService } from '@core/transfers.service';
} }
</span> </span>
} @else if (transfers.failedJobs().length; as failed) { } @else if (transfers.failedJobs().length; as failed) {
<ng-icon name="mynaXCircle" size="0.8rem" class="shrink-0 text-destructive" /> <ng-icon name="tdesignCloseCircle" size="0.8rem" class="shrink-0 text-destructive" />
<span class="text-destructive"> <span class="text-destructive">
{{ i18n.t('transfer.failed', { count: failed }) }} {{ i18n.t('transfer.failed', { count: failed }) }}
</span> </span>
} @else if (transfers.hasJobs()) { } @else if (transfers.hasJobs()) {
<ng-icon name="mynaCheckCircle" size="0.8rem" class="shrink-0 text-success" /> <ng-icon name="tdesignCheckCircle" size="0.8rem" class="shrink-0 text-success" />
<span>{{ i18n.t('transfer.finished') }}</span> <span>{{ i18n.t('transfer.finished') }}</span>
} @else { } @else {
<span>{{ i18n.t('transfer.ready') }}</span> <span>{{ i18n.t('transfer.ready') }}</span>
@@ -44,7 +44,7 @@ import { TransfersService } from '@core/transfers.service';
<!-- active panel info --> <!-- active panel info -->
<div class="flex shrink-0 items-center gap-1.5"> <div class="flex shrink-0 items-center gap-1.5">
<ng-icon <ng-icon
[name]="activePanel().sessionId() === 'local' ? 'mynaHardDrive' : 'mynaServers'" [name]="activePanel().sessionId() === 'local' ? 'tdesignHardDrive' : 'tdesignServer'"
size="0.8rem" size="0.8rem"
[class.text-primary]="activePanel().sessionId() !== 'local'" [class.text-primary]="activePanel().sessionId() !== 'local'"
/> />
+46 -396
View File
@@ -1,40 +1,12 @@
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core'; import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core';
import { NgIcon } from '@ng-icons/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 { ContextMenuService } from '@core/context-menu.service';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import { PanelsService, type Workspace } from '@core/panels.service'; import { PanelsService, type Workspace } from '@core/panels.service';
import { APP_SKIN, IS_MAC_CHROME, MOD_LABEL } from '@core/platform'; import { IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
import { RpcService } from '@core/rpc.service'; import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { UpdaterService } from '@core/updater.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({ @Component({
selector: 'app-tab-bar', selector: 'app-tab-bar',
imports: [NgIcon], imports: [NgIcon],
@@ -50,16 +22,19 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
> >
<div <div
#tabScroll #tabScroll
role="tablist"
class="flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" class="flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
> >
@for (ws of panels.workspaces(); track ws.id; let i = $index) { @for (ws of panels.workspaces(); track ws.id; let i = $index) {
<div <div
[attr.data-tab-id]="ws.id" [attr.data-tab-id]="ws.id"
class="group relative flex max-w-52 shrink-0 items-center rounded-md electrobun-webkit-app-region-no-drag" class="group relative flex max-w-52 shrink-0 items-center electrobun-webkit-app-region-no-drag"
[class.bg-accent]="ws.id === panels.activeWorkspaceId()" [class.bg-accent]="ws.id === panels.activeWorkspaceId()"
[class.opacity-50]="draggedId() === ws.id" [class.opacity-50]="draggedId() === ws.id"
[style.box-shadow]="dropIndicatorShadow(i)" [style.box-shadow]="dropIndicatorShadow(i)"
(contextmenu)="onTabContextMenu($event, ws, i)" (contextmenu)="onTabContextMenu($event, ws, i)"
(mousedown)="onTabMouseDown($event)"
(auxclick)="onAuxClick($event, ws.id)"
draggable="true" draggable="true"
(dragstart)="onDragStart($event, ws.id)" (dragstart)="onDragStart($event, ws.id)"
(dragover)="onDragOver($event, i)" (dragover)="onDragOver($event, i)"
@@ -67,6 +42,8 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
(dragend)="onDragEnd()" (dragend)="onDragEnd()"
> >
<button <button
role="tab"
[attr.aria-selected]="ws.id === panels.activeWorkspaceId()"
(click)="panels.setActiveWorkspace(ws.id)" (click)="panels.setActiveWorkspace(ws.id)"
class="flex min-w-0 items-center gap-1.5 py-1.5 pr-1 pl-2.5 text-xs" class="flex min-w-0 items-center gap-1.5 py-1.5 pr-1 pl-2.5 text-xs"
[class.text-foreground]="ws.id === panels.activeWorkspaceId()" [class.text-foreground]="ws.id === panels.activeWorkspaceId()"
@@ -74,7 +51,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
[title]="panels.workspaceName(ws)" [title]="panels.workspaceName(ws)"
> >
<ng-icon <ng-icon
[name]="panels.workspaceIsRemote(ws) ? 'mynaServers' : 'mynaHardDrive'" [name]="panels.workspaceIsRemote(ws) ? 'tdesignServer' : 'tdesignHardDrive'"
size="0.85rem" size="0.85rem"
class="shrink-0" class="shrink-0"
[class.text-primary]="panels.workspaceIsRemote(ws)" [class.text-primary]="panels.workspaceIsRemote(ws)"
@@ -84,16 +61,18 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@if (panels.workspaces().length > 1) { @if (panels.workspaces().length > 1) {
<button <button
(click)="close($event, ws.id)" (click)="close($event, ws.id)"
tabindex="-1"
aria-hidden="true"
[title]="i18n.t('tabBar.closeTab', { mod: modLabel })" [title]="i18n.t('tabBar.closeTab', { mod: modLabel })"
class="mr-1 grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground" class="mr-1 grid size-4 shrink-0 place-items-center text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground"
> >
<ng-icon name="mynaX" size="0.7rem" /> <ng-icon name="tdesignClose" size="0.7rem" />
</button> </button>
} @else { } @else {
<span class="mr-1 size-4 shrink-0"></span> <span class="mr-1 size-4 shrink-0"></span>
} }
@if (ws.id === panels.activeWorkspaceId()) { @if (ws.id === panels.activeWorkspaceId()) {
<div class="absolute inset-x-0 -bottom-px h-0.5 rounded-full bg-primary"></div> <div class="absolute inset-x-0 -bottom-px h-0.5 bg-primary"></div>
} }
</div> </div>
} }
@@ -101,9 +80,9 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<button <button
(click)="panels.newWorkspace()" (click)="panels.newWorkspace()"
[title]="i18n.t('tabBar.newTab', { mod: modLabel })" [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" class="ml-0.5 grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaPlus" size=".8rem" /> <ng-icon name="tdesignAdd" size=".8rem" />
</button> </button>
<div class="flex-1"></div> <div class="flex-1"></div>
@@ -115,7 +94,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
(click)="onUpdateClick()" (click)="onUpdateClick()"
[disabled]="updater.state() === 'downloading'" [disabled]="updater.state() === 'downloading'"
[title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })" [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" class="relative mr-1 grid h-6 place-items-center overflow-hidden px-2.5 text-[0.6875rem] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default"
> >
@if (updater.state() === 'downloading') { @if (updater.state() === 'downloading') {
<span <span
@@ -126,13 +105,13 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<span class="relative flex items-center gap-1.5"> <span class="relative flex items-center gap-1.5">
@switch (updater.state()) { @switch (updater.state()) {
@case ('available') { @case ('available') {
<ng-icon name="mynaDownload" size=".8rem" />{{ i18n.t('tabBar.update') }} <ng-icon name="tdesignDownload" size=".8rem" />{{ i18n.t('tabBar.update') }}
} }
@case ('downloading') { @case ('downloading') {
<ng-icon name="mynaSpinner" size=".8rem" class="animate-spin" />{{ i18n.t('tabBar.updating') }} {{ updater.progress() }}% <ng-icon name="tdesignLoading" size=".8rem" class="animate-spin" />{{ i18n.t('tabBar.updating') }} {{ updater.progress() }}%
} }
@case ('ready') { @case ('ready') {
<ng-icon name="mynaRefresh" size=".8rem" />{{ i18n.t('tabBar.restartToUpdate') }} <ng-icon name="tdesignRefresh" size=".8rem" />{{ i18n.t('tabBar.restartToUpdate') }}
} }
} }
</span> </span>
@@ -143,363 +122,31 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<button <button
(click)="openManager()" (click)="openManager()"
[title]="i18n.t('tabBar.manageConnections')" [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" class="grid h-6 px-2 py-1 place-items-center text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
> >
<!-- <ng-icon name="mynaCog" size="0.9rem" /> --> <!-- <ng-icon name="tdesignSetting" size="0.9rem" /> -->
{{ i18n.t('tabBar.connections') }} {{ i18n.t('tabBar.connections') }}
</button> </button>
<!-- global options --> <!-- settings (own native window) -->
<button <button
(click)="settingsOpen.set(!settingsOpen())" (click)="openSettings()"
[title]="i18n.t('tabBar.options')" [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" 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="mynaDots" size="1.5rem" /> <ng-icon name="tdesignSetting" size="0.9rem" />
</button> </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> </div>
`, `,
}) })
export class TabBar { export class TabBar {
readonly panels = inject(PanelsService); readonly panels = inject(PanelsService);
readonly settings = inject(SettingsService);
readonly i18n = inject(I18nService); readonly i18n = inject(I18nService);
readonly updater = inject(UpdaterService); readonly updater = inject(UpdaterService);
private readonly contextMenu = inject(ContextMenuService); private readonly contextMenu = inject(ContextMenuService);
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
readonly settingsOpen = signal(false);
readonly localeOptions = SUPPORTED;
readonly localeLabels = LOCALE_LABELS;
readonly isMacChrome = IS_MAC_CHROME; readonly isMacChrome = IS_MAC_CHROME;
readonly modLabel = MOD_LABEL; readonly modLabel = MOD_LABEL;
readonly appName = BRANDING.appName;
readonly buildLabel = BRANDING.buildLabel;
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 // drag & drop reordering
readonly draggedId = signal<number | null>(null); readonly draggedId = signal<number | null>(null);
@@ -522,6 +169,22 @@ export class TabBar {
this.panels.closeWorkspace(id); this.panels.closeWorkspace(id);
} }
/**
* Middle-click closes the tab, as in browsers. `auxclick` also fires for the
* right button, hence the button check; the last tab is refused by
* closeWorkspace itself.
*/
onAuxClick(event: MouseEvent, id: number): void {
if (event.button !== 1) return;
event.preventDefault();
this.close(event, id);
}
/** A middle press over the scrollable strip would start WebView2's autoscroll. */
onTabMouseDown(event: MouseEvent): void {
if (event.button === 1) event.preventDefault();
}
/** /**
* Right-click a tab. The menu targets THIS tab, not the active one — its * 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 * actions all carry the id — so right-clicking a background tab does not
@@ -540,18 +203,14 @@ export class TabBar {
); );
} }
/** "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 { openManager(): void {
void this.rpc.request.openConnectionManager({}); void this.rpc.request.openConnectionManager({});
} }
openSettings(): void {
void this.rpc.request.openSettings({});
}
/** Update pill: first click downloads, second click (when ready) applies + relaunches. */ /** Update pill: first click downloads, second click (when ready) applies + relaunches. */
onUpdateClick(): void { onUpdateClick(): void {
const state = this.updater.state(); const state = this.updater.state();
@@ -559,15 +218,6 @@ export class TabBar {
else if (state === 'ready') this.updater.apply(); 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). */ /** Insertion line at the drop position (inset shadow: no layout shift). */
dropIndicatorShadow(index: number): string | null { dropIndicatorShadow(index: number): string | null {
const target = this.dropTarget(); const target = this.dropTarget();
+5 -5
View File
@@ -12,7 +12,7 @@ import { ToastService } from '@core/toast.service';
<div <div
(mouseenter)="toast.pause(t.id)" (mouseenter)="toast.pause(t.id)"
(mouseleave)="toast.resume(t.id)" (mouseleave)="toast.resume(t.id)"
class="group pointer-events-auto relative flex items-start gap-2 overflow-hidden rounded-lg border border-border bg-card p-3 text-xs text-card-foreground shadow-lg" class="group pointer-events-auto relative flex items-start gap-2 overflow-hidden border border-border bg-card p-3 text-xs text-card-foreground shadow-lg"
> >
<ng-icon <ng-icon
[name]="toastIcon(t.kind)" [name]="toastIcon(t.kind)"
@@ -24,7 +24,7 @@ import { ToastService } from '@core/toast.service';
(click)="toast.dismiss(t.id)" (click)="toast.dismiss(t.id)"
class="shrink-0 text-muted-foreground hover:text-foreground" class="shrink-0 text-muted-foreground hover:text-foreground"
> >
<ng-icon name="mynaX" size="0.9rem" /> <ng-icon name="tdesignClose" size="0.9rem" />
</button> </button>
<!-- auto-dismiss countdown; freezes with the timer while hovered --> <!-- auto-dismiss countdown; freezes with the timer while hovered -->
<div <div
@@ -43,8 +43,8 @@ export class Toasts {
readonly toast = inject(ToastService); readonly toast = inject(ToastService);
toastIcon(kind: 'error' | 'info' | 'success'): string { toastIcon(kind: 'error' | 'info' | 'success'): string {
if (kind === 'error') return 'mynaXCircle'; if (kind === 'error') return 'tdesignCloseCircle';
if (kind === 'success') return 'mynaCheckCircle'; if (kind === 'success') return 'tdesignCheckCircle';
return 'mynaCheck'; return 'tdesignCheck';
} }
} }
@@ -6,9 +6,9 @@
<button <button
(click)="transfers.clearFinished()" (click)="transfers.clearFinished()"
[disabled]="!transfers.finishedJobs().length" [disabled]="!transfers.finishedJobs().length"
class="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent" class="flex items-center gap-1 px-1.5 py-0.5 hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
> >
<ng-icon name="mynaTrash" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }} <ng-icon name="tdesignDelete" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }}
</button> </button>
</div> </div>
<div class="max-h-40 overflow-y-auto border-t border-border bg-chrome"> <div class="max-h-40 overflow-y-auto border-t border-border bg-chrome">
@@ -19,9 +19,16 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="truncate text-[0.6875rem] text-muted-foreground">{{ progressLabel(job) }}</div> <div class="truncate text-[0.6875rem] text-muted-foreground">{{ progressLabel(job) }}</div>
@if (isActive(job)) { @if (isActive(job)) {
<div class="mt-0.5 h-1 overflow-hidden rounded-full bg-muted">
<div <div
class="h-full rounded-full bg-primary transition-[width] duration-100" role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
[attr.aria-valuenow]="jobPercent(job)"
[attr.aria-label]="job.name"
class="mt-0.5 h-1 overflow-hidden bg-muted"
>
<div
class="h-full bg-primary transition-[width] duration-100"
[style.width.%]="jobPercent(job)" [style.width.%]="jobPercent(job)"
></div> ></div>
</div> </div>
@@ -31,17 +38,17 @@
<button <button
(click)="transfers.cancel(job.id)" (click)="transfers.cancel(job.id)"
[title]="i18n.t('transfer.cancel')" [title]="i18n.t('transfer.cancel')"
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive" class="shrink-0 p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
> >
<ng-icon name="mynaX" size="0.9rem" /> <ng-icon name="tdesignClose" size="0.9rem" />
</button> </button>
} @else { } @else {
<button <button
(click)="transfers.dismiss(job.id)" (click)="transfers.dismiss(job.id)"
[title]="i18n.t('transfer.dismiss')" [title]="i18n.t('transfer.dismiss')"
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground" class="shrink-0 p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="mynaX" size="0.9rem" /> <ng-icon name="tdesignClose" size="0.9rem" />
</button> </button>
} }
</div> </div>
@@ -33,12 +33,12 @@ export class TransferBar {
stateIcon(job: TransferSnapshot): string { stateIcon(job: TransferSnapshot): string {
switch (job.state) { switch (job.state) {
case 'completed': case 'completed':
return 'mynaCheckCircle'; return 'tdesignCheckCircle';
case 'failed': case 'failed':
case 'cancelled': case 'cancelled':
return 'mynaXCircle'; return 'tdesignCloseCircle';
default: default:
return 'mynaSpinner'; return 'tdesignLoading';
} }
} }
+13 -6
View File
@@ -6,13 +6,20 @@
<base href="/" /> <base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" /> <link rel="icon" type="image/x-icon" href="favicon.ico" />
<!-- Pre-stylesheet paint: the window background must be right BEFORE
styles.css parses or new windows flash white/inverted. Only the
html background decision lives here — values must stay in sync with
the corresponding --background tokens in styles.css. The data-*
attributes are baked in by the bun preload (buildPreload in
src/bun/windows.ts) so forced themes are correct at first paint. -->
<style> <style>
/* The native window is transparent (chromeOptions), so the page itself html { background: oklch(0.995 0.0001 260); }
must not paint a background — the Aero frame in styles.css is the only html[data-theme='dark'] { background: oklch(0.195 0.009 260); }
opaque surface, and its radius/drop shadow need to fall outside it. html[data-theme='dark'][data-dark-tone='darker'] { background: oklch(0 0 0); }
Windows are created hidden and revealed on dom-ready, so there is no @media (prefers-color-scheme: dark) {
unthemed flash to guard against here any more. */ html:not([data-theme='light']) { background: oklch(0.195 0.009 260); }
html.win7, html.win7 body { background: transparent; } html:not([data-theme='light'])[data-dark-tone='darker'] { background: oklch(0 0 0); }
}
</style> </style>
</head> </head>
<body> <body>
+3 -15
View File
@@ -1,19 +1,7 @@
import { bootstrapApplication } from '@angular/platform-browser'; import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from '@core/app.config'; import { appConfig } from '@core/app.config';
import { APP_MODE, APP_SKIN } from '@core/platform'; import { APP_MODE } from '@core/platform';
import { SEVEN_ROOTS } from './app/skins/7like';
import { SHAD_ROOTS } from './app/skins/shadlike'; import { SHAD_ROOTS } from './app/skins/shadlike';
// One bundle, two skins x three window kinds. Both globals are baked into the // One bundle, four window kinds: the preload global picks the root component.
// preload (src/bun/windows.ts buildPreload) because both are needed before the bootstrapApplication(SHAD_ROOTS[APP_MODE], appConfig).catch((err) => console.error(err));
// first paint: settings arrive over an async RPC, far too late to choose a
// component tree without flashing the wrong UI.
//
// Every skin's barrel exports the same { main, manager, editor } shape, so this
// file never learns anything about either tree's internals. Both are imported
// statically and ship in one bundle — the inactive skin costs bundle size but
// no runtime work, which is the right trade for a local desktop app and keeps
// bootstrap synchronous.
const tree = APP_SKIN === 'win7' ? SEVEN_ROOTS : SHAD_ROOTS;
const root = APP_MODE === 'editor' ? tree.editor : APP_MODE === 'manager' ? tree.manager : tree.main;
bootstrapApplication(root, appConfig).catch((err) => console.error(err));
+214 -171
View File
@@ -1,59 +1,56 @@
/* Layer order — declared BEFORE the imports so it wins (a @layer statement is
one of the only two rule types allowed above @import). `skin` sorts after
`utilities`, so every 7.css rule outranks every Tailwind utility on the
elements it targets; that inversion is what lets the win7 skin restyle
~1900 inline utility classes without touching the templates. `skinfix` is
above both, and uses `revert-layer` to hand individual properties back to
`utilities` where 7.css's Win7-dialog metrics are wrong for this app.
Tailwind re-declares the first four names, a no-op once ordered here.
See the "Windows 7 skin" section at the bottom of this file. */
@layer theme, base, components, utilities, skin, skinfix;
@import "tailwindcss"; @import "tailwindcss";
/* The two skin stylesheets. Both ship in one bundle and are scoped by the
`.win7` class on <html>, exactly as when they lived in this file — only one
skin's rules can match at a time. They sit here because CSS requires every
@import to precede all other rules; layer assignment is fixed by the @layer
statement above, not by this order. */
@import "./app/skins/shadlike/shadlike.css";
@import "./app/skins/7like/7like.css";
/* shadcn-style theme tokens. Light stays the zinc family; the dark neutrals are a /* Theme tokens, adapted from Oxide's design system (docs.oxide.computer): a cool
warm gray family (hue ~107) anchored on #1a1a19 — see the dark blocks below. neutral scale at hue 260 with almost no chroma, one green accent, opaque
The app follows the OS light/dark preference automatically. strokes. Step names in the comments (n0…n1300, green-800, …) are Oxide's.
The app follows the OS light/dark preference unless a theme is forced.
Surface elevation ramp (both themes): --chrome (frame strips: tab/transfer/status Surface layers, each its own shade so the window reads as parts: --chrome
bars, window toolbars) sits BELOW --background (content panes), which sits below (frame strips: tab/transfer/status bars, window header/footer strips and
--card (popovers, dialogs), which sits below --accent (hover). */ sidebars), --toolbar (the top row of a file panel's header strip, and
secondary toolbars) stepping through --toolbar-2/-3 toward --background
(content: file lists, editor text, forms; --row-alt stripes every other file
row), and --card (popovers, dialogs). Dark climbs frame < list < header <
card; light steps greys out from near-white content: frame < header < list
= card. --accent is a translucent overlay, not a surface: hover/selected
states must read on every layer, and an opaque hover vanishes on a surface
of its own lightness. The design is square: there are no radius tokens. */
:root { :root {
/* Native UI (scrollbars, form controls) follows the OS unless a theme is forced. */ /* Native UI (form controls) follows the OS unless a theme is forced. */
color-scheme: light dark; color-scheme: light dark;
--background: oklch(1 0 0); --background: oklch(0.995 0.0001 260); /* n1300 */
--chrome: oklch(0.977 0.001 286.375); --chrome: oklch(0.925 0.0001 260); /* n1000 */
--foreground: oklch(0.141 0.005 285.823); --toolbar: oklch(0.95 0.0001 260); /* n1100 */
--card: oklch(1 0 0); --row-alt: oklch(0.975 0.0001 260); /* n1200 */
--card-foreground: oklch(0.141 0.005 285.823); --foreground: oklch(0.162 0.01 260); /* n0 */
--primary: oklch(0.21 0.006 285.885); --card: oklch(0.995 0.0001 260);
--primary-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.162 0.01 260);
--secondary: oklch(0.967 0.001 286.375); --primary: oklch(0.474 0.1481 173.7); /* green-500 */
--secondary-foreground: oklch(0.21 0.006 285.885); --primary-foreground: oklch(0.995 0.0001 260);
--muted: oklch(0.967 0.001 286.375); /* Oxide's surface-accent: the fill of a selected/highlighted item (green-1200) */
--muted-foreground: oklch(0.552 0.016 285.938); --primary-surface: oklch(0.922 0.0586 163.7);
--accent: oklch(0.967 0.001 286.375); --muted: oklch(0.95 0.0001 260); /* n1100 */
--accent-foreground: oklch(0.21 0.006 285.885); --muted-foreground: oklch(0.483 0.0041 260); /* n500 */
--destructive: oklch(0.577 0.245 27.325); --accent: oklch(0.162 0.01 260 / 5%);
--destructive-foreground: oklch(0.985 0 0); --destructive: oklch(0.474 0.1285 21.3); /* red-500 */
--success: oklch(0.627 0.17 149.2); --destructive-foreground: oklch(0.995 0.0001 260);
--border: oklch(0.92 0.004 286.32); --success: oklch(0.474 0.1481 173.7); /* green-500 */
--input: oklch(0.92 0.004 286.32); --border: oklch(0.9 0.0001 260); /* n900 */
--ring: oklch(0.705 0.015 286.067); --input: oklch(0.79 0.0011 260); /* n800 */
--radius: 0.5rem; --ring: oklch(0.563 0.1714 170.4); /* green-600 */
/* file-type icon colors ("Multi" mode) — fixed category hues, muted chroma */ --cursor-tint: color-mix(in oklab, var(--primary) 8%, transparent);
--icon-folder: oklch(0.58 0.13 250); /* The file panel's header strip steps toward the list, a third per row.
--icon-link: oklch(0.56 0.1 190); Declared once: the var()s resolve on :root, so dark and AMOLED (where both
--icon-image: oklch(0.58 0.13 295); ends are black) follow without their own lines. */
--icon-media: oklch(0.6 0.13 10); --toolbar-2: color-mix(in oklab, var(--toolbar) 66%, var(--background));
--icon-archive: oklch(0.62 0.13 60); --toolbar-3: color-mix(in oklab, var(--toolbar) 33%, var(--background));
--icon-code: oklch(0.56 0.12 155); /* file-type icon colors — fixed category hues from Oxide's scales (600 steps) */
--icon-folder: oklch(0.563 0.1139 278.7); /* blue-600 */
--icon-link: oklch(0.67 0.1216 275.3); /* blue-700 */
--icon-image: oklch(0.563 0.1328 312.1); /* purple-600 */
--icon-media: oklch(0.563 0.1487 18); /* red-600 */
--icon-archive: oklch(0.563 0.125 81.7); /* yellow-600 */
--icon-code: oklch(0.563 0.1714 170.4); /* green-600 */
} }
/* Dark tokens apply when the user forces dark, or follows a dark system theme. /* Dark tokens apply when the user forces dark, or follows a dark system theme.
@@ -64,110 +61,111 @@
:root[data-theme='dark'] { :root[data-theme='dark'] {
color-scheme: dark; color-scheme: dark;
--background: oklch(0.2178 0.002 107); --background: oklch(0.195 0.009 260); /* n50 */
--chrome: oklch(0.1953 0.002 107); --chrome: oklch(0.162 0.01 260); /* n0 */
--foreground: oklch(0.982 0.003 107); --toolbar: oklch(0.275 0.0065 260); /* between n200 and n300 */
--card: oklch(0.245 0.002 107); --row-alt: oklch(0.205 0.0085 260); /* between n50 and n100 */
--card-foreground: oklch(0.982 0.003 107); --foreground: oklch(0.9 0.0001 260); /* n900 */
--primary: oklch(0.92 0.004 107); /* above --toolbar, so a popover never reads darker than the strip it covers */
--primary-foreground: oklch(0.21 0.002 107); --card: oklch(0.29 0.0065 260);
--secondary: oklch(0.302 0.003 107); --card-foreground: oklch(0.9 0.0001 260);
--secondary-foreground: oklch(0.982 0.003 107); --primary: oklch(0.77 0.1919 163.7); /* green-800 */
--muted: oklch(0.302 0.003 107); --primary-foreground: oklch(0.162 0.01 260);
--muted-foreground: oklch(0.715 0.012 107); --primary-surface: oklch(0.24 0.0722 183.7); /* green-200 */
--accent: oklch(0.302 0.003 107); --muted: oklch(0.316 0.006 260); /* n300 */
--accent-foreground: oklch(0.982 0.003 107); --muted-foreground: oklch(0.717 0.0021 260); /* n700 */
--destructive: oklch(0.704 0.191 22.216); --accent: oklch(1 0 0 / 7%);
--destructive-foreground: oklch(0.982 0.003 107); --destructive: oklch(0.712 0.185 11.3); /* red-800 */
--success: oklch(0.696 0.17 149.2); --destructive-foreground: oklch(0.162 0.01 260);
--border: oklch(1 0 0 / 14%); --success: oklch(0.77 0.1919 163.7); /* green-800 */
--input: oklch(1 0 0 / 18%); --border: oklch(0.316 0.006 260); /* n300 */
--ring: oklch(0.552 0.01 107); --input: oklch(0.389 0.005 260); /* n400 */
--icon-folder: oklch(0.75 0.11 250); --ring: oklch(0.563 0.1714 170.4); /* green-600 */
--icon-link: oklch(0.74 0.09 190); --cursor-tint: color-mix(in oklab, var(--primary) 12%, transparent);
--icon-image: oklch(0.76 0.11 295); --icon-folder: oklch(0.71 0.15 272); /* blue-800 */
--icon-media: oklch(0.76 0.11 10); --icon-link: oklch(0.67 0.1216 275.3); /* blue-700 */
--icon-archive: oklch(0.77 0.11 65); --icon-image: oklch(0.74 0.175 305.4); /* purple-800 */
--icon-code: oklch(0.75 0.1 155); --icon-media: oklch(0.712 0.185 11.3); /* red-800 */
--icon-archive: oklch(0.837 0.14 75); /* yellow-800 */
--icon-code: oklch(0.77 0.1919 163.7); /* green-800 */
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) { :root:not([data-theme='light']) {
--background: oklch(0.2178 0.002 107); --background: oklch(0.195 0.009 260);
--chrome: oklch(0.1953 0.002 107); --chrome: oklch(0.162 0.01 260);
--foreground: oklch(0.982 0.003 107); --toolbar: oklch(0.275 0.0065 260);
--card: oklch(0.245 0.002 107); --row-alt: oklch(0.205 0.0085 260);
--card-foreground: oklch(0.982 0.003 107); --foreground: oklch(0.9 0.0001 260);
--primary: oklch(0.92 0.004 107); --card: oklch(0.29 0.0065 260);
--primary-foreground: oklch(0.21 0.002 107); --card-foreground: oklch(0.9 0.0001 260);
--secondary: oklch(0.302 0.003 107); --primary: oklch(0.77 0.1919 163.7);
--secondary-foreground: oklch(0.982 0.003 107); --primary-foreground: oklch(0.162 0.01 260);
--muted: oklch(0.302 0.003 107); --primary-surface: oklch(0.24 0.0722 183.7);
--muted-foreground: oklch(0.715 0.012 107); --muted: oklch(0.316 0.006 260);
--accent: oklch(0.302 0.003 107); --muted-foreground: oklch(0.717 0.0021 260);
--accent-foreground: oklch(0.982 0.003 107); --accent: oklch(1 0 0 / 7%);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.712 0.185 11.3);
--destructive-foreground: oklch(0.982 0.003 107); --destructive-foreground: oklch(0.162 0.01 260);
--success: oklch(0.696 0.17 149.2); --success: oklch(0.77 0.1919 163.7);
--border: oklch(1 0 0 / 14%); --border: oklch(0.316 0.006 260);
--input: oklch(1 0 0 / 18%); --input: oklch(0.389 0.005 260);
--ring: oklch(0.552 0.01 107); --ring: oklch(0.563 0.1714 170.4);
--icon-folder: oklch(0.75 0.11 250); --cursor-tint: color-mix(in oklab, var(--primary) 12%, transparent);
--icon-link: oklch(0.74 0.09 190); --icon-folder: oklch(0.71 0.15 272);
--icon-image: oklch(0.76 0.11 295); --icon-link: oklch(0.67 0.1216 275.3);
--icon-media: oklch(0.76 0.11 10); --icon-image: oklch(0.74 0.175 305.4);
--icon-archive: oklch(0.77 0.11 65); --icon-media: oklch(0.712 0.185 11.3);
--icon-code: oklch(0.75 0.1 155); --icon-archive: oklch(0.837 0.14 75);
--icon-code: oklch(0.77 0.1919 163.7);
} }
} }
/* Dark tone "darker" (data-dark-tone set by SettingsService; absent = grey "dark"). /* Dark tone "darker" (data-dark-tone set by SettingsService; absent = grey "dark").
True-black AMOLED palette: --chrome collapses into --background on purpose, so True-black AMOLED palette: every surface layer (--chrome, --toolbar and the
the frame/content step of the default tone disappears and every large surface is steps derived from it, --background, --row-alt) collapses to #000 on purpose
#000 (OLED pixels actually off). Elevation survives only through --card and — OLED pixels actually off — so every NEW surface token needs a line here.
--accent; --border/--input are raised to 16%/18% because on pure black those Elevation survives only through --card, --muted and --accent (opaque here:
lines are the only thing defining panes, bars and rows. Duplicated for an overlay on black is too faint); the opaque strokes already read on black.
forced-dark and system-dark like the token blocks above — the extra attribute Duplicated for forced-dark and system-dark like the token blocks above — the
outranks them. */ extra attribute outranks them. */
:root[data-theme='dark'][data-dark-tone='darker'] { :root[data-theme='dark'][data-dark-tone='darker'] {
--background: oklch(0 0 0); --background: oklch(0 0 0);
--chrome: oklch(0 0 0); --chrome: oklch(0 0 0);
--card: oklch(0.21 0.002 107); --toolbar: oklch(0 0 0);
--secondary: oklch(0.274 0.003 107); --row-alt: oklch(0 0 0);
--muted: oklch(0.274 0.003 107); --card: oklch(0.216 0.008 260); /* n100 */
--muted-foreground: oklch(0.705 0.012 107); --muted: oklch(0.247 0.007 260); /* n200 */
--accent: oklch(0.274 0.003 107); --accent: oklch(0.247 0.007 260); /* n200 */
--border: oklch(1 0 0 / 16%);
--input: oklch(1 0 0 / 18%);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme='light'])[data-dark-tone='darker'] { :root:not([data-theme='light'])[data-dark-tone='darker'] {
--background: oklch(0 0 0); --background: oklch(0 0 0);
--chrome: oklch(0 0 0); --chrome: oklch(0 0 0);
--card: oklch(0.21 0.002 107); --toolbar: oklch(0 0 0);
--secondary: oklch(0.274 0.003 107); --row-alt: oklch(0 0 0);
--muted: oklch(0.274 0.003 107); --card: oklch(0.216 0.008 260);
--muted-foreground: oklch(0.705 0.012 107); --muted: oklch(0.247 0.007 260);
--accent: oklch(0.274 0.003 107); --accent: oklch(0.247 0.007 260);
--border: oklch(1 0 0 / 16%);
--input: oklch(1 0 0 / 18%);
} }
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-chrome: var(--chrome); --color-chrome: var(--chrome);
--color-toolbar: var(--toolbar);
--color-toolbar-2: var(--toolbar-2);
--color-toolbar-3: var(--toolbar-3);
--color-row-alt: var(--row-alt);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary); --color-primary-surface: var(--primary-surface);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success); --color-success: var(--success);
@@ -181,20 +179,13 @@
--color-icon-media: var(--icon-media); --color-icon-media: var(--icon-media);
--color-icon-archive: var(--icon-archive); --color-icon-archive: var(--icon-archive);
--color-icon-code: var(--icon-code); --color-icon-code: var(--icon-code);
/* rem subtrahends (4px/2px at the 16px root), NOT px: --radius is rem, and a
mixed-unit calc would stop the derived radii from following the interface
scale — corners would sharpen as the UI grew. */
--radius-sm: calc(var(--radius) - 0.25rem);
--radius-md: calc(var(--radius) - 0.125rem);
--radius-lg: var(--radius);
} }
/* Panel-chrome metrics: file list, path bar, action buttons, source picker. /* Panel-chrome metrics: file list, path bar, action buttons, source picker.
ALL REM, deliberately — the interface scale (settings.uiScale) works by ALL REM, deliberately — the interface scale (settings.uiScale) works by
setting the root font-size, so every rem value in the app scales from that setting the root font-size, so every rem value in the app scales from that
one lever. A px value here would freeze while everything around it grew, one lever. A px value here would freeze while everything around it grew.
which is exactly what happened to the win7 boxes before stage 1c. There is There is only one tier now: the old data-text compact/large tiers are gone, replaced
only one tier now: the old data-text compact/large tiers are gone, replaced
by the continuous scale. Values are the old default tier verbatim by the continuous scale. Values are the old default tier verbatim
(13px -> 0.8125rem etc. at the 16px root), so scale 1.0 is pixel-identical (13px -> 0.8125rem etc. at the 16px root), so scale 1.0 is pixel-identical
to the pre-scale app. */ to the pre-scale app. */
@@ -234,43 +225,6 @@
animation-play-state: paused; animation-play-state: paused;
} }
/* Accent tone (data-accent set by SettingsService; absent = graphite/zinc).
Each tone is one hue+chroma line; the formula blocks below derive --primary,
--ring and the cursor-row tint for light and both dark states. The dark
formula blocks (0,3,0) outrank the base dark token blocks and the light
formula block (0,2,0) in every dark state; graphite matches no [data-accent]
selector and keeps the zinc tokens untouched. */
:root {
/* graphite: cursor row identical to the old bg-accent highlight */
--cursor-tint: var(--accent);
}
:root[data-accent='blue'] { --tone-h: 255; --tone-c: 0.17; }
:root[data-accent='indigo'] { --tone-h: 277; --tone-c: 0.19; }
:root[data-accent='purple'] { --tone-h: 305; --tone-c: 0.17; }
:root[data-accent='rose'] { --tone-h: 8; --tone-c: 0.17; }
/* darkest orange that still reads orange; white button text lands ≈ 4.3:1 */
:root[data-accent='orange'] { --tone-h: 50; --tone-c: 0.18; --tone-l: 0.58; }
:root[data-accent='green'] { --tone-h: 150; --tone-c: 0.15; }
:root[data-accent='teal'] { --tone-h: 185; --tone-c: 0.12; }
:root[data-accent] {
--primary: oklch(var(--tone-l, 0.55) var(--tone-c) var(--tone-h));
--ring: oklch(0.62 calc(var(--tone-c) * 0.55) var(--tone-h));
--cursor-tint: color-mix(in oklab, var(--primary) 8%, transparent);
}
:root[data-theme='dark'][data-accent] {
--primary: oklch(0.72 calc(var(--tone-c) * 0.85) var(--tone-h));
--ring: oklch(0.62 calc(var(--tone-c) * 0.55) var(--tone-h));
--cursor-tint: color-mix(in oklab, var(--primary) 12%, transparent);
}
@media (prefers-color-scheme: dark) {
:root[data-accent]:not([data-theme='light']) {
--primary: oklch(0.72 calc(var(--tone-c) * 0.85) var(--tone-h));
--ring: oklch(0.62 calc(var(--tone-c) * 0.55) var(--tone-h));
--cursor-tint: color-mix(in oklab, var(--primary) 12%, transparent);
}
}
html, html,
body { body {
height: 100%; height: 100%;
@@ -282,3 +236,92 @@ body {
cursor: default; cursor: default;
} }
/* Native controls, restyled square to match the rest. WebKit draws checkboxes,
radios and selects natively and ignores border-radius/background until
appearance: none. In @layer base so utilities (size-3.5, pl-1 …) still win —
which is also why the selects use pl-*, never px-*: a px utility would
override the padding-right the caret needs. */
@layer base {
input[type='checkbox'],
input[type='radio'] {
appearance: none;
display: inline-grid;
place-content: center;
flex-shrink: 0;
width: 0.875rem;
height: 0.875rem;
margin: 0;
border: 1px solid var(--input);
background-color: var(--background);
}
input[type='checkbox']:checked,
input[type='radio']:checked {
border-color: var(--primary);
background-color: var(--primary);
}
/* the check: an L of two borders, rotated */
input[type='checkbox']:checked::after {
content: '';
width: 0.5rem;
height: 0.28rem;
border: solid var(--primary-foreground);
border-width: 0 0 0.125rem 0.125rem;
transform: translateY(-0.06rem) rotate(-45deg);
}
/* radios are square too; a square dot tells them apart from checkboxes */
input[type='radio']:checked::after {
content: '';
width: 0.375rem;
height: 0.375rem;
background-color: var(--primary-foreground);
}
input[type='checkbox']:focus-visible,
input[type='radio']:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 1px;
}
input[type='checkbox']:disabled,
input[type='radio']:disabled {
opacity: 0.5;
}
/* No native arrow: a caret drawn from two currentColor triangles follows the
theme (a data-URI svg can't). The opened option list stays native — macOS
draws that popup itself. */
select {
appearance: none;
padding-right: 1.5rem;
background-image:
linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position:
calc(100% - 0.8rem) 55%,
calc(100% - 0.5rem) 55%;
background-size: 0.3rem 0.3rem;
background-repeat: no-repeat;
}
/* number inputs (the manager's port field): no native spin buttons */
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
appearance: none;
margin: 0;
}
/* Square scrollbars. Styling them replaces macOS's auto-hiding overlay
scrollbars with always-visible ones — the inherent cost. */
::-webkit-scrollbar {
width: 0.625rem;
height: 0.625rem;
}
::-webkit-scrollbar-track,
::-webkit-scrollbar-corner {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--border);
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--input);
}
}
+9 -3
View File
@@ -8,14 +8,20 @@
UI layer (services, platform, formatters). @core is an ALIAS rather than a UI layer (services, platform, formatters). @core is an ALIAS rather than a
relative path on purpose: skin components live at varying depths under relative path on purpose: skin components live at varying depths under
app/skins/<skin>/, and an alias keeps their imports identical wherever a app/skins/<skin>/, and an alias keeps their imports identical wherever a
component is moved to. */ component is moved to.
electrobun/view -> the SDK Hutch projects into .hutch/devkit (the npm
`electrobun` package only exports a throwing stub). Exact target from the
devkit's export map; `bunx electrobun prepare` must have run. */
"paths": { "paths": {
"@shared/*": ["../src/shared/*"], "@shared/*": ["../src/shared/*"],
"@core/*": ["./src/app/core/*"] "@core/*": ["./src/app/core/*"],
"electrobun/view": ["../.hutch/devkit/api/browser/index.ts"]
}, },
"noImplicitOverride": true, "noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true, "noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true, /* Off because the devkit's browser SDK is raw TS compiled inside this
program, and 2.0.2's transport send() returns on only one path. */
"noImplicitReturns": false,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"skipLibCheck": true, "skipLibCheck": true,
"isolatedModules": true, "isolatedModules": true,