`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>
189 lines
6.4 KiB
TypeScript
189 lines
6.4 KiB
TypeScript
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';
|
|
import { IS_WINDOWS } from './platform';
|
|
import { RpcService } from './rpc.service';
|
|
import { SettingsService } from './settings.service';
|
|
import { I18nService } from './i18n.service';
|
|
import { ToastService } from './toast.service';
|
|
|
|
/** The row (or empty space) the user last right-clicked; ctx-* actions apply here. */
|
|
export interface MenuTarget {
|
|
side: Side;
|
|
panel: PanelState;
|
|
entry: FileEntry | null;
|
|
}
|
|
|
|
/**
|
|
* File operations invoked from a panel's toolbar / keyboard. They act on the
|
|
* GIVEN panel: on the selection if there is one, else on the cursor row.
|
|
*/
|
|
@Injectable({ providedIn: 'root' })
|
|
export class FileActionsService {
|
|
private readonly i18n = inject(I18nService);
|
|
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, then open the menu on it. */
|
|
openContextMenu(event: MouseEvent, target: MenuTarget): void {
|
|
this.menuTarget = target;
|
|
const { panel, entry } = target;
|
|
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> {
|
|
const res = await this.rpc.request.moveToTrash({ path: entry.path });
|
|
if (!res.ok) this.toast.error(res.error);
|
|
else this.toast.info(this.i18n.t('toasts.movedToTrash', { name: entry.name }));
|
|
await panel.refresh();
|
|
}
|
|
|
|
/** Selection if non-empty, else the cursor entry. Never the ".." parent row. */
|
|
targets(panel: PanelState): FileEntry[] {
|
|
const selected = panel.selectedEntries();
|
|
if (selected.length) return selected;
|
|
const atCursor = panel.sorted()[panel.cursor()];
|
|
return atCursor && atCursor.name !== '..' ? [atCursor] : [];
|
|
}
|
|
|
|
mkdir(panel: PanelState): void {
|
|
this.dialogs.prompt.set({
|
|
title: this.i18n.t('fileActions.newFolderTitle'),
|
|
placeholder: this.i18n.t('fileActions.newFolderPlaceholder'),
|
|
confirmLabel: this.i18n.t('fileActions.createConfirm'),
|
|
onConfirm: async (name) => {
|
|
const res = await this.rpc.request.mkdir({
|
|
sessionId: panel.sessionId(),
|
|
path: joinPath(panel.cwd(), name),
|
|
});
|
|
if (!res.ok) this.toast.error(res.error);
|
|
await panel.refresh();
|
|
},
|
|
});
|
|
}
|
|
|
|
createFile(panel: PanelState): void {
|
|
this.dialogs.prompt.set({
|
|
title: this.i18n.t('fileActions.newFileTitle'),
|
|
placeholder: this.i18n.t('fileActions.newFilePlaceholder'),
|
|
confirmLabel: this.i18n.t('fileActions.createConfirm'),
|
|
onConfirm: async (name) => {
|
|
const res = await this.rpc.request.createFile({
|
|
sessionId: panel.sessionId(),
|
|
path: joinPath(panel.cwd(), name),
|
|
});
|
|
if (!res.ok) this.toast.error(res.error);
|
|
await panel.refresh();
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Open the cursor file in the built-in editor or the configured external app. */
|
|
async edit(panel: PanelState): Promise<void> {
|
|
const entry = panel.sorted()[panel.cursor()];
|
|
if (!entry || entry.type !== 'file' || entry.name === '..') return;
|
|
|
|
if (this.settings.editor() === 'custom') {
|
|
const res = await this.rpc.request.editExternal({
|
|
sessionId: panel.sessionId(),
|
|
path: entry.path,
|
|
});
|
|
if (!res.ok) {
|
|
this.toast.error(res.error);
|
|
} else if (res.watching) {
|
|
this.toast.info(this.i18n.t('toasts.editingExternal', { name: entry.name }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// built-in editor: its own native window (reads the file itself)
|
|
const res = await this.rpc.request.openEditor({
|
|
sessionId: panel.sessionId(),
|
|
path: entry.path,
|
|
name: entry.name,
|
|
sessionTitle: panel.title(),
|
|
});
|
|
if (!res.ok) this.toast.error(res.error);
|
|
}
|
|
|
|
rename(panel: PanelState): void {
|
|
const entry = panel.sorted()[panel.cursor()];
|
|
if (!entry || entry.name === '..') return;
|
|
this.dialogs.prompt.set({
|
|
title: this.i18n.t('fileActions.renameTitle', { name: entry.name }),
|
|
initial: entry.name,
|
|
confirmLabel: this.i18n.t('fileActions.renameConfirm'),
|
|
onConfirm: async (name) => {
|
|
if (name === entry.name) return;
|
|
const res = await this.rpc.request.rename({
|
|
sessionId: panel.sessionId(),
|
|
oldPath: entry.path,
|
|
newPath: joinPath(panel.cwd(), name),
|
|
});
|
|
if (!res.ok) this.toast.error(res.error);
|
|
await panel.refresh();
|
|
},
|
|
});
|
|
}
|
|
|
|
delete(panel: PanelState): void {
|
|
const targets = this.targets(panel);
|
|
if (!targets.length) return;
|
|
const what =
|
|
targets.length === 1
|
|
? `"${targets[0].name}"`
|
|
: this.i18n.t('fileActions.itemsCount', { count: targets.length });
|
|
this.dialogs.confirm.set({
|
|
title: this.i18n.t('fileActions.deleteTitle'),
|
|
body: this.i18n.t('fileActions.deleteBody', { what }),
|
|
confirmLabel: this.i18n.t('fileActions.deleteConfirm'),
|
|
destructive: true,
|
|
onConfirm: async () => {
|
|
const res = await this.rpc.request.deleteEntries({
|
|
sessionId: panel.sessionId(),
|
|
entries: targets.map(({ path, type }) => ({ path, type })),
|
|
});
|
|
if (!res.ok) this.toast.error(res.error);
|
|
await panel.refresh();
|
|
},
|
|
});
|
|
}
|
|
|
|
chmod(panel: PanelState): void {
|
|
// Windows local chmod only flips the read-only bit — don't pretend
|
|
if (IS_WINDOWS && panel.sessionId() === 'local') return;
|
|
const targets = this.targets(panel);
|
|
if (!targets.length) return;
|
|
this.dialogs.chmod.set({
|
|
targets: targets.map((t) => t.name),
|
|
initialMode: targets[0].mode,
|
|
onConfirm: async (mode) => {
|
|
const res = await this.rpc.request.chmod({
|
|
sessionId: panel.sessionId(),
|
|
paths: targets.map((t) => t.path),
|
|
mode,
|
|
});
|
|
if (!res.ok) this.toast.error(res.error);
|
|
await panel.refresh();
|
|
},
|
|
});
|
|
}
|
|
}
|