diff --git a/README.md b/README.md index b8155cb..5dec7e3 100644 --- a/README.md +++ b/README.md @@ -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,13 @@ 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//miniSCP-Build-.*` — 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 +`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 +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; 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 +245,28 @@ 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 `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 - **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 (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). diff --git a/docs/architecture.md b/docs/architecture.md index 518c827..1ff191d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/build-and-release.md b/docs/build-and-release.md index d5f89b6..fb54fd3 100644 --- a/docs/build-and-release.md +++ b/docs/build-and-release.md @@ -25,7 +25,8 @@ semver-shaped (`0.1.`) 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//miniSCP-Build-.*`. @@ -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,14 @@ 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 `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 brandings don't get collected. diff --git a/docs/gotchas.md b/docs/gotchas.md index f86eda2..2e649c3 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -48,7 +48,30 @@ 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). +- 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 white/inverted blink returns: (1) windows are created `hidden: true` and 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 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 diff --git a/docs/platform-notes.md b/docs/platform-notes.md index 8fb2a0f..20a191b 100644 --- a/docs/platform-notes.md +++ b/docs/platform-notes.md @@ -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,75 @@ 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. +- **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-linux-x64.tar.gz`; unpack it elsewhere and + run `bin/launcher`. + ## Windows known issues (upstream electrobun, documented not fixed) - **HiDPI blur** — WebView2 renders at 1x on scaled displays diff --git a/scripts/release.ts b/scripts/release.ts index df9c650..34f8c5e 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,5 +1,5 @@ // 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 @@ -11,11 +11,12 @@ 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,24 @@ 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 { + 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}`); } diff --git a/src/bun/context-menu.ts b/src/bun/context-menu.ts deleted file mode 100644 index fa8a0bd..0000000 --- a/src/bun/context-menu.ts +++ /dev/null @@ -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; - } -} diff --git a/src/bun/index.ts b/src/bun/index.ts index 123bb79..6d86007 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -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 }) => {}, @@ -56,7 +54,7 @@ const events = { openConnectionManager: () => {}, 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 watcher.onChange = (dir) => rpc.send.pathRefresh({ sessionId: "local", dir }); const url = await getMainViewUrl(); @@ -149,7 +147,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; diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts index 4e6fb46..3880579 100644 --- a/src/bun/rpc-handlers.ts +++ b/src/bun/rpc-handlers.ts @@ -1,7 +1,6 @@ import { BrowserView, 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; /** Assigned after the rpc object exists (it needs rpc.send). */ events: { @@ -122,7 +120,6 @@ export function createAppRPC({ store, settings, edits, - contextMenu, watcher, events, }: AppDeps) { @@ -282,10 +279,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 {}; }), @@ -295,12 +294,6 @@ export function createAppRPC({ return {}; }), - showColumnsMenu: ({ visible }) => - wrap(async () => { - contextMenu.showColumns(visible); - return {}; - }), - // ---- editor windows ---- openEditor: (ctx) => wrap(async () => { diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index 6aa9f8c..7854fc2 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -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; 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; response: Result }; /** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */ diff --git a/ui/src/app/app.html b/ui/src/app/app.html index f90fe01..c8e8f62 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -53,5 +53,6 @@ } + diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index da85662..bf1a285 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -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'; @@ -36,6 +37,7 @@ import { UpdaterService } from './updater.service'; OverwriteDialog, Toasts, TransferBar, + ContextMenu, ], templateUrl: './app.html', host: { @@ -180,10 +182,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; diff --git a/ui/src/app/context-menu.service.ts b/ui/src/app/context-menu.service.ts new file mode 100644 index 0000000..b91fe2f --- /dev/null +++ b/ui/src/app/context-menu.service.ts @@ -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(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), + })); + } +} diff --git a/ui/src/app/context-menu.ts b/ui/src/app/context-menu.ts new file mode 100644 index 0000000..b748e46 --- /dev/null +++ b/ui/src/app/context-menu.ts @@ -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) { + +
+ + } + `, + // 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>('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; + } + } + } +} diff --git a/ui/src/app/file-actions.service.ts b/ui/src/app/file-actions.service.ts index 48d9375..ab40e70 100644 --- a/ui/src/app/file-actions.service.ts +++ b/ui/src/app/file-actions.service.ts @@ -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 { + /** 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 { diff --git a/ui/src/app/file-panel/file-panel.ts b/ui/src/app/file-panel/file-panel.ts index 4881b7e..943dc29 100644 --- a/ui/src/app/file-panel/file-panel.ts +++ b/ui/src/app/file-panel/file-panel.ts @@ -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 { diff --git a/ui/src/app/platform.ts b/ui/src/app/platform.ts index 2aa1e41..6f2c6cd 100644 --- a/ui/src/app/platform.ts +++ b/ui/src/app/platform.ts @@ -15,11 +15,15 @@ declare global { 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). */ -export const IS_MAC_CHROME = PLATFORM === 'darwin'; +export const IS_MAC_CHROME = IS_MAC; export const IS_WINDOWS = PLATFORM === 'win32'; +export const IS_LINUX = PLATFORM === 'linux'; + /** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */ export const MOD_LABEL = IS_MAC_CHROME ? '⌘' : 'Ctrl+'; export const ALT_LABEL = IS_MAC_CHROME ? '⌥' : 'Alt+'; diff --git a/ui/src/app/rpc.service.ts b/ui/src/app/rpc.service.ts index b35bf10..96c17d4 100644 --- a/ui/src/app/rpc.service.ts +++ b/ui/src/app/rpc.service.ts @@ -99,6 +99,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); }