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:
@@ -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)
|
||||
(processes, windows, RPC, every module/service), [build & release](docs/build-and-release.md)
|
||||
(Build-n versioning, per-target release pipeline, CI), [platform notes](docs/platform-notes.md)
|
||||
(macOS/Windows 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
|
||||
|
||||
@@ -75,6 +76,11 @@ ui/src/app/ Angular components + signal services
|
||||
|
||||
- [Bun](https://bun.sh) ≥ 1.3 (`bun --version`).
|
||||
- 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`)
|
||||
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-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 linux-x64 # on a Linux x64 machine
|
||||
```
|
||||
|
||||
Artifacts land in `release/<target>/miniSCP-Build<n>-<target>.*` — a `.dmg`
|
||||
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
|
||||
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
|
||||
@@ -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;
|
||||
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 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;
|
||||
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
|
||||
|
||||
- **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
|
||||
(symlinks are listed but skipped by transfers), OS keychain, tab persistence
|
||||
across restarts, Windows/Linux packaging (the code is portable; only macOS
|
||||
packaging is wired up).
|
||||
across restarts, Linux desktop integration (AppImage/.deb/.desktop entry —
|
||||
the release is a portable tarball).
|
||||
|
||||
+35
-11
@@ -10,7 +10,8 @@ Dense reference for development and debugging. Companion files:
|
||||
its native launcher. Owns every privileged capability: filesystem, SSH,
|
||||
window management, native menus/dialogs/tray, the connection/settings stores.
|
||||
- **Webviews** (`ui/` Angular): one per window, system WebKit on macOS /
|
||||
WebView2 on Windows. 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
|
||||
`webviewId` (each webview auto-connects using natively injected globals).
|
||||
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),
|
||||
atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults
|
||||
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
|
||||
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
|
||||
@@ -161,7 +158,8 @@ would float over editor windows).
|
||||
locale (from `settings.locale`, `system` via `Intl…resolvedOptions().locale`);
|
||||
`t(key, params)` shares the same `src/shared/i18n` dictionaries as the UI.
|
||||
Wired in index.ts on boot and on `settingsChanged` (relocalizes + rebuilds
|
||||
the app menu). `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;
|
||||
Windows colored icon. No `setImage` calls ever (they lose the template
|
||||
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).
|
||||
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
|
||||
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
|
||||
(fixed `--icon-*` tokens, never accent-tinted) and the Kind column.
|
||||
- `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`,
|
||||
`EDITOR_CONTEXT`, `IS_MAC_CHROME`, `IS_WINDOWS`, `MOD_LABEL`/`ALT_LABEL`)
|
||||
+ `blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside
|
||||
text fields).
|
||||
`EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`,
|
||||
`MOD_LABEL`/`ALT_LABEL`) + `blockNativeContextMenu` (kills WebKit's
|
||||
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
|
||||
`state` (`idle→available→downloading→ready`|`error`), `version`, `progress`.
|
||||
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
|
||||
styles `::-webkit-scrollbar` under that class), `w7-toolbar` on the three
|
||||
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 —
|
||||
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
|
||||
`openSecondaryWindow`. `win7` uses `titleBarStyle: "hidden"` on **every**
|
||||
platform — that clears `Titled` from the Cocoa style mask, which is also the
|
||||
only way to remove the macOS traffic lights. 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` >
|
||||
`.title-bar-text` + `.title-bar-controls`, buttons keyed by `aria-label`),
|
||||
mounted by all three window roots. Because `titleBarStyle` is fixed at
|
||||
|
||||
@@ -25,7 +25,8 @@ semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel`
|
||||
|
||||
## 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
|
||||
--env=stable` → copy/rename artifacts into
|
||||
`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
|
||||
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
|
||||
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
|
||||
x64 CLI binary once (`electrobun-cli-darwin-x64.tar.gz` from the GitHub
|
||||
release matching the installed version, cached at
|
||||
@@ -56,7 +58,16 @@ Facts the script encodes:
|
||||
(self-extracting installer → `%LOCALAPPDATA%`) plus the script's own
|
||||
**portable zip** made from the assembled app folder under
|
||||
`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
|
||||
brandings don't get collected.
|
||||
|
||||
|
||||
+44
-4
@@ -63,7 +63,46 @@ touching the adjacent code.
|
||||
- Tray: never call `setImage` after construction on macOS — the template
|
||||
treatment is lost and the icon renders black on dark menu bars.
|
||||
- `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are
|
||||
single-character only. ContextMenu 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
|
||||
white/inverted blink returns: (1) windows are created `hidden: true` and
|
||||
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
|
||||
containing an apostrophe. Plurals are `{ one, other }` maps (not ICU strings);
|
||||
zh/ja supply only `other`.
|
||||
- **Native menus can't use the Angular service** — `menu.ts`/`context-menu.ts`
|
||||
go through backend `i18n.ts`, and the app menu must be rebuilt when the locale
|
||||
changes (wired in index.ts `settingsChanged`), or it keeps the old language.
|
||||
- **Native menus can't use the Angular service** — `menu.ts` goes through backend
|
||||
`i18n.ts`, and the app menu must be rebuilt when the locale changes (wired in
|
||||
index.ts `settingsChanged`), or it keeps the old language. The context menu no
|
||||
longer has this problem: it is Angular now and reads `I18nService` directly.
|
||||
- `eu`/`zh`/`ja` are AI drafts pending native review (see the i18n README).
|
||||
- **Silent auto-refresh must snapshot sessionId+cwd and discard a mismatched
|
||||
result.** A normal `refresh()` is masked by `busy` (input blocked), but the
|
||||
|
||||
+93
-10
@@ -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
|
||||
deferred; Linux untargeted — electrobun lacks menus/context menus there).
|
||||
Every platform seam carries a `PORTABILITY:` comment at the branch site.
|
||||
macOS is the primary target; Windows 11+ x64 and Linux x64 are **beta** targets
|
||||
(win-arm64 and linux-arm64 deferred). Every platform seam carries a
|
||||
`PORTABILITY:` comment at the branch site.
|
||||
|
||||
## 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 |
|
||||
| External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` |
|
||||
| Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any |
|
||||
| Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` |
|
||||
| Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty |
|
||||
| chmod/Permissions | context-menu.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 |
|
||||
| chmod/Permissions | context-menu.service.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full |
|
||||
| Shell wording | `ui context-menu.service.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
|
||||
| Shortcut labels | `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 |
|
||||
| App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — About lives in the options popover | none |
|
||||
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored (needs AppIndicator, see below) |
|
||||
| App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — About lives in the options popover | none (unsupported upstream) |
|
||||
| connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 |
|
||||
| App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config |
|
||||
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
|
||||
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
|
||||
|
||||
The **context menu is HTML on every platform** (`ui/src/app/context-menu.ts` +
|
||||
`context-menu.service.ts`), not a native electrobun menu — see the Linux section
|
||||
below for why. Only the three shell actions it can't do itself (reveal, open with
|
||||
the default app, copy path) cross to bun, via the `shellItemAction` RPC.
|
||||
|
||||
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
|
||||
(`path.posix` throughout `fs/sftp.ts`).
|
||||
|
||||
## Memory footprint (expected, not a bug)
|
||||
|
||||
Investigated 2026-07 — packaged build. Roughly **~200 MB macOS / ~300 MB
|
||||
Windows**, 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).
|
||||
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
|
||||
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)
|
||||
|
||||
- **HiDPI blur** — WebView2 renders at 1x on scaled displays
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
"dev:ui": "bun run --cwd ui start",
|
||||
"build:ui": "bun run --cwd ui 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",
|
||||
"release": "bun scripts/release.ts"
|
||||
},
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
// Dev orchestrator: starts ng serve (UI hot reload), waits for it, then runs
|
||||
// `electrobun dev --watch` (relaunches the app when bun-side code changes).
|
||||
import { launchEnv } from "./linux-gpu";
|
||||
|
||||
const DEV_SERVER_URL = "http://localhost:4200";
|
||||
|
||||
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"], {
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
env: { ...process.env, DEV_SERVER_URL },
|
||||
env: launchEnv({ DEV_SERVER_URL }),
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
|
||||
@@ -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
@@ -1,21 +1,22 @@
|
||||
// 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
|
||||
// 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
|
||||
// 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.
|
||||
// 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 path from "node:path";
|
||||
|
||||
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];
|
||||
|
||||
function hostTarget(): Target {
|
||||
if (process.platform === "win32") return "win-x64";
|
||||
if (process.platform === "linux") return "linux-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") {
|
||||
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") {
|
||||
fail(`${requested} must be built on macOS.`);
|
||||
}
|
||||
@@ -89,7 +93,12 @@ if (viaRosetta) {
|
||||
|
||||
// ---- 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 artifactsDir = path.join(ROOT, "artifacts");
|
||||
const outDir = path.join(ROOT, "release", requested);
|
||||
@@ -109,15 +118,42 @@ for (const file of produced) {
|
||||
out.push(name);
|
||||
}
|
||||
|
||||
// Windows: also produce a portable zip from the assembled app folder (the
|
||||
// Setup.exe is an installer; portable = unzip anywhere and run). Best-effort:
|
||||
// the folder layout is confirmed by the first real Windows build.
|
||||
if (requested === "win-x64") {
|
||||
// Windows / Linux: package the assembled app folder directly. On Windows that
|
||||
// complements the Setup.exe installer (portable = unzip anywhere and run); on
|
||||
// Linux it is the ONLY artifact, since electrobun emits just the .tar.zst
|
||||
// updater blob there — no installer, AppImage or .deb. Best-effort: the folder
|
||||
// layout is confirmed by the first real build on each OS.
|
||||
if (requested === "win-x64" || requested === "linux-x64") {
|
||||
const appDir = path.join(ROOT, "build", `stable-${osName}-${archName}`, BRANDING.appName);
|
||||
if (existsSync(appDir)) {
|
||||
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
|
||||
run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]);
|
||||
out.push(path.basename(portable));
|
||||
if (requested === "win-x64") {
|
||||
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
|
||||
run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]);
|
||||
out.push(path.basename(portable));
|
||||
} else {
|
||||
// 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 {
|
||||
console.warn(`[release] WARN: portable skipped — app folder not found at ${appDir}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import Electrobun, { ApplicationMenu, BrowserWindow, Updater, Utils } from "elec
|
||||
import { BRANDING } from "../shared/branding";
|
||||
import type { EditorOpenContext } from "../shared/rpc";
|
||||
import { effectiveConcurrency, type AppSettings } from "../shared/types";
|
||||
import { PanelContextMenu } from "./context-menu";
|
||||
import { EditManager } from "./edits";
|
||||
import { EditorWindows } from "./editor-windows";
|
||||
import { SessionRegistry } from "./fs/registry";
|
||||
@@ -46,7 +45,6 @@ const engine = new TransferEngine(registry);
|
||||
const store = new ConnectionStore();
|
||||
const settings = new SettingsStore();
|
||||
const edits = new EditManager(registry, engine, settings);
|
||||
const contextMenu = new PanelContextMenu();
|
||||
const watcher = new LocalDirWatcher();
|
||||
const events = {
|
||||
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
|
||||
@@ -65,7 +63,6 @@ const rpc = createAppRPC({
|
||||
store,
|
||||
settings,
|
||||
edits,
|
||||
contextMenu,
|
||||
watcher,
|
||||
mainWindow: () => mainWin,
|
||||
events,
|
||||
@@ -158,7 +155,6 @@ win.on("close", () => {
|
||||
});
|
||||
|
||||
new AppTray(win);
|
||||
contextMenu.onAction = (action) => rpc.send.menuAction({ action });
|
||||
|
||||
// notify on finished transfers while the window is in the background
|
||||
let windowFocused = true;
|
||||
|
||||
+5
-12
@@ -1,7 +1,6 @@
|
||||
import { BrowserView, type BrowserWindow, Updater, Utils } from "electrobun/bun";
|
||||
import type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
||||
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
|
||||
import type { PanelContextMenu } from "./context-menu";
|
||||
import { readFileText, writeFileText, type EditManager } from "./edits";
|
||||
import { listLocalDrives } from "./fs/local";
|
||||
import type { SessionRegistry } from "./fs/registry";
|
||||
@@ -24,7 +23,6 @@ export interface AppDeps {
|
||||
store: ConnectionStore;
|
||||
settings: SettingsStore;
|
||||
edits: EditManager;
|
||||
contextMenu: PanelContextMenu;
|
||||
watcher: LocalDirWatcher;
|
||||
/**
|
||||
* The main BrowserWindow, for the win7 skin's custom title-bar controls.
|
||||
@@ -175,7 +173,6 @@ export function createAppRPC({
|
||||
store,
|
||||
settings,
|
||||
edits,
|
||||
contextMenu,
|
||||
watcher,
|
||||
mainWindow,
|
||||
events,
|
||||
@@ -339,10 +336,12 @@ export function createAppRPC({
|
||||
return {};
|
||||
}),
|
||||
|
||||
// ---- context menu + local shell integration ----
|
||||
showRowMenu: (params) =>
|
||||
// ---- local shell integration (menu itself is HTML, see ui/) ----
|
||||
shellItemAction: ({ action, path }) =>
|
||||
wrap(async () => {
|
||||
contextMenu.show(params);
|
||||
if (action === "reveal") Utils.showItemInFolder(path);
|
||||
else if (action === "open") Utils.openPath(path);
|
||||
else Utils.clipboardWriteText(path);
|
||||
return {};
|
||||
}),
|
||||
|
||||
@@ -352,12 +351,6 @@ export function createAppRPC({
|
||||
return {};
|
||||
}),
|
||||
|
||||
showColumnsMenu: ({ visible }) =>
|
||||
wrap(async () => {
|
||||
contextMenu.showColumns(visible);
|
||||
return {};
|
||||
}),
|
||||
|
||||
// ---- editor windows ----
|
||||
openEditor: (ctx) =>
|
||||
wrap(async () => {
|
||||
|
||||
+9
-12
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
AppSettings,
|
||||
ColumnKey,
|
||||
ConnectionConfig,
|
||||
ConnectionsFile,
|
||||
EntryType,
|
||||
@@ -126,20 +125,18 @@ export type AppRPC = {
|
||||
response: Result<{ names: string[] }>;
|
||||
};
|
||||
clearEditLeftovers: { params: Record<string, never>; response: Result };
|
||||
// context menu + local shell integration
|
||||
showRowMenu: {
|
||||
params: {
|
||||
path: string;
|
||||
name: string;
|
||||
type: EntryType;
|
||||
isLocal: boolean;
|
||||
hasEntry: boolean;
|
||||
};
|
||||
// local shell integration
|
||||
/**
|
||||
* Shell actions the context menu triggers on a LOCAL path. The menu
|
||||
* itself is HTML (ui/src/app/context-menu.ts) — electrobun's native
|
||||
* context menu is a no-op on Linux — but these three still need the
|
||||
* bun side's Utils.
|
||||
*/
|
||||
shellItemAction: {
|
||||
params: { action: "reveal" | "open" | "copyPath"; 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). */
|
||||
showAbout: { params: Record<string, never>; response: Result };
|
||||
/** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */
|
||||
|
||||
@@ -55,5 +55,6 @@
|
||||
<app-overwrite-dialog [request]="req" />
|
||||
}
|
||||
|
||||
<app-context-menu />
|
||||
<app-toasts />
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { I18nService } from './i18n.service';
|
||||
import { BRANDING } from '@shared/branding';
|
||||
import type { ColumnKey } from '@shared/types';
|
||||
import { ConnectionsService } from './connections.service';
|
||||
import { ContextMenu } from './context-menu';
|
||||
import { ChmodDialog } from './dialogs/chmod-dialog';
|
||||
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
||||
import { OverwriteDialog } from './dialogs/overwrite-dialog';
|
||||
@@ -38,6 +39,7 @@ import { UpdaterService } from './updater.service';
|
||||
Toasts,
|
||||
TransferBar,
|
||||
TitleBar,
|
||||
ContextMenu,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
host: {
|
||||
@@ -194,10 +196,22 @@ export class App {
|
||||
const target = this.actions.menuTarget;
|
||||
if (!target) return;
|
||||
const { panel, side, entry } = target;
|
||||
// the empty-space menu acts on the directory itself
|
||||
const path = entry?.path ?? panel.cwd();
|
||||
switch (action) {
|
||||
case 'ctx-open':
|
||||
if (entry) panel.open(entry);
|
||||
break;
|
||||
// local shell integration — only bun can reach the OS shell/clipboard
|
||||
case 'ctx-reveal':
|
||||
void this.rpc.request.shellItemAction({ action: 'reveal', path });
|
||||
break;
|
||||
case 'ctx-open-default':
|
||||
void this.rpc.request.shellItemAction({ action: 'open', path });
|
||||
break;
|
||||
case 'ctx-copy-path':
|
||||
void this.rpc.request.shellItemAction({ action: 'copyPath', path });
|
||||
break;
|
||||
case 'ctx-edit':
|
||||
void this.actions.edit(panel);
|
||||
break;
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import type { FileEntry } from '@shared/types';
|
||||
import { ContextMenuService } from './context-menu.service';
|
||||
import { DialogsService } from './dialogs.service';
|
||||
import { joinPath } from './format';
|
||||
import { type PanelState, type Side } from './panels.service';
|
||||
@@ -26,21 +27,25 @@ export class FileActionsService {
|
||||
private readonly rpc = inject(RpcService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly dialogs = inject(DialogsService);
|
||||
private readonly menu = inject(ContextMenuService);
|
||||
private readonly settings = inject(SettingsService);
|
||||
|
||||
menuTarget: MenuTarget | null = null;
|
||||
|
||||
/** Right-click entry point: remember the target and ask bun for a native menu. */
|
||||
async openContextMenu(target: MenuTarget): Promise<void> {
|
||||
/** Right-click entry point: remember the target, then open the menu on it. */
|
||||
openContextMenu(event: MouseEvent, target: MenuTarget): void {
|
||||
this.menuTarget = target;
|
||||
const { panel, entry } = target;
|
||||
await this.rpc.request.showRowMenu({
|
||||
path: entry?.path ?? panel.cwd(),
|
||||
name: entry?.name ?? '',
|
||||
type: entry?.type ?? 'dir',
|
||||
isLocal: panel.sessionId() === 'local',
|
||||
hasEntry: !!entry,
|
||||
});
|
||||
this.menu.open(
|
||||
event,
|
||||
this.menu.rowItems({
|
||||
path: entry?.path ?? panel.cwd(),
|
||||
name: entry?.name ?? '',
|
||||
type: entry?.type ?? 'dir',
|
||||
isLocal: panel.sessionId() === 'local',
|
||||
hasEntry: !!entry,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async trash(panel: PanelState, entry: FileEntry): Promise<void> {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { NgIcon } from '@ng-icons/core';
|
||||
import { I18nService } from '../i18n.service';
|
||||
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
|
||||
import { ConnectionsService } from '../connections.service';
|
||||
import { ContextMenuService } from '../context-menu.service';
|
||||
import { DialogsService } from '../dialogs.service';
|
||||
import { FileActionsService } from '../file-actions.service';
|
||||
import { fileGlyph, fileIconClass, fileKindKey } from '../file-icons';
|
||||
@@ -47,6 +48,7 @@ export class FilePanel {
|
||||
readonly transfers = inject(TransfersService);
|
||||
readonly connections = inject(ConnectionsService);
|
||||
readonly dialogs = inject(DialogsService);
|
||||
private readonly menu = inject(ContextMenuService);
|
||||
private readonly settings = inject(SettingsService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
readonly panel = computed(() => this.panels.get(this.side()));
|
||||
@@ -88,7 +90,7 @@ export class FilePanel {
|
||||
|
||||
headerContextMenu(event: MouseEvent): void {
|
||||
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 {
|
||||
@@ -238,14 +240,14 @@ export class FilePanel {
|
||||
// standard behavior: keep the selection when right-clicking inside it
|
||||
if (entry && !panel.selected().has(entry.name)) panel.clearSelection();
|
||||
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 {
|
||||
event.preventDefault();
|
||||
if (this.blocked()) return;
|
||||
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 {
|
||||
|
||||
@@ -18,7 +18,7 @@ declare global {
|
||||
|
||||
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
|
||||
@@ -40,6 +40,8 @@ export const IS_MAC_CHROME = IS_MAC && !IS_WIN7_CHROME;
|
||||
|
||||
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
|
||||
* platform, NOT IS_MAC_CHROME — the keyboard does not change with the skin. */
|
||||
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
|
||||
|
||||
@@ -103,6 +103,15 @@ export class RpcService {
|
||||
this.menuListeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an action that originated in the UI (the HTML context menu) as if
|
||||
* it had arrived from bun, so app.ts keeps ONE handler for menu actions
|
||||
* regardless of whether the app menubar or a right-click produced them.
|
||||
*/
|
||||
emitMenuAction(action: string): void {
|
||||
for (const listener of this.menuListeners) listener(action);
|
||||
}
|
||||
|
||||
onSettingsChanged(listener: (settings: AppSettings) => void): void {
|
||||
this.settingsListeners.add(listener);
|
||||
}
|
||||
|
||||
@@ -579,6 +579,19 @@ body {
|
||||
.win7 .w7-flat::after {
|
||||
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
|
||||
@@ -591,4 +604,44 @@ body {
|
||||
min-height: 23px;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user