Merge main into feat/7css-skin + give the HTML context menu an Aero skin

main retired electrobun's native context menu (a documented no-op on Linux) in
favour of an HTML one on every platform. Merging that here needs the menu to
look right under the Win7 skin as well.

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-15 14:59:02 +02:00
co-authored by Claude
23 changed files with 737 additions and 214 deletions
+42 -4
View File
@@ -15,7 +15,8 @@ remote server over SSH/SFTP, so you can move files **local↔remote**,
Dense reference material lives in [docs/](docs/): [architecture](docs/architecture.md) Dense reference material lives in [docs/](docs/): [architecture](docs/architecture.md)
(processes, windows, RPC, every module/service), [build & release](docs/build-and-release.md) (processes, windows, RPC, every module/service), [build & release](docs/build-and-release.md)
(Build-n versioning, per-target release pipeline, CI), [platform notes](docs/platform-notes.md) (Build-n versioning, per-target release pipeline, CI), [platform notes](docs/platform-notes.md)
(macOS/Windows matrix + smoke tests), and [gotchas](docs/gotchas.md) (known landmines). (macOS/Windows/Linux matrix, runtime deps + smoke tests), and
[gotchas](docs/gotchas.md) (known landmines).
## Features ## Features
@@ -75,6 +76,11 @@ ui/src/app/ Angular components + signal services
- [Bun](https://bun.sh) ≥ 1.3 (`bun --version`). - [Bun](https://bun.sh) ≥ 1.3 (`bun --version`).
- macOS: **Xcode Command Line Tools** (for the app build). - macOS: **Xcode Command Line Tools** (for the app build).
- Linux: **AppIndicator** — the app links it for the tray and will not start
without it (`ERR_DLOPEN_FAILED`). `libayatana-appindicator` (Arch),
`libayatana-appindicator3-1` (Debian/Ubuntu),
`libayatana-appindicator-gtk3` (Fedora), plus system `webkit2gtk`.
Details and diagnostics in [platform notes](docs/platform-notes.md).
- No Node install required — a preload shim (`scripts/node-version-shim.ts`) - No Node install required — a preload shim (`scripts/node-version-shim.ts`)
satisfies the Angular CLI's Node version check while running under Bun. satisfies the Angular CLI's Node version check while running under Bun.
@@ -107,11 +113,14 @@ cross-compile between OSes:
bun run release -- --target mac-arm64 # on the Mac (native) bun run release -- --target mac-arm64 # on the Mac (native)
bun run release -- --target mac-x64 # on the same arm64 Mac, via Rosetta 2 bun run release -- --target mac-x64 # on the same arm64 Mac, via Rosetta 2
bun run release -- --target win-x64 # on a Windows 11 x64 machine bun run release -- --target win-x64 # on a Windows 11 x64 machine
bun run release -- --target linux-x64 # on a Linux x64 machine
``` ```
Artifacts land in `release/<target>/miniSCP-Build<n>-<target>.*` — a `.dmg` Artifacts land in `release/<target>/miniSCP-Build<n>-<target>.*` — a `.dmg`
per mac arch; on Windows a `-Setup.zip` installer **and** a `-portable.zip` per mac arch; on Windows a `-Setup.zip` installer **and** a `-portable.zip`
(unzip anywhere and run). The mac-x64 build fetches electrobun's x64 CLI once (unzip anywhere and run); on Linux a portable `.tar.gz` (unpack and run
`miniSCP.sh`, which sets the NVIDIA workaround then execs `bin/launcher`).
The mac-x64 build fetches electrobun's x64 CLI once
into `~/.cache/miniscp/` and runs it under Rosetta so the whole pipeline into `~/.cache/miniscp/` and runs it under Rosetta so the whole pipeline
believes it is on an Intel Mac. CI: `.gitea/workflows/release.yml` runs the believes it is on an Intel Mac. CI: `.gitea/workflows/release.yml` runs the
same script on a macos + windows runner matrix once act_runners are same script on a macos + windows runner matrix once act_runners are
@@ -210,6 +219,12 @@ shell-integration wording, drive listing, and the connections-file ACL. The
application menu ([src/bun/menu.ts](src/bun/menu.ts)) is macOS-only for now; application menu ([src/bun/menu.ts](src/bun/menu.ts)) is macOS-only for now;
the in-app UI covers every menu action. the in-app UI covers every menu action.
Right-click menus are **HTML, on every platform**
([ui/src/app/context-menu.ts](ui/src/app/context-menu.ts)) rather than native —
electrobun's native context menu is a documented no-op on Linux, and one menu
implementation beats two that drift. Only reveal / open-with-default / copy-path
cross to the backend, through the `shellItemAction` RPC.
## Windows (beta) ## Windows (beta)
Windows 11+ x64 is supported as a **beta** target (win-arm64 deferred). All Windows 11+ x64 is supported as a **beta** target (win-arm64 deferred). All
@@ -231,6 +246,29 @@ drive root ('..' disappears); local↔remote transfers; external editor launch
renders colored; Permissions absent on local panels; Ctrl+labels in tooltips; renders colored; Permissions absent on local panels; Ctrl+labels in tooltips;
portable zip runs from any folder. portable zip runs from any folder.
## Linux (beta)
Linux x64 is supported as a **beta** target and builds a portable tarball.
One hard requirement: the AppIndicator library listed under
[Prerequisites](#prerequisites) — electrobun links it for the tray, so without
it the app aborts at startup with `ERR_DLOPEN_FAILED` rather than merely losing
its tray icon.
- **No native menus at all** upstream (both the app menubar and context menus
are no-ops). The menubar was already macOS-only and the context menus are now
HTML, so nothing is missing in practice.
- **Tray visibility** depends on the desktop: KDE, XFCE, MATE and Cinnamon show
it; GNOME 3.26+ needs the AppIndicator extension. The app works either way.
- **Unsigned and unpackaged**: no AppImage, `.deb`, desktop entry or icon
installation — unpack the tarball and run `miniSCP.sh`.
- **NVIDIA GPUs** need `WEBKIT_DISABLE_DMABUF_RENDERER=1` or the window renders
black. Every launch path (dev scripts and the packaged `miniSCP.sh` wrapper)
sets it automatically when an NVIDIA driver is detected — see
[gotchas](docs/gotchas.md#linux) if you add a new way to launch the app.
Full dependency table, diagnostics and the first-boot smoke test:
[platform notes](docs/platform-notes.md).
## Known issues ## Known issues
- **Rare native crash under load.** A long-running session (multiple remote - **Rare native crash under load.** A long-running session (multiple remote
@@ -246,5 +284,5 @@ portable zip runs from any folder.
Drag-and-drop, file preview/edit, transfer resume, symlink following Drag-and-drop, file preview/edit, transfer resume, symlink following
(symlinks are listed but skipped by transfers), OS keychain, tab persistence (symlinks are listed but skipped by transfers), OS keychain, tab persistence
across restarts, Windows/Linux packaging (the code is portable; only macOS across restarts, Linux desktop integration (AppImage/.deb/.desktop entry —
packaging is wired up). the release is a portable tarball).
+35 -11
View File
@@ -10,7 +10,8 @@ Dense reference for development and debugging. Companion files:
its native launcher. Owns every privileged capability: filesystem, SSH, its native launcher. Owns every privileged capability: filesystem, SSH,
window management, native menus/dialogs/tray, the connection/settings stores. window management, native menus/dialogs/tray, the connection/settings stores.
- **Webviews** (`ui/` Angular): one per window, system WebKit on macOS / - **Webviews** (`ui/` Angular): one per window, system WebKit on macOS /
WebView2 on Windows. Zero privileged access — everything goes through RPC. WebView2 on Windows / WebKitGTK on Linux. Zero privileged access — everything
goes through RPC.
- **RPC transport**: one WebSocket server in the bun process, multiplexed by - **RPC transport**: one WebSocket server in the bun process, multiplexed by
`webviewId` (each webview auto-connects using natively injected globals). `webviewId` (each webview auto-connects using natively injected globals).
Requests are typed by the `AppRPC` schema in `src/shared/rpc.ts`. Requests are typed by the `AppRPC` schema in `src/shared/rpc.ts`.
@@ -148,10 +149,6 @@ would float over editor windows).
- `store/connections.ts` — plaintext-passwords JSON (deliberate v1 choice), - `store/connections.ts` — plaintext-passwords JSON (deliberate v1 choice),
atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
spread-merge on load, so NEW SETTINGS KEYS need no backend change. spread-merge on load, so NEW SETTINGS KEYS need no backend change.
- `context-menu.ts` — native row/empty-space menus + the column picker.
Local shell actions (reveal/open/copy-path) run bun-side via Utils; others
forward through `menuAction` and the UI applies them to the row it
remembered (`FileActionsService.menuTarget`). Labels are per-platform.
- `menu.ts` — macOS-only app menu, rebuilt on every connections change (the - `menu.ts` — macOS-only app menu, rebuilt on every connections change (the
Connect submenu mirrors the store). ⌘W is intercepted by the menu, so bun Connect submenu mirrors the store). ⌘W is intercepted by the menu, so bun
routes `close-tab` to the focused editor window first. Labels go through routes `close-tab` to the focused editor window first. Labels go through
@@ -161,7 +158,8 @@ would float over editor windows).
locale (from `settings.locale`, `system` via `Intl…resolvedOptions().locale`); locale (from `settings.locale`, `system` via `Intl…resolvedOptions().locale`);
`t(key, params)` shares the same `src/shared/i18n` dictionaries as the UI. `t(key, params)` shares the same `src/shared/i18n` dictionaries as the UI.
Wired in index.ts on boot and on `settingsChanged` (relocalizes + rebuilds Wired in index.ts on boot and on `settingsChanged` (relocalizes + rebuilds
the app menu). `context-menu.ts` labels resolve through `t()` at show-time. the app menu). The context menu no longer needs this — it is Angular and reads
`I18nService` directly.
- `tray.ts` — static icon, click = activate main. macOS template image; - `tray.ts` — static icon, click = activate main. macOS template image;
Windows colored icon. No `setImage` calls ever (they lose the template Windows colored icon. No `setImage` calls ever (they lose the template
treatment) and no menu (clicks arrive directly). treatment) and no menu (clicks arrive directly).
@@ -220,12 +218,24 @@ would float over editor windows).
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 right-click menus,
in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was
retired on ALL platforms rather than kept as a second code path (see
platform-notes.md). The service builds the item list (row / empty-space menu,
and the header column picker) and holds the open request as a signal; the
component renders it at the pointer with edge-flip clamping, arrow/Enter/Escape
keys, and close-on-blur/resize/scroll. Picked items are emitted verbatim as
`ctx-*` / `col:*` strings through `RpcService.emitMenuAction`, so they land in
the SAME `App.handleMenuAction` dispatcher the macOS app menubar uses — one
handler, two sources. The three shell actions the webview cannot perform
(reveal, open-with-default, copy path) go back to bun via the `shellItemAction`
RPC, which is all that remains of the old native menu.
- `file-icons.ts` — extension → category map driving glyphs, colors - `file-icons.ts` — extension → category map driving glyphs, colors
(fixed `--icon-*` tokens, never accent-tinted) and the Kind column. (fixed `--icon-*` tokens, never accent-tinted) and the Kind column.
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`, - `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
`EDITOR_CONTEXT`, `IS_MAC_CHROME`, `IS_WINDOWS`, `MOD_LABEL`/`ALT_LABEL`) `EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`,
+ `blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside `MOD_LABEL`/`ALT_LABEL`) + `blockNativeContextMenu` (kills WebKit's
text fields). Reload/Inspect menu outside text fields — the app draws its own menu instead).
- `updater.service.ts` — auto-updater state for the tab-bar pill. Signals - `updater.service.ts` — auto-updater state for the tab-bar pill. Signals
`state` (`idle→available→downloading→ready`|`error`), `version`, `progress`. `state` (`idle→available→downloading→ready`|`error`), `version`, `progress`.
Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on
@@ -298,7 +308,18 @@ still scales and 7.css's `pt` shorthand stops resetting weight/line-height.
(+aria) on the transfer bars, `has-scrollbar` on scroll containers (7.css only (+aria) on the transfer bars, `has-scrollbar` on scroll containers (7.css only
styles `::-webkit-scrollbar` under that class), `w7-toolbar` on the three styles `::-webkit-scrollbar` under that class), `w7-toolbar` on the three
header rows (their buttons go flat — Explorer's command bar was flat, unlike header rows (their buttons go flat — Explorer's command bar was flat, unlike
dialog push buttons), `w7-btn` on the 10 dialog action buttons. dialog push buttons), `w7-btn` on the 10 dialog action buttons, `w7-menu` (+
`w7-menu-sep`) on the HTML context menu.
**Context menu**: 7.css ships no menu component, so the Aero menu is
hand-authored across both layers — the row `<button>`s lose their push-button
slab in `skin` (needs `revert-layer`), and the slab/border/selection-band paint
is stated in `skinfix`. It is built ONLY from existing `--w7-*` vars (never
literal hex), so the hand-written dark Win7 palette recolours it for free —
the same constraint the dark block documents for itself. The hover and
keyboard-cursor highlights are deliberately given the same band: the component
marks the keyboard cursor with `bg-accent`, which is overridden alongside
`:hover` so the two are indistinguishable, as in a real Win7 menu.
**Font**: Segoe UI is absent off Windows and not redistributable. Noto Sans — **Font**: Segoe UI is absent off Windows and not redistributable. Noto Sans —
the substitute 7.css itself names — is vendored (OFL-1.1, from the substitute 7.css itself names — is vendored (OFL-1.1, from
@@ -309,7 +330,10 @@ so esbuild emits it hashed into `media/` and rewrites the `url()`.
place either skin's frame is decided, spread by both the main window and place either skin's frame is decided, spread by both the main window and
`openSecondaryWindow`. `win7` uses `titleBarStyle: "hidden"` on **every** `openSecondaryWindow`. `win7` uses `titleBarStyle: "hidden"` on **every**
platform — that clears `Titled` from the Cocoa style mask, which is also the platform — that clears `Titled` from the Cocoa style mask, which is also the
only way to remove the macOS traffic lights. The webview then draws the whole 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/title-bar/title-bar.ts` (7.css's `.title-bar` > frame via `ui/src/app/title-bar/title-bar.ts` (7.css's `.title-bar` >
`.title-bar-text` + `.title-bar-controls`, buttons keyed by `aria-label`), `.title-bar-text` + `.title-bar-controls`, buttons keyed by `aria-label`),
mounted by all three window roots. Because `titleBarStyle` is fixed at mounted by all three window roots. Because `titleBarStyle` is fixed at
+14 -3
View File
@@ -25,7 +25,8 @@ semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel`
## Release builds ## Release builds
`bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64]` (default: host). `bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64|linux-x64]`
(default: host).
Steps: gen-version → gen-icons → Angular build → `electrobun build Steps: gen-version → gen-icons → Angular build → `electrobun build
--env=stable` → copy/rename artifacts into --env=stable` → copy/rename artifacts into
`release/<target>/miniSCP-Build<n>-<target>.*`. `release/<target>/miniSCP-Build<n>-<target>.*`.
@@ -37,7 +38,8 @@ Facts the script encodes:
- **No OS cross-compile.** The CLI builds for its host (its `targets` config - **No OS cross-compile.** The CLI builds for its host (its `targets` config
field is unread in 1.18.1); packaging needs host tools (codesign/hdiutil on field is unread in 1.18.1); packaging needs host tools (codesign/hdiutil on
macOS, rcedit/PowerShell on Windows). Hence one script run per artifact, on macOS, rcedit/PowerShell on Windows). Hence one script run per artifact, on
the matching machine; `win-x64` on a non-Windows host errors out. the matching machine; `win-x64` on a non-Windows host and `linux-x64` on a
non-Linux host both error out.
- **mac-x64 from the arm64 Mac (Rosetta)**: the script downloads electrobun's - **mac-x64 from the arm64 Mac (Rosetta)**: the script downloads electrobun's
x64 CLI binary once (`electrobun-cli-darwin-x64.tar.gz` from the GitHub x64 CLI binary once (`electrobun-cli-darwin-x64.tar.gz` from the GitHub
release matching the installed version, cached at release matching the installed version, cached at
@@ -56,7 +58,16 @@ Facts the script encodes:
(self-extracting installer → `%LOCALAPPDATA%`) plus the script's own (self-extracting installer → `%LOCALAPPDATA%`) plus the script's own
**portable zip** made from the assembled app folder under **portable zip** made from the assembled app folder under
`build/stable-win-x64/` (layout to confirm on the first real Windows build — `build/stable-win-x64/` (layout to confirm on the first real Windows build —
the step warns instead of failing). the step warns instead of failing); Linux → only
`stable-linux-x64-miniSCP.tar.zst` + `update.json`, i.e. the updater feed and
nothing installable, so the script's **`.tar.gz` of the assembled app folder**
under `build/stable-linux-x64/` IS the release artifact (same best-effort
warn). No AppImage/.deb/.desktop — unpack and run the `miniSCP.sh` wrapper the
script writes beside `bin/launcher` (it applies the NVIDIA/DMABUF workaround,
which the app cannot set for itself — see gotchas.md).
- **Linux builds need the AppIndicator dev libraries present to RUN** (not to
build) — see platform-notes.md; a packaged tarball started on a machine
without them dies at `dlopen`.
- The script filters artifacts by app name, so stale artifacts from older - The script filters artifacts by app name, so stale artifacts from older
brandings don't get collected. brandings don't get collected.
+44 -4
View File
@@ -63,7 +63,46 @@ touching the adjacent code.
- Tray: never call `setImage` after construction on macOS — the template - Tray: never call `setImage` after construction on macOS — the template
treatment is lost and the icon renders black on dark menu bars. treatment is lost and the icon renders black on dark menu bars.
- `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are - `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are
single-character only. ContextMenu exists on macOS+Windows, not Linux. single-character only.
- **`ContextMenu.showContextMenu` is a silent no-op on Linux** (electrobun's own
`dist/api/bun/proc/linux.md` says so; the app also prints "Application menus are
not supported on Linux" at boot). Because the UI suppresses the webview's own
right-click menu, that left EVERY panel context menu dead on Linux — no error,
nothing happens. The menu is plain HTML now (`ui/src/app/context-menu.ts`) on
every platform; don't reintroduce the native one for "nativeness" on mac/win
without keeping a Linux path.
## Linux
- **`ERR_DLOPEN_FAILED` on `bun run start`** means a missing system library, not
broken app code. `libNativeWrapper.so` links `libayatana-appindicator3.so.1`
*hard* (for the tray), so the app cannot start at all without it — even though
the tray is a minor feature. Diagnose with
`ldd build/dev-linux-x64/*/bin/libNativeWrapper.so | grep 'not found'`; the
`libasar.so => not found` line in that output is a red herring (it ships beside
the wrapper and resolves at load). Install list per distro is in
[platform-notes.md](platform-notes.md).
- **Black window on NVIDIA + `Failed to create GBM buffer of size WxH: Invalid
argument`** (usually with `X11 Error: GLXBadWindow (code 170)`). The app boots,
logs "miniSCP started", reports no error — and paints nothing. WebKitGTK's
DMABUF renderer can't allocate buffers on the NVIDIA proprietary driver;
`WEBKIT_DISABLE_DMABUF_RENDERER=1` is the upstream workaround.
- **The app cannot apply this to itself.** WebKit reads the variable when it
forks its web process, and **Bun's `process.env` writes do not propagate to
child processes** (verified: a spawned child sees it unset). Setting it in
`src/bun/index.ts` — even in a module imported before `electrobun/bun` —
does nothing. It must be in the environment before the app process starts.
- Hence every launch path injects it: `scripts/linux-gpu.ts` (`launchEnv()`)
for `bun run dev` / `bun run start`, and the `miniSCP.sh` wrapper that
`release.ts` writes beside `bin/launcher` in the tarball. **A new launch
path must call `launchEnv()` or it will render black on NVIDIA.**
- Gated on `/proc/driver/nvidia` so Intel/AMD keep hardware compositing, and
an explicit `WEBKIT_DISABLE_DMABUF_RENDERER` from the user always wins.
- A "does it boot?" log check does NOT catch this — grep for the GBM line, or
look at the window.
- Benign startup noise on Linux — non-fatal, the app runs normally:
`libayatana-appindicator is deprecated. Please use libayatana-appindicator-glib`
(upstream electrobun's choice, not ours).
- **Window-open flash prevention is three-layer** — remove any one and the - **Window-open flash prevention is three-layer** — remove any one and the
white/inverted blink returns: (1) windows are created `hidden: true` and white/inverted blink returns: (1) windows are created `hidden: true` and
revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms revealed by `showWhenReady` on the webview's first `dom-ready` (800 ms
@@ -122,9 +161,10 @@ touching the adjacent code.
single-quoted entries** (`'…c'est…'`). Use double quotes for any value single-quoted entries** (`'…c'est…'`). Use double quotes for any value
containing an apostrophe. Plurals are `{ one, other }` maps (not ICU strings); containing an apostrophe. Plurals are `{ one, other }` maps (not ICU strings);
zh/ja supply only `other`. zh/ja supply only `other`.
- **Native menus can't use the Angular service** — `menu.ts`/`context-menu.ts` - **Native menus can't use the Angular service** — `menu.ts` goes through backend
go through backend `i18n.ts`, and the app menu must be rebuilt when the locale `i18n.ts`, and the app menu must be rebuilt when the locale changes (wired in
changes (wired in index.ts `settingsChanged`), or it keeps the old language. index.ts `settingsChanged`), or it keeps the old language. The context menu no
longer has this problem: it is Angular now and reads `I18nService` directly.
- `eu`/`zh`/`ja` are AI drafts pending native review (see the i18n README). - `eu`/`zh`/`ja` are AI drafts pending native review (see the i18n README).
- **Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched - **Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched
result.** A normal `refresh()` is masked by `busy` (input blocked), but the result.** A normal `refresh()` is masked by `busy` (input blocked), but the
+93 -10
View File
@@ -1,35 +1,41 @@
# Platform notes — macOS / Windows behavior matrix # Platform notes — macOS / Windows / Linux behavior matrix
macOS is the primary target; Windows 11+ x64 is a **beta** target (win-arm64 macOS is the primary target; Windows 11+ x64 and Linux x64 are **beta** targets
deferred; Linux untargeted — electrobun lacks menus/context menus there). (win-arm64 and linux-arm64 deferred). Every platform seam carries a
Every platform seam carries a `PORTABILITY:` comment at the branch site. `PORTABILITY:` comment at the branch site.
## Seam inventory (where platform branches live) ## Seam inventory (where platform branches live)
| Concern | File | mac | win | other | | Concern | File | mac | win | linux |
|---|---|---|---|---| |---|---|---|---|---|
| Window chrome | `src/bun/windows.ts`, `index.ts` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win | | Window chrome | `src/bun/windows.ts`, `index.ts` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
| External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` | | External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` |
| Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any | | Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any |
| Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` | | Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` |
| Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty | | Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty |
| chmod/Permissions | context-menu.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full | | chmod/Permissions | context-menu.service.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full |
| Shell wording | `context-menu.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager | | Shell wording | `ui context-menu.service.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
| Shortcut labels | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ | | Shortcut labels | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ |
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored | | Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored (needs AppIndicator, see below) |
| App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — About lives in the options popover | none | | App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — About lives in the options popover | none (unsupported upstream) |
| connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 | | connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 |
| App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config | | App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config |
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK | | ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" | | Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
The **context menu is HTML on every platform** (`ui/src/app/context-menu.ts` +
`context-menu.service.ts`), not a native electrobun menu — see the Linux section
below for why. Only the three shell actions it can't do itself (reveal, open with
the default app, copy path) cross to bun, via the `shellItemAction` RPC.
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
(`path.posix` throughout `fs/sftp.ts`). (`path.posix` throughout `fs/sftp.ts`).
## Memory footprint (expected, not a bug) ## Memory footprint (expected, not a bug)
Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB
Windows**, which is normal for a Bun + system-WebView app (30 MB is native / Windows** (Linux sits near macOS: WebKitGTK, like WKWebView, is lighter than
Chromium), which is normal for a Bun + system-WebView app (30 MB is native /
no-web-engine territory; Tauri-class apps run ~80–200 MB, Electron 150–400 MB). no-web-engine territory; Tauri-class apps run ~80–200 MB, Electron 150–400 MB).
Not reducible to tens of MB without dropping the web UI. Not reducible to tens of MB without dropping the web UI.
@@ -49,6 +55,83 @@ Not reducible to tens of MB without dropping the web UI.
each), and each open tab keeps both panels' full `entries` arrays; terminal each), and each open tab keeps both panels' full `entries` arrays; terminal
transfers are retained until cleared. None affect normal use. transfers are retained until cleared. None affect normal use.
## Linux runtime dependencies (REQUIRED — the app will not start without them)
electrobun's `libNativeWrapper.so` links AppIndicator *hard* for the tray, so a
missing library is a hard boot failure (`ERR_DLOPEN_FAILED` out of `bun:ffi`
`dlopen`), not a degraded tray. The transitive deps (`libdbusmenu-glib`,
`libdbusmenu-gtk3`, `libayatana-indicator3`, `libayatana-ido3`) come in as
package dependencies:
| Distro | Install |
|---|---|
| Arch / CachyOS | `sudo pacman -S libayatana-appindicator` |
| Debian / Ubuntu | `sudo apt install libayatana-appindicator3-1` |
| Fedora | `sudo dnf install libayatana-appindicator-gtk3` |
GTK/WebKit itself comes from the distro too — electrobun uses the system webview
(`bundleCEF: false`), so `webkit2gtk` must be present.
Diagnosing a boot failure:
```sh
ldd build/dev-linux-x64/*/bin/libNativeWrapper.so | grep 'not found'
```
`libasar.so => not found` in that output is **expected and harmless** — it ships
in the same `bin/` folder and resolves at load time; only an unresolved
`libayatana-appindicator3.so.1` is fatal.
**Tray visibility** is a separate matter from linking: GNOME 3.26+ hides the
legacy tray, so the icon needs the
[AppIndicator extension](https://extensions.gnome.org/extension/615/appindicator-support/).
KDE Plasma, XFCE, MATE and Cinnamon show it out of the box. The app runs fine
either way — the tray is a convenience, and every action it offers exists in-app.
## Linux known issues (upstream electrobun)
- **No native menus of any kind.** Application menus and `showContextMenu` are
both no-ops (electrobun's `dist/api/bun/proc/linux.md`; the app logs
"Application menus are not supported on Linux" at boot). The app menubar was
already macOS-only, and the panel context menus are now HTML on every platform,
so nothing is missing at the UI level — but do not add a feature that depends
on a native menu.
- **NVIDIA renders black without the DMABUF workaround.** WebKitGTK can't
allocate GBM buffers on the proprietary driver, so the window paints nothing
while the log looks perfectly healthy. Every launch path sets
`WEBKIT_DISABLE_DMABUF_RENDERER=1` when `/proc/driver/nvidia` exists —
`scripts/linux-gpu.ts` for dev, the `miniSCP.sh` wrapper in the tarball for
releases. The app can't do it itself (Bun's `process.env` writes don't reach
child processes); see [gotchas.md](gotchas.md#linux).
- **Benign startup noise**: a deprecation warning pointing at
`libayatana-appindicator-glib`. Not fatal.
- **Unsigned, unpackaged**: the release artifact is a portable tarball — no
AppImage, `.deb`, `.desktop` entry or icon installation. Unpack and run
`miniSCP.sh` (which execs `bin/launcher`).
## First-Linux-boot smoke test
1. Install the AppIndicator package for your distro (table above), then
`bun run start`. The window opens **and actually renders the UI** (a black
window on NVIDIA means the DMABUF workaround didn't apply — check for
`Failed to create GBM buffer` in the log), and the tray icon appears (or
doesn't, on bare GNOME — that's the extension caveat, not a failure).
2. Right-click a file row: menu appears at the pointer, Send to Other Panel is
first, Permissions is present, Escape / click-outside / arrow keys + Enter all
work, and a right-click near the bottom-right flips the menu instead of
overflowing.
3. Right-click empty panel space (New Folder / New File / Refresh / Show in File
Manager) and a column header (checkmarks toggle columns live).
4. Copy Path puts the path on the clipboard; Show in File Manager opens the
distro's file manager; Open (local file) launches the default app via
`xdg-open`.
5. local↔remote transfers both directions; external editor round-trip
(temp download → save → auto-upload) with the inotify watcher.
6. Move to Trash lands the item in the desktop's trash; connections.json is 0600.
7. `bun scripts/release.ts --target linux-x64` produces
`release/linux-x64/miniSCP-Build<N>-linux-x64.tar.gz`; unpack it elsewhere and
run `miniSCP.sh`.
## Windows known issues (upstream electrobun, documented not fixed) ## Windows known issues (upstream electrobun, documented not fixed)
- **HiDPI blur** — WebView2 renders at 1x on scaled displays - **HiDPI blur** — WebView2 renders at 1x on scaled displays
+1 -1
View File
@@ -11,7 +11,7 @@
"dev:ui": "bun run --cwd ui start", "dev:ui": "bun run --cwd ui start",
"build:ui": "bun run --cwd ui build", "build:ui": "bun run --cwd ui build",
"build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build", "build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build",
"start": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun dev", "start": "bun scripts/gen-version.ts && bun run build:ui && bun scripts/start.ts",
"postinstall": "bun scripts/gen-version.ts", "postinstall": "bun scripts/gen-version.ts",
"release": "bun scripts/release.ts" "release": "bun scripts/release.ts"
}, },
+3 -1
View File
@@ -1,5 +1,7 @@
// Dev orchestrator: starts ng serve (UI hot reload), waits for it, then runs // Dev orchestrator: starts ng serve (UI hot reload), waits for it, then runs
// `electrobun dev --watch` (relaunches the app when bun-side code changes). // `electrobun dev --watch` (relaunches the app when bun-side code changes).
import { launchEnv } from "./linux-gpu";
const DEV_SERVER_URL = "http://localhost:4200"; const DEV_SERVER_URL = "http://localhost:4200";
const ui = Bun.spawn(["bun", "run", "--cwd", "ui", "start"], { const ui = Bun.spawn(["bun", "run", "--cwd", "ui", "start"], {
@@ -27,7 +29,7 @@ console.log("Angular dev server is up, launching Electrobun ...");
const app = Bun.spawn(["bunx", "electrobun", "dev", "--watch"], { const app = Bun.spawn(["bunx", "electrobun", "dev", "--watch"], {
stdout: "inherit", stdout: "inherit",
stderr: "inherit", stderr: "inherit",
env: { ...process.env, DEV_SERVER_URL }, env: launchEnv({ DEV_SERVER_URL }),
}); });
function shutdown() { function shutdown() {
+33
View File
@@ -0,0 +1,33 @@
// PORTABILITY: WebKitGTK's DMABUF renderer cannot allocate GBM buffers on the
// NVIDIA proprietary driver — the app window paints BLACK and stderr fills with
// "Failed to create GBM buffer of size WxH: Invalid argument" (usually with an
// "X11 Error: GLXBadWindow" alongside). Disabling that renderer path is the
// standard upstream workaround.
//
// It has to be in the environment BEFORE the app process starts: WebKit reads
// it when it forks its web process, and Bun's `process.env` writes do NOT
// propagate to children (verified — a child sees the variable unset), so the
// app cannot set this for itself from inside src/bun/. Every launch path has to
// inject it: the dev scripts here, and the wrapper script that release.ts ships
// beside the packaged launcher.
//
// Scoped to NVIDIA so Intel/AMD keep hardware compositing, and an explicit
// value from the user's environment always wins.
import { existsSync } from "node:fs";
export function needsDmabufWorkaround(): boolean {
return (
process.platform === "linux" &&
process.env.WEBKIT_DISABLE_DMABUF_RENDERER === undefined &&
existsSync("/proc/driver/nvidia")
);
}
/** `process.env` plus the workaround, ready to hand to Bun.spawn. */
export function launchEnv(extra: Record<string, string> = {}): Record<string, string | undefined> {
return {
...process.env,
...(needsDmabufWorkaround() ? { WEBKIT_DISABLE_DMABUF_RENDERER: "1" } : {}),
...extra,
};
}
+47 -11
View File
@@ -1,21 +1,22 @@
// Build one release artifact for one target, on the matching host: // Build one release artifact for one target, on the matching host:
// bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64] // bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64|linux-x64]
// Default target = the host. electrobun cannot cross-compile OSes (its // Default target = the host. electrobun cannot cross-compile OSes (its
// packaging needs host tools: codesign/hdiutil on mac, rcedit/PowerShell on // packaging needs host tools: codesign/hdiutil on mac, rcedit/PowerShell on
// Windows), so win-x64 must run on a Windows machine. mac-x64 IS buildable on // Windows), so win-x64 must run on a Windows machine. mac-x64 IS buildable on
// an arm64 Mac: we fetch electrobun's x64 CLI binary and run it under // an arm64 Mac: we fetch electrobun's x64 CLI binary and run it under
// Rosetta 2 — it sees an x64 host, pulls the x64 core, and packages mac-x64. // Rosetta 2 — it sees an x64 host, pulls the x64 core, and packages mac-x64.
// Artifacts land in release/<target>/miniSCP-Build<N>-<target>.<ext>. // Artifacts land in release/<target>/miniSCP-Build<N>-<target>.<ext>.
import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs"; import { cpSync, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
const ROOT = path.join(import.meta.dir, ".."); const ROOT = path.join(import.meta.dir, "..");
const TARGETS = ["mac-arm64", "mac-x64", "win-x64"] as const; const TARGETS = ["mac-arm64", "mac-x64", "win-x64", "linux-x64"] as const;
type Target = (typeof TARGETS)[number]; type Target = (typeof TARGETS)[number];
function hostTarget(): Target { function hostTarget(): Target {
if (process.platform === "win32") return "win-x64"; if (process.platform === "win32") return "win-x64";
if (process.platform === "linux") return "linux-x64";
return process.arch === "arm64" ? "mac-arm64" : "mac-x64"; return process.arch === "arm64" ? "mac-arm64" : "mac-x64";
} }
@@ -43,6 +44,9 @@ if (!TARGETS.includes(requested)) fail(`unknown target "${requested}" (${TARGETS
if (requested === "win-x64" && process.platform !== "win32") { if (requested === "win-x64" && process.platform !== "win32") {
fail("win-x64 must be built on a Windows machine/runner — electrobun cannot cross-compile."); fail("win-x64 must be built on a Windows machine/runner — electrobun cannot cross-compile.");
} }
if (requested === "linux-x64" && process.platform !== "linux") {
fail("linux-x64 must be built on a Linux machine/runner — electrobun cannot cross-compile.");
}
if (requested.startsWith("mac") && process.platform !== "darwin") { if (requested.startsWith("mac") && process.platform !== "darwin") {
fail(`${requested} must be built on macOS.`); fail(`${requested} must be built on macOS.`);
} }
@@ -89,7 +93,12 @@ if (viaRosetta) {
// ---- collect artifacts ---- // ---- collect artifacts ----
const [osName, archName] = requested === "win-x64" ? ["win", "x64"] : ["macos", requested.slice(4)]; const [osName, archName] =
requested === "win-x64"
? ["win", "x64"]
: requested === "linux-x64"
? ["linux", "x64"]
: ["macos", requested.slice(4)];
const prefix = `stable-${osName}-${archName}-`; const prefix = `stable-${osName}-${archName}-`;
const artifactsDir = path.join(ROOT, "artifacts"); const artifactsDir = path.join(ROOT, "artifacts");
const outDir = path.join(ROOT, "release", requested); const outDir = path.join(ROOT, "release", requested);
@@ -109,15 +118,42 @@ for (const file of produced) {
out.push(name); out.push(name);
} }
// Windows: also produce a portable zip from the assembled app folder (the // Windows / Linux: package the assembled app folder directly. On Windows that
// Setup.exe is an installer; portable = unzip anywhere and run). Best-effort: // complements the Setup.exe installer (portable = unzip anywhere and run); on
// the folder layout is confirmed by the first real Windows build. // Linux it is the ONLY artifact, since electrobun emits just the .tar.zst
if (requested === "win-x64") { // updater blob there — no installer, AppImage or .deb. Best-effort: the folder
// layout is confirmed by the first real build on each OS.
if (requested === "win-x64" || requested === "linux-x64") {
const appDir = path.join(ROOT, "build", `stable-${osName}-${archName}`, BRANDING.appName); const appDir = path.join(ROOT, "build", `stable-${osName}-${archName}`, BRANDING.appName);
if (existsSync(appDir)) { if (existsSync(appDir)) {
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`); if (requested === "win-x64") {
run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]); const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
out.push(path.basename(portable)); run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]);
out.push(path.basename(portable));
} else {
// Ship a wrapper beside the launcher: the packaged app never goes
// through scripts/start.ts, and the NVIDIA/DMABUF workaround has to be
// in the environment BEFORE the process starts (see scripts/linux-gpu.ts).
const wrapper = path.join(appDir, `${BRANDING.appName}.sh`);
writeFileSync(
wrapper,
[
"#!/bin/sh",
"# WebKitGTK's DMABUF renderer cannot allocate GBM buffers on the NVIDIA",
"# proprietary driver — the window renders black. Disable it there only.",
'if [ -z "$WEBKIT_DISABLE_DMABUF_RENDERER" ] && [ -e /proc/driver/nvidia ]; then',
" export WEBKIT_DISABLE_DMABUF_RENDERER=1",
"fi",
'exec "$(dirname "$(readlink -f "$0")")/bin/launcher" "$@"',
"",
].join("\n"),
{ mode: 0o755 },
);
const tarball = path.join(outDir, `${BRANDING.appName}-${buildName}-linux-x64.tar.gz`);
// -C the parent so the archive unpacks into one named folder
run(["tar", "-czf", tarball, "-C", path.dirname(appDir), path.basename(appDir)]);
out.push(path.basename(tarball));
}
} else { } else {
console.warn(`[release] WARN: portable skipped — app folder not found at ${appDir}`); console.warn(`[release] WARN: portable skipped — app folder not found at ${appDir}`);
} }
+20
View File
@@ -0,0 +1,20 @@
// `bun run start`: launch the app against the prebuilt Angular bundle (no ng
// serve / hot reload — that's `bun run dev`). Exists as a script rather than a
// bare `bunx electrobun dev` in package.json so the Linux GPU workaround can be
// injected into the environment before the app process starts.
import { launchEnv } from "./linux-gpu";
const app = Bun.spawn(["bunx", "electrobun", "dev"], {
stdout: "inherit",
stderr: "inherit",
env: launchEnv(),
});
function shutdown(): void {
app.kill();
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
process.exit(await app.exited);
-128
View File
@@ -1,128 +0,0 @@
import { ContextMenu, Utils, type ApplicationMenuItemConfig } from "electrobun/bun";
import type { ColumnKey } from "../shared/types";
import { t } from "./i18n";
/** Optional list columns, in display order (Name is fixed). */
const COLUMN_KEYS: ColumnKey[] = ["size", "mtime", "kind", "rights", "owner", "group"];
// PORTABILITY: shell-integration wording per OS (resolved at show() for locale)
const IS_WIN = process.platform === "win32";
const revealLabel = (): string =>
t(
process.platform === "darwin"
? "ctx.revealFinder"
: IS_WIN
? "ctx.revealExplorer"
: "ctx.revealFileManager",
);
const trashLabel = (): string => t(IS_WIN ? "ctx.moveToRecycleBin" : "ctx.moveToTrash");
export interface RowMenuContext {
path: string;
name: string;
type: "file" | "dir" | "symlink";
isLocal: boolean;
hasEntry: boolean; // false = right-click on empty panel space
}
/**
* Native right-click menus for the file panels. Local-only items run directly
* here via Utils (reveal, open with default app, copy path); everything else
* forwards to the webview through the menuAction channel — the UI kept its own
* context (which panel/row was clicked) when it requested the menu.
*/
export class PanelContextMenu {
private context: RowMenuContext | null = null;
/** Forwards actions the UI must handle (ctx-open, ctx-delete, ...). */
onAction: (action: string) => void = () => {};
constructor() {
ContextMenu.on("context-menu-clicked", (event) => {
const action = (event as { data?: { action?: string } }).data?.action;
if (!action) return;
// column toggles carry no row context
if (action.startsWith("col:")) {
this.onAction(action);
return;
}
const ctx = this.context;
if (!ctx) return;
switch (action) {
case "ctx-reveal":
Utils.showItemInFolder(ctx.path);
break;
case "ctx-open-default":
Utils.openPath(ctx.path);
break;
case "ctx-copy-path":
Utils.clipboardWriteText(ctx.path);
break;
default:
if (action.startsWith("ctx-")) this.onAction(action);
}
});
}
show(context: RowMenuContext): void {
this.context = context;
ContextMenu.showContextMenu(this.build(context));
}
/** Column picker for the list headers: checkable toggles per column. */
showColumns(visible: ColumnKey[]): void {
ContextMenu.showContextMenu(
COLUMN_KEYS.map((key) => ({
label: t(`columns.${key}`),
action: `col:${key}`,
checked: visible.includes(key),
})),
);
}
private build(ctx: RowMenuContext): ApplicationMenuItemConfig[] {
const items: ApplicationMenuItemConfig[] = [];
if (!ctx.hasEntry || ctx.name === "..") {
// empty space (or the parent row): folder-level actions
items.push({ label: t("ctx.newFolder"), action: "ctx-new-folder" });
items.push({ label: t("ctx.newFile"), action: "ctx-new-file" });
items.push({ type: "separator" });
items.push({ label: t("ctx.refresh"), action: "ctx-refresh" });
if (ctx.isLocal) {
items.push({ type: "separator" });
items.push({ label: revealLabel(), action: "ctx-reveal" });
}
return items;
}
items.push({ label: t("ctx.copyToOther"), action: "ctx-copy" });
if (ctx.type === "dir") {
items.push({ label: t("ctx.open"), action: "ctx-open" });
} else {
if (ctx.isLocal) {
items.push({ label: t("ctx.open"), action: "ctx-open-default", tooltip: t("ctx.openDefault") });
}
items.push({ label: t("ctx.edit"), action: "ctx-edit", accelerator: "e" });
}
items.push({ label: t("ctx.rename"), action: "ctx-rename" });
// chmod on Windows local files only flips the read-only bit — hide the lie
if (!(ctx.isLocal && IS_WIN)) {
items.push({ label: t("ctx.permissions"), action: "ctx-chmod" });
}
items.push({ type: "separator" });
items.push({ label: t("ctx.copyPath"), action: "ctx-copy-path" });
if (ctx.isLocal) {
items.push({ label: revealLabel(), action: "ctx-reveal" });
}
items.push({ type: "separator" });
if (ctx.isLocal) {
items.push({
label: trashLabel(),
action: "ctx-trash",
...(process.platform === "darwin" ? { tooltip: t("ctx.trashTooltip") } : {}),
});
}
items.push({ label: t("ctx.delete"), action: "ctx-delete" });
return items;
}
}
-4
View File
@@ -2,7 +2,6 @@ import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "elec
import { BRANDING } from "../shared/branding"; import { BRANDING } from "../shared/branding";
import type { EditorOpenContext } from "../shared/rpc"; import type { EditorOpenContext } from "../shared/rpc";
import { effectiveConcurrency, type AppSettings } from "../shared/types"; import { effectiveConcurrency, type AppSettings } from "../shared/types";
import { PanelContextMenu } from "./context-menu";
import { EditManager } from "./edits"; import { EditManager } from "./edits";
import { EditorWindows } from "./editor-windows"; import { EditorWindows } from "./editor-windows";
import { SessionRegistry } from "./fs/registry"; import { SessionRegistry } from "./fs/registry";
@@ -46,7 +45,6 @@ const engine = new TransferEngine(registry);
const store = new ConnectionStore(); const store = new ConnectionStore();
const settings = new SettingsStore(); const settings = new SettingsStore();
const edits = new EditManager(registry, engine, settings); const edits = new EditManager(registry, engine, settings);
const contextMenu = new PanelContextMenu();
const watcher = new LocalDirWatcher(); const watcher = new LocalDirWatcher();
const events = { const events = {
sessionClosed: (_info: { sessionId: string; reason: string }) => {}, sessionClosed: (_info: { sessionId: string; reason: string }) => {},
@@ -65,7 +63,6 @@ const rpc = createAppRPC({
store, store,
settings, settings,
edits, edits,
contextMenu,
watcher, watcher,
mainWindow: () => mainWin, mainWindow: () => mainWin,
events, events,
@@ -158,7 +155,6 @@ win.on("close", () => {
}); });
new AppTray(win); new AppTray(win);
contextMenu.onAction = (action) => rpc.send.menuAction({ action });
// notify on finished transfers while the window is in the background // notify on finished transfers while the window is in the background
let windowFocused = true; let windowFocused = true;
+5 -12
View File
@@ -1,7 +1,6 @@
import { BrowserView, type BrowserWindow, Updater, Utils } from "electrobun/bun"; import { BrowserView, type BrowserWindow, Updater, Utils } from "electrobun/bun";
import type { AppRPC, EditorOpenContext } from "../shared/rpc"; import type { AppRPC, EditorOpenContext } from "../shared/rpc";
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types"; import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
import type { PanelContextMenu } from "./context-menu";
import { readFileText, writeFileText, type EditManager } from "./edits"; import { readFileText, writeFileText, type EditManager } from "./edits";
import { listLocalDrives } from "./fs/local"; import { listLocalDrives } from "./fs/local";
import type { SessionRegistry } from "./fs/registry"; import type { SessionRegistry } from "./fs/registry";
@@ -24,7 +23,6 @@ export interface AppDeps {
store: ConnectionStore; store: ConnectionStore;
settings: SettingsStore; settings: SettingsStore;
edits: EditManager; edits: EditManager;
contextMenu: PanelContextMenu;
watcher: LocalDirWatcher; watcher: LocalDirWatcher;
/** /**
* The main BrowserWindow, for the win7 skin's custom title-bar controls. * The main BrowserWindow, for the win7 skin's custom title-bar controls.
@@ -175,7 +173,6 @@ export function createAppRPC({
store, store,
settings, settings,
edits, edits,
contextMenu,
watcher, watcher,
mainWindow, mainWindow,
events, events,
@@ -339,10 +336,12 @@ export function createAppRPC({
return {}; return {};
}), }),
// ---- context menu + local shell integration ---- // ---- local shell integration (menu itself is HTML, see ui/) ----
showRowMenu: (params) => shellItemAction: ({ action, path }) =>
wrap(async () => { wrap(async () => {
contextMenu.show(params); if (action === "reveal") Utils.showItemInFolder(path);
else if (action === "open") Utils.openPath(path);
else Utils.clipboardWriteText(path);
return {}; return {};
}), }),
@@ -352,12 +351,6 @@ export function createAppRPC({
return {}; return {};
}), }),
showColumnsMenu: ({ visible }) =>
wrap(async () => {
contextMenu.showColumns(visible);
return {};
}),
// ---- editor windows ---- // ---- editor windows ----
openEditor: (ctx) => openEditor: (ctx) =>
wrap(async () => { wrap(async () => {
+9 -12
View File
@@ -1,6 +1,5 @@
import type { import type {
AppSettings, AppSettings,
ColumnKey,
ConnectionConfig, ConnectionConfig,
ConnectionsFile, ConnectionsFile,
EntryType, EntryType,
@@ -126,20 +125,18 @@ export type AppRPC = {
response: Result<{ names: string[] }>; response: Result<{ names: string[] }>;
}; };
clearEditLeftovers: { params: Record<string, never>; response: Result }; clearEditLeftovers: { params: Record<string, never>; response: Result };
// context menu + local shell integration // local shell integration
showRowMenu: { /**
params: { * Shell actions the context menu triggers on a LOCAL path. The menu
path: string; * itself is HTML (ui/src/app/context-menu.ts) — electrobun's native
name: string; * context menu is a no-op on Linux — but these three still need the
type: EntryType; * bun side's Utils.
isLocal: boolean; */
hasEntry: boolean; shellItemAction: {
}; params: { action: "reveal" | "open" | "copyPath"; path: string };
response: Result; response: Result;
}; };
moveToTrash: { params: { path: string }; response: Result }; moveToTrash: { params: { path: string }; response: Result };
/** Native column-picker menu (right-click on the list headers). */
showColumnsMenu: { params: { visible: ColumnKey[] }; response: Result };
/** Native About box (name, Build n, commit, engine versions). */ /** Native About box (name, Build n, commit, engine versions). */
showAbout: { params: Record<string, never>; response: Result }; showAbout: { params: Record<string, never>; response: Result };
/** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */ /** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */
+1
View File
@@ -55,5 +55,6 @@
<app-overwrite-dialog [request]="req" /> <app-overwrite-dialog [request]="req" />
} }
<app-context-menu />
<app-toasts /> <app-toasts />
</div> </div>
+14
View File
@@ -4,6 +4,7 @@ import { I18nService } from './i18n.service';
import { BRANDING } from '@shared/branding'; import { BRANDING } from '@shared/branding';
import type { ColumnKey } from '@shared/types'; import type { ColumnKey } from '@shared/types';
import { ConnectionsService } from './connections.service'; import { ConnectionsService } from './connections.service';
import { ContextMenu } from './context-menu';
import { ChmodDialog } from './dialogs/chmod-dialog'; import { ChmodDialog } from './dialogs/chmod-dialog';
import { ConfirmDialog } from './dialogs/confirm-dialog'; import { ConfirmDialog } from './dialogs/confirm-dialog';
import { OverwriteDialog } from './dialogs/overwrite-dialog'; import { OverwriteDialog } from './dialogs/overwrite-dialog';
@@ -38,6 +39,7 @@ import { UpdaterService } from './updater.service';
Toasts, Toasts,
TransferBar, TransferBar,
TitleBar, TitleBar,
ContextMenu,
], ],
templateUrl: './app.html', templateUrl: './app.html',
host: { host: {
@@ -194,10 +196,22 @@ export class App {
const target = this.actions.menuTarget; const target = this.actions.menuTarget;
if (!target) return; if (!target) return;
const { panel, side, entry } = target; const { panel, side, entry } = target;
// the empty-space menu acts on the directory itself
const path = entry?.path ?? panel.cwd();
switch (action) { switch (action) {
case 'ctx-open': case 'ctx-open':
if (entry) panel.open(entry); if (entry) panel.open(entry);
break; break;
// local shell integration — only bun can reach the OS shell/clipboard
case 'ctx-reveal':
void this.rpc.request.shellItemAction({ action: 'reveal', path });
break;
case 'ctx-open-default':
void this.rpc.request.shellItemAction({ action: 'open', path });
break;
case 'ctx-copy-path':
void this.rpc.request.shellItemAction({ action: 'copyPath', path });
break;
case 'ctx-edit': case 'ctx-edit':
void this.actions.edit(panel); void this.actions.edit(panel);
break; break;
+125
View File
@@ -0,0 +1,125 @@
import { Injectable, inject, signal } from '@angular/core';
import type { ColumnKey, EntryType } from '@shared/types';
import { I18nService } from './i18n.service';
import { IS_MAC, IS_WINDOWS } from './platform';
import { RpcService } from './rpc.service';
/** Optional list columns, in display order (Name is fixed). */
const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group'];
export interface MenuItem {
type?: 'separator';
label?: string;
/** Emitted verbatim to the action dispatcher (`ctx-*` / `col:*`). */
action?: string;
checked?: boolean;
tooltip?: string;
/** Shortcut hint, rendered right-aligned. */
accelerator?: string;
}
export interface MenuRequest {
x: number;
y: number;
items: MenuItem[];
}
export interface RowMenuContext {
path: string;
name: string;
type: EntryType;
isLocal: boolean;
hasEntry: boolean; // false = right-click on empty panel space
}
// PORTABILITY: shell-integration wording per OS (resolved at build time for locale)
const revealKey = (): string =>
IS_MAC ? 'ctx.revealFinder' : IS_WINDOWS ? 'ctx.revealExplorer' : 'ctx.revealFileManager';
const trashKey = (): string => (IS_WINDOWS ? 'ctx.moveToRecycleBin' : 'ctx.moveToTrash');
/**
* Builds and opens the panel right-click menus. This used to be a native
* electrobun ContextMenu on the bun side, but `showContextMenu` is a documented
* no-op on Linux (electrobun's own dist/api/bun/proc/linux.md), which left the
* whole right-click surface dead there. The menu is plain HTML now — one code
* path on every platform — and still emits the exact same action strings, so
* the dispatcher in app.ts is unchanged.
*/
@Injectable({ providedIn: 'root' })
export class ContextMenuService {
private readonly i18n = inject(I18nService);
private readonly rpc = inject(RpcService);
readonly request = signal<MenuRequest | null>(null);
open(event: MouseEvent, items: MenuItem[]): void {
this.request.set({ x: event.clientX, y: event.clientY, items });
}
close(): void {
this.request.set(null);
}
/** A picked item goes through the same dispatcher the app menubar uses. */
onPick(action: string): void {
this.rpc.emitMenuAction(action);
}
/** Row / empty-space menu for a file panel. */
rowItems(ctx: RowMenuContext): MenuItem[] {
const t = (key: string) => this.i18n.t(key);
const items: MenuItem[] = [];
if (!ctx.hasEntry || ctx.name === '..') {
// empty space (or the parent row): folder-level actions
items.push({ label: t('ctx.newFolder'), action: 'ctx-new-folder' });
items.push({ label: t('ctx.newFile'), action: 'ctx-new-file' });
items.push({ type: 'separator' });
items.push({ label: t('ctx.refresh'), action: 'ctx-refresh' });
if (ctx.isLocal) {
items.push({ type: 'separator' });
items.push({ label: t(revealKey()), action: 'ctx-reveal' });
}
return items;
}
items.push({ label: t('ctx.copyToOther'), action: 'ctx-copy' });
if (ctx.type === 'dir') {
items.push({ label: t('ctx.open'), action: 'ctx-open' });
} else {
if (ctx.isLocal) {
items.push({ label: t('ctx.open'), action: 'ctx-open-default', tooltip: t('ctx.openDefault') });
}
items.push({ label: t('ctx.edit'), action: 'ctx-edit', accelerator: 'F4' });
}
items.push({ label: t('ctx.rename'), action: 'ctx-rename' });
// chmod on Windows local files only flips the read-only bit — hide the lie
if (!(ctx.isLocal && IS_WINDOWS)) {
items.push({ label: t('ctx.permissions'), action: 'ctx-chmod' });
}
items.push({ type: 'separator' });
items.push({ label: t('ctx.copyPath'), action: 'ctx-copy-path' });
if (ctx.isLocal) {
items.push({ label: t(revealKey()), action: 'ctx-reveal' });
}
items.push({ type: 'separator' });
if (ctx.isLocal) {
items.push({
label: t(trashKey()),
action: 'ctx-trash',
...(IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}),
});
}
items.push({ label: t('ctx.delete'), action: 'ctx-delete' });
return items;
}
/** Column picker for the list headers: checkable toggles per column. */
columnItems(visible: ColumnKey[]): MenuItem[] {
return COLUMN_KEYS.map((key) => ({
label: this.i18n.t(`columns.${key}`),
action: `col:${key}`,
checked: visible.includes(key),
}));
}
}
+167
View File
@@ -0,0 +1,167 @@
import {
Component,
ElementRef,
computed,
effect,
inject,
signal,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { ContextMenuService, type MenuItem } from './context-menu.service';
import { FocusService } from './focus.service';
/** Rough per-item metrics, used only to decide whether the menu must flip. */
const ITEM_H = 26;
const SEPARATOR_H = 7;
const MENU_W = 224; // w-56
const EDGE_GAP = 6;
/**
* The panel right-click menu. Replaces electrobun's native ContextMenu, which
* is a no-op on Linux — see ContextMenuService for the why. Emits the same
* `ctx-*` / `col:*` action strings the native menu did.
*/
@Component({
selector: 'app-context-menu',
imports: [NgIcon],
template: `
@if (menu.request(); as req) {
<!-- click-outside catcher; mousedown so it beats the panel's own handlers -->
<div class="fixed inset-0 z-50" (mousedown)="close()" (contextmenu)="onBackdropContext($event)"></div>
<div
#menuEl
role="menu"
tabindex="-1"
(keydown)="onKeydown($event)"
[style.left.px]="position().x"
[style.top.px]="position().y"
class="w7-menu fixed z-50 w-56 rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl outline-none"
>
@for (item of req.items; track $index) {
@if (item.type === 'separator') {
<div class="w7-menu-sep my-1 border-t border-border"></div>
} @else {
<button
type="button"
role="menuitem"
[title]="item.tooltip ?? ''"
[class.bg-accent]="$index === activeIndex()"
(mouseenter)="activeIndex.set($index)"
(click)="pick(item)"
class="flex w-full items-center gap-2 px-3 py-1 text-left text-(length:--path-font) hover:bg-accent"
>
<span class="w-3.5 shrink-0">
@if (item.checked) {
<ng-icon name="mynaCheck" size="0.85rem" class="text-primary" />
}
</span>
<span class="flex-1 truncate">{{ item.label }}</span>
@if (item.accelerator) {
<span class="shrink-0 text-muted-foreground">{{ item.accelerator }}</span>
}
</button>
}
}
</div>
}
`,
// Closing on scroll/blur matches native menu behavior: the anchor row moves
// out from under a menu that would otherwise float over stale content.
host: {
'(window:blur)': 'close()',
'(window:resize)': 'close()',
'(document:scroll)': 'close()',
},
})
export class ContextMenu {
readonly menu = inject(ContextMenuService);
private readonly focus = inject(FocusService);
private readonly menuEl = viewChild<ElementRef<HTMLDivElement>>('menuEl');
readonly activeIndex = signal(-1);
/** Click point, flipped/clamped so the menu never overflows the viewport. */
readonly position = computed(() => {
const req = this.menu.request();
if (!req) return { x: 0, y: 0 };
const height =
req.items.reduce((sum, i) => sum + (i.type === 'separator' ? SEPARATOR_H : ITEM_H), 0) + 8;
const x =
req.x + MENU_W > window.innerWidth - EDGE_GAP
? Math.max(EDGE_GAP, req.x - MENU_W)
: req.x;
const y =
req.y + height > window.innerHeight - EDGE_GAP
? Math.max(EDGE_GAP, req.y - height)
: req.y;
return { x, y };
});
constructor() {
// Focus the menu once it exists so arrow keys / Escape reach it. Keyed on
// the viewChild signal, NOT on request(): under zoneless CD an effect can
// run before the @if has rendered the element.
effect(() => this.menuEl()?.nativeElement.focus());
}
close(): void {
if (!this.menu.request()) return;
this.menu.close();
this.activeIndex.set(-1); // a fresh menu starts with nothing highlighted
this.focus.bump();
}
pick(item: MenuItem): void {
if (!item.action) return;
const action = item.action;
this.close();
this.menu.onPick(action);
}
/** Right-clicking the backdrop closes rather than stacking a second menu. */
onBackdropContext(event: MouseEvent): void {
event.preventDefault();
this.close();
}
onKeydown(event: KeyboardEvent): void {
const items = this.menu.request()?.items ?? [];
switch (event.key) {
case 'Escape':
event.preventDefault();
event.stopPropagation();
this.close();
break;
case 'ArrowDown':
event.preventDefault();
this.step(items, 1);
break;
case 'ArrowUp':
event.preventDefault();
this.step(items, -1);
break;
case 'Enter':
case ' ': {
event.preventDefault();
const item = items[this.activeIndex()];
if (item) this.pick(item);
break;
}
}
}
/** Move the highlight by one selectable item, skipping separators, wrapping. */
private step(items: MenuItem[], delta: number): void {
const count = items.length;
if (!count) return;
let index = this.activeIndex();
for (let hops = 0; hops < count; hops++) {
index = (index + delta + count) % count;
if (items[index].type !== 'separator') {
this.activeIndex.set(index);
return;
}
}
}
}
+14 -9
View File
@@ -1,5 +1,6 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import type { FileEntry } from '@shared/types'; import type { FileEntry } from '@shared/types';
import { ContextMenuService } from './context-menu.service';
import { DialogsService } from './dialogs.service'; import { DialogsService } from './dialogs.service';
import { joinPath } from './format'; import { joinPath } from './format';
import { type PanelState, type Side } from './panels.service'; import { type PanelState, type Side } from './panels.service';
@@ -26,21 +27,25 @@ export class FileActionsService {
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService); private readonly toast = inject(ToastService);
private readonly dialogs = inject(DialogsService); private readonly dialogs = inject(DialogsService);
private readonly menu = inject(ContextMenuService);
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
menuTarget: MenuTarget | null = null; menuTarget: MenuTarget | null = null;
/** Right-click entry point: remember the target and ask bun for a native menu. */ /** Right-click entry point: remember the target, then open the menu on it. */
async openContextMenu(target: MenuTarget): Promise<void> { openContextMenu(event: MouseEvent, target: MenuTarget): void {
this.menuTarget = target; this.menuTarget = target;
const { panel, entry } = target; const { panel, entry } = target;
await this.rpc.request.showRowMenu({ this.menu.open(
path: entry?.path ?? panel.cwd(), event,
name: entry?.name ?? '', this.menu.rowItems({
type: entry?.type ?? 'dir', path: entry?.path ?? panel.cwd(),
isLocal: panel.sessionId() === 'local', name: entry?.name ?? '',
hasEntry: !!entry, type: entry?.type ?? 'dir',
}); isLocal: panel.sessionId() === 'local',
hasEntry: !!entry,
}),
);
} }
async trash(panel: PanelState, entry: FileEntry): Promise<void> { async trash(panel: PanelState, entry: FileEntry): Promise<void> {
+5 -3
View File
@@ -12,6 +12,7 @@ import { NgIcon } from '@ng-icons/core';
import { I18nService } from '../i18n.service'; import { I18nService } from '../i18n.service';
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types'; import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
import { ConnectionsService } from '../connections.service'; import { ConnectionsService } from '../connections.service';
import { ContextMenuService } from '../context-menu.service';
import { DialogsService } from '../dialogs.service'; import { DialogsService } from '../dialogs.service';
import { FileActionsService } from '../file-actions.service'; import { FileActionsService } from '../file-actions.service';
import { fileGlyph, fileIconClass, fileKindKey } from '../file-icons'; import { fileGlyph, fileIconClass, fileKindKey } from '../file-icons';
@@ -47,6 +48,7 @@ export class FilePanel {
readonly transfers = inject(TransfersService); readonly transfers = inject(TransfersService);
readonly connections = inject(ConnectionsService); readonly connections = inject(ConnectionsService);
readonly dialogs = inject(DialogsService); readonly dialogs = inject(DialogsService);
private readonly menu = inject(ContextMenuService);
private readonly settings = inject(SettingsService); private readonly settings = inject(SettingsService);
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
readonly panel = computed(() => this.panels.get(this.side())); readonly panel = computed(() => this.panels.get(this.side()));
@@ -88,7 +90,7 @@ export class FilePanel {
headerContextMenu(event: MouseEvent): void { headerContextMenu(event: MouseEvent): void {
event.preventDefault(); event.preventDefault();
void this.rpc.request.showColumnsMenu({ visible: this.settings.columns() }); this.menu.open(event, this.menu.columnItems(this.settings.columns()));
} }
pickDrive(drive: string): void { pickDrive(drive: string): void {
@@ -238,14 +240,14 @@ export class FilePanel {
// standard behavior: keep the selection when right-clicking inside it // standard behavior: keep the selection when right-clicking inside it
if (entry && !panel.selected().has(entry.name)) panel.clearSelection(); if (entry && !panel.selected().has(entry.name)) panel.clearSelection();
panel.cursor.set(index); panel.cursor.set(index);
void this.actions.openContextMenu({ side: this.side(), panel, entry }); this.actions.openContextMenu(event, { side: this.side(), panel, entry });
} }
listContextMenu(event: MouseEvent): void { listContextMenu(event: MouseEvent): void {
event.preventDefault(); event.preventDefault();
if (this.blocked()) return; if (this.blocked()) return;
this.activate(); this.activate();
void this.actions.openContextMenu({ side: this.side(), panel: this.panel(), entry: null }); this.actions.openContextMenu(event, { side: this.side(), panel: this.panel(), entry: null });
} }
openAt(index: number): void { openAt(index: number): void {
+3 -1
View File
@@ -18,7 +18,7 @@ declare global {
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : ''; export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
const IS_MAC = PLATFORM === 'darwin'; export const IS_MAC = PLATFORM === 'darwin';
/** /**
* The win7 skin makes the window frameless on EVERY platform and draws its own * The win7 skin makes the window frameless on EVERY platform and draws its own
@@ -40,6 +40,8 @@ export const IS_MAC_CHROME = IS_MAC && !IS_WIN7_CHROME;
export const IS_WINDOWS = PLATFORM === 'win32'; export const IS_WINDOWS = PLATFORM === 'win32';
export const IS_LINUX = PLATFORM === 'linux';
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. Keyed on the real /** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. Keyed on the real
* platform, NOT IS_MAC_CHROME — the keyboard does not change with the skin. */ * platform, NOT IS_MAC_CHROME — the keyboard does not change with the skin. */
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+'; export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
+9
View File
@@ -103,6 +103,15 @@ export class RpcService {
this.menuListeners.add(listener); this.menuListeners.add(listener);
} }
/**
* Dispatch an action that originated in the UI (the HTML context menu) as if
* it had arrived from bun, so app.ts keeps ONE handler for menu actions
* regardless of whether the app menubar or a right-click produced them.
*/
emitMenuAction(action: string): void {
for (const listener of this.menuListeners) listener(action);
}
onSettingsChanged(listener: (settings: AppSettings) => void): void { onSettingsChanged(listener: (settings: AppSettings) => void): void {
this.settingsListeners.add(listener); this.settingsListeners.add(listener);
} }
+53
View File
@@ -579,6 +579,19 @@ body {
.win7 .w7-flat::after { .win7 .w7-flat::after {
content: none; content: none;
} }
/* Context-menu rows are <button>s, so 7.css would paint each one as an Aero
push-button slab. Strip the slab here (revert-layer needs `skin`); the
Win7 menu paint itself is stated in `skinfix` below. */
.win7 .w7-menu button {
background: revert-layer;
border: 0;
box-shadow: none;
}
.win7 .w7-menu button::before,
.win7 .w7-menu button::after {
content: none;
}
} }
/* Above `skin`, for rules that must state a value rather than defer to a /* Above `skin`, for rules that must state a value rather than defer to a
@@ -591,4 +604,44 @@ body {
min-height: 23px; min-height: 23px;
padding: 0 12px; padding: 0 12px;
} }
/* Aero context menu. 7.css has no menu component, so this is hand-authored —
but ONLY out of the existing --w7-* vars, never literal hex, so the
hand-written dark Win7 palette above recolours it for free. Values are
stated (not reverted), hence `skinfix` rather than `skin`.
A real Win7 menu is a square-cornered white slab with a hairline border
and a gradient selection band that spans the full row. */
.win7 .w7-menu {
border-radius: 0;
border: 1px solid var(--w7-el-bd);
background: var(--w7-surface);
color: var(--w7-el-c);
font: var(--w7-font);
padding: 2px;
box-shadow: 0 2px 6px #0000004d;
}
.win7 .w7-menu button {
border-radius: 0;
padding: 3px 8px;
color: var(--w7-el-c);
}
/* Hover AND keyboard highlight share the band — the utility `bg-accent` that
drives the keyboard cursor is overridden here, so both look identical. */
.win7 .w7-menu button:hover,
.win7 .w7-menu button.bg-accent {
background: var(--w7-li-bg-hl);
border: 1px solid var(--w7-li-bd-hl);
/* keep the 1px border from shifting the label */
padding: 2px 7px;
}
.win7 .w7-menu .w7-menu-sep {
border-color: var(--w7-el-bd);
margin: 2px 0;
}
/* Accelerator hints ride the muted colour, like Win7's grey shortcut column.
Keyed on the utility class the accelerator span carries — `:last-child`
would also catch the LABEL on every item that has no accelerator. */
.win7 .w7-menu button > span.text-muted-foreground {
color: var(--w7-el-c-d);
}
} }