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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-15 14:23:58 +02:00
co-authored by Claude
parent fc03fa6216
commit f7ebed5478
18 changed files with 567 additions and 209 deletions
+1
View File
@@ -53,5 +53,6 @@
<app-overwrite-dialog [request]="req" />
}
<app-context-menu />
<app-toasts />
</div>
+14
View File
@@ -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;
+125
View File
@@ -0,0 +1,125 @@
import { Injectable, inject, signal } from '@angular/core';
import type { ColumnKey, EntryType } from '@shared/types';
import { I18nService } from './i18n.service';
import { IS_MAC, IS_WINDOWS } from './platform';
import { RpcService } from './rpc.service';
/** Optional list columns, in display order (Name is fixed). */
const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group'];
export interface MenuItem {
type?: 'separator';
label?: string;
/** Emitted verbatim to the action dispatcher (`ctx-*` / `col:*`). */
action?: string;
checked?: boolean;
tooltip?: string;
/** Shortcut hint, rendered right-aligned. */
accelerator?: string;
}
export interface MenuRequest {
x: number;
y: number;
items: MenuItem[];
}
export interface RowMenuContext {
path: string;
name: string;
type: EntryType;
isLocal: boolean;
hasEntry: boolean; // false = right-click on empty panel space
}
// PORTABILITY: shell-integration wording per OS (resolved at build time for locale)
const revealKey = (): string =>
IS_MAC ? 'ctx.revealFinder' : IS_WINDOWS ? 'ctx.revealExplorer' : 'ctx.revealFileManager';
const trashKey = (): string => (IS_WINDOWS ? 'ctx.moveToRecycleBin' : 'ctx.moveToTrash');
/**
* Builds and opens the panel right-click menus. This used to be a native
* electrobun ContextMenu on the bun side, but `showContextMenu` is a documented
* no-op on Linux (electrobun's own dist/api/bun/proc/linux.md), which left the
* whole right-click surface dead there. The menu is plain HTML now — one code
* path on every platform — and still emits the exact same action strings, so
* the dispatcher in app.ts is unchanged.
*/
@Injectable({ providedIn: 'root' })
export class ContextMenuService {
private readonly i18n = inject(I18nService);
private readonly rpc = inject(RpcService);
readonly request = signal<MenuRequest | null>(null);
open(event: MouseEvent, items: MenuItem[]): void {
this.request.set({ x: event.clientX, y: event.clientY, items });
}
close(): void {
this.request.set(null);
}
/** A picked item goes through the same dispatcher the app menubar uses. */
onPick(action: string): void {
this.rpc.emitMenuAction(action);
}
/** Row / empty-space menu for a file panel. */
rowItems(ctx: RowMenuContext): MenuItem[] {
const t = (key: string) => this.i18n.t(key);
const items: MenuItem[] = [];
if (!ctx.hasEntry || ctx.name === '..') {
// empty space (or the parent row): folder-level actions
items.push({ label: t('ctx.newFolder'), action: 'ctx-new-folder' });
items.push({ label: t('ctx.newFile'), action: 'ctx-new-file' });
items.push({ type: 'separator' });
items.push({ label: t('ctx.refresh'), action: 'ctx-refresh' });
if (ctx.isLocal) {
items.push({ type: 'separator' });
items.push({ label: t(revealKey()), action: 'ctx-reveal' });
}
return items;
}
items.push({ label: t('ctx.copyToOther'), action: 'ctx-copy' });
if (ctx.type === 'dir') {
items.push({ label: t('ctx.open'), action: 'ctx-open' });
} else {
if (ctx.isLocal) {
items.push({ label: t('ctx.open'), action: 'ctx-open-default', tooltip: t('ctx.openDefault') });
}
items.push({ label: t('ctx.edit'), action: 'ctx-edit', accelerator: 'F4' });
}
items.push({ label: t('ctx.rename'), action: 'ctx-rename' });
// chmod on Windows local files only flips the read-only bit — hide the lie
if (!(ctx.isLocal && IS_WINDOWS)) {
items.push({ label: t('ctx.permissions'), action: 'ctx-chmod' });
}
items.push({ type: 'separator' });
items.push({ label: t('ctx.copyPath'), action: 'ctx-copy-path' });
if (ctx.isLocal) {
items.push({ label: t(revealKey()), action: 'ctx-reveal' });
}
items.push({ type: 'separator' });
if (ctx.isLocal) {
items.push({
label: t(trashKey()),
action: 'ctx-trash',
...(IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}),
});
}
items.push({ label: t('ctx.delete'), action: 'ctx-delete' });
return items;
}
/** Column picker for the list headers: checkable toggles per column. */
columnItems(visible: ColumnKey[]): MenuItem[] {
return COLUMN_KEYS.map((key) => ({
label: this.i18n.t(`columns.${key}`),
action: `col:${key}`,
checked: visible.includes(key),
}));
}
}
+167
View File
@@ -0,0 +1,167 @@
import {
Component,
ElementRef,
computed,
effect,
inject,
signal,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { ContextMenuService, type MenuItem } from './context-menu.service';
import { FocusService } from './focus.service';
/** Rough per-item metrics, used only to decide whether the menu must flip. */
const ITEM_H = 26;
const SEPARATOR_H = 7;
const MENU_W = 224; // w-56
const EDGE_GAP = 6;
/**
* The panel right-click menu. Replaces electrobun's native ContextMenu, which
* is a no-op on Linux — see ContextMenuService for the why. Emits the same
* `ctx-*` / `col:*` action strings the native menu did.
*/
@Component({
selector: 'app-context-menu',
imports: [NgIcon],
template: `
@if (menu.request(); as req) {
<!-- click-outside catcher; mousedown so it beats the panel's own handlers -->
<div class="fixed inset-0 z-50" (mousedown)="close()" (contextmenu)="onBackdropContext($event)"></div>
<div
#menuEl
role="menu"
tabindex="-1"
(keydown)="onKeydown($event)"
[style.left.px]="position().x"
[style.top.px]="position().y"
class="fixed z-50 w-56 rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl outline-none"
>
@for (item of req.items; track $index) {
@if (item.type === 'separator') {
<div class="my-1 border-t border-border"></div>
} @else {
<button
type="button"
role="menuitem"
[title]="item.tooltip ?? ''"
[class.bg-accent]="$index === activeIndex()"
(mouseenter)="activeIndex.set($index)"
(click)="pick(item)"
class="flex w-full items-center gap-2 px-3 py-1 text-left text-(length:--path-font) hover:bg-accent"
>
<span class="w-3.5 shrink-0">
@if (item.checked) {
<ng-icon name="mynaCheck" size="0.85rem" class="text-primary" />
}
</span>
<span class="flex-1 truncate">{{ item.label }}</span>
@if (item.accelerator) {
<span class="shrink-0 text-muted-foreground">{{ item.accelerator }}</span>
}
</button>
}
}
</div>
}
`,
// Closing on scroll/blur matches native menu behavior: the anchor row moves
// out from under a menu that would otherwise float over stale content.
host: {
'(window:blur)': 'close()',
'(window:resize)': 'close()',
'(document:scroll)': 'close()',
},
})
export class ContextMenu {
readonly menu = inject(ContextMenuService);
private readonly focus = inject(FocusService);
private readonly menuEl = viewChild<ElementRef<HTMLDivElement>>('menuEl');
readonly activeIndex = signal(-1);
/** Click point, flipped/clamped so the menu never overflows the viewport. */
readonly position = computed(() => {
const req = this.menu.request();
if (!req) return { x: 0, y: 0 };
const height =
req.items.reduce((sum, i) => sum + (i.type === 'separator' ? SEPARATOR_H : ITEM_H), 0) + 8;
const x =
req.x + MENU_W > window.innerWidth - EDGE_GAP
? Math.max(EDGE_GAP, req.x - MENU_W)
: req.x;
const y =
req.y + height > window.innerHeight - EDGE_GAP
? Math.max(EDGE_GAP, req.y - height)
: req.y;
return { x, y };
});
constructor() {
// Focus the menu once it exists so arrow keys / Escape reach it. Keyed on
// the viewChild signal, NOT on request(): under zoneless CD an effect can
// run before the @if has rendered the element.
effect(() => this.menuEl()?.nativeElement.focus());
}
close(): void {
if (!this.menu.request()) return;
this.menu.close();
this.activeIndex.set(-1); // a fresh menu starts with nothing highlighted
this.focus.bump();
}
pick(item: MenuItem): void {
if (!item.action) return;
const action = item.action;
this.close();
this.menu.onPick(action);
}
/** Right-clicking the backdrop closes rather than stacking a second menu. */
onBackdropContext(event: MouseEvent): void {
event.preventDefault();
this.close();
}
onKeydown(event: KeyboardEvent): void {
const items = this.menu.request()?.items ?? [];
switch (event.key) {
case 'Escape':
event.preventDefault();
event.stopPropagation();
this.close();
break;
case 'ArrowDown':
event.preventDefault();
this.step(items, 1);
break;
case 'ArrowUp':
event.preventDefault();
this.step(items, -1);
break;
case 'Enter':
case ' ': {
event.preventDefault();
const item = items[this.activeIndex()];
if (item) this.pick(item);
break;
}
}
}
/** Move the highlight by one selectable item, skipping separators, wrapping. */
private step(items: MenuItem[], delta: number): void {
const count = items.length;
if (!count) return;
let index = this.activeIndex();
for (let hops = 0; hops < count; hops++) {
index = (index + delta + count) % count;
if (items[index].type !== 'separator') {
this.activeIndex.set(index);
return;
}
}
}
}
+14 -9
View File
@@ -1,5 +1,6 @@
import { Injectable, inject } from '@angular/core';
import 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> {
+5 -3
View File
@@ -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 {
+5 -1
View File
@@ -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+';
+9
View File
@@ -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);
}