Make Linux a supported platform: HTML context menus + runtime deps + release target

`bun run start` died on Linux with ERR_DLOPEN_FAILED (missing
libayatana-appindicator3.so.1). Fixing the environment exposed the real gap:
electrobun's ContextMenu.showContextMenu is a documented no-op on Linux, and
since the UI suppresses the webview's own right-click menu, EVERY panel context
menu was silently dead there — Send to Other Panel, Open, Rename, Permissions,
Delete, Copy Path, Reveal and the column picker all unreachable. Electrobun
itself logs "Application menus are not supported on Linux. Implement menu UI in
your webview HTML instead." at boot.

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-15 14:23:58 +02:00
co-authored by Claude
parent fc03fa6216
commit f7ebed5478
18 changed files with 567 additions and 209 deletions
+40 -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,13 @@ 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
`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 +218,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 +245,28 @@ 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 `bin/launcher`.
- Harmless startup warnings (`GLXBadWindow`, `Failed to create GBM buffer`, an
appindicator deprecation notice) are documented in
[gotchas](docs/gotchas.md#linux).
Full dependency table, diagnostics and the first-boot smoke test:
[platform notes](docs/platform-notes.md).
## Known issues ## Known issues
- **Rare native crash under load.** A long-running session (multiple remote - **Rare native crash under load.** A long-running session (multiple remote
@@ -246,5 +282,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).
+19 -9
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
+12 -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,14 @@ 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 `bin/launcher`.
- **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.
+28 -4
View File
@@ -48,7 +48,30 @@ 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).
- Benign startup noise on Linux — all non-fatal, the app runs normally:
`X11 Error: GLXBadWindow (code 170)`, `Failed to create GBM buffer of size
WxH: Invalid argument` (compositor/GLX chatter from the GTK webview), and
`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
@@ -100,9 +123,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
+85 -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,75 @@ 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.
- **Benign startup noise**: `X11 Error: GLXBadWindow (code 170)`, `Failed to
create GBM buffer of size WxH: Invalid argument`, and a deprecation warning
pointing at `libayatana-appindicator-glib`. None are fatal.
- **Unsigned, unpackaged**: the release artifact is a portable tarball — no
AppImage, `.deb`, `.desktop` entry or icon installation. Unpack and run
`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 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 `bin/launcher`.
## 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
+28 -10
View File
@@ -1,5 +1,5 @@
// 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
@@ -11,11 +11,12 @@ 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,24 @@ 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 {
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}`);
} }
-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;
}
}
+1 -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 }) => {},
@@ -56,7 +54,7 @@ const events = {
openConnectionManager: () => {}, openConnectionManager: () => {},
showAbout: () => {}, showAbout: () => {},
}; };
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, watcher, events }); const rpc = createAppRPC({ registry, engine, store, settings, edits, watcher, 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();
@@ -149,7 +147,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, Updater, Utils } from "electrobun/bun"; import { BrowserView, 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;
/** Assigned after the rpc object exists (it needs rpc.send). */ /** Assigned after the rpc object exists (it needs rpc.send). */
events: { events: {
@@ -122,7 +120,6 @@ export function createAppRPC({
store, store,
settings, settings,
edits, edits,
contextMenu,
watcher, watcher,
events, events,
}: AppDeps) { }: AppDeps) {
@@ -282,10 +279,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 {};
}), }),
@@ -295,12 +294,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
@@ -53,5 +53,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';
@@ -36,6 +37,7 @@ import { UpdaterService } from './updater.service';
OverwriteDialog, OverwriteDialog,
Toasts, Toasts,
TransferBar, TransferBar,
ContextMenu,
], ],
templateUrl: './app.html', templateUrl: './app.html',
host: { host: {
@@ -180,10 +182,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="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="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 {
+5 -1
View File
@@ -15,11 +15,15 @@ declare global {
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : ''; export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
export const IS_MAC = PLATFORM === 'darwin';
/** True when the window uses macOS hiddenInset chrome (header fused with titlebar). */ /** True when the window uses macOS hiddenInset chrome (header fused with titlebar). */
export const IS_MAC_CHROME = PLATFORM === 'darwin'; export const IS_MAC_CHROME = IS_MAC;
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. */ /** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
export const MOD_LABEL = IS_MAC_CHROME ? '⌘' : 'Ctrl+'; export const MOD_LABEL = IS_MAC_CHROME ? '⌘' : 'Ctrl+';
export const ALT_LABEL = IS_MAC_CHROME ? '⌥' : 'Alt+'; export const ALT_LABEL = IS_MAC_CHROME ? '⌥' : 'Alt+';
+9
View File
@@ -99,6 +99,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);
} }