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:
@@ -53,5 +53,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';
|
||||
@@ -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;
|
||||
|
||||
@@ -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="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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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+';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user