Right-click context menus on file panels

Native menus (electrobun ContextMenu) on rows and empty panel space. Rows:
Open (dirs navigate; local files open with the default app via Utils.openPath),
Edit, Copy to Other Panel, Rename, Permissions, Copy Path (works for remote
paths too), Reveal in Finder (local, Utils.showItemInFolder), Move to Trash
(local, Utils.moveToTrash — tooltip notes Finder Restore doesn't apply) and
Delete. Empty space / '..': New Folder, New File, Refresh, Reveal in Finder.

Right-click parks the cursor on the row (keeping the selection when clicking
inside it). Local shell actions run bun-side directly; everything else routes
through the existing menuAction channel back to the stored click target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-11 23:48:23 +02:00
co-authored by Claude Fable 5
parent 9737cad6de
commit fbbde16f35
8 changed files with 226 additions and 3 deletions
+45
View File
@@ -17,6 +17,7 @@ import { StatusBar } from './status-bar/status-bar';
import { TabBar } from './tab-bar/tab-bar';
import { ToastService } from './toast.service';
import { TransferBar } from './transfer-bar/transfer-bar';
import { TransfersService } from './transfers.service';
@Component({
selector: 'app-root',
@@ -43,6 +44,7 @@ export class App {
readonly panels = inject(PanelsService);
readonly actions = inject(FileActionsService);
readonly connections = inject(ConnectionsService);
private readonly transfers = inject(TransfersService);
constructor() {
document.title = BRANDING.appName;
@@ -69,6 +71,10 @@ export class App {
/** Native application-menu actions forwarded from the bun process. */
private handleMenuAction(action: string): void {
if (action.startsWith('ctx-')) {
this.handleContextAction(action);
return;
}
if (action.startsWith('connect:')) {
const id = action.slice('connect:'.length);
const connection = this.connections.connections().find((c) => c.id === id);
@@ -117,6 +123,45 @@ export class App {
}
}
/** Context-menu actions apply to the row/panel remembered at right-click time. */
private handleContextAction(action: string): void {
const target = this.actions.menuTarget;
if (!target) return;
const { panel, side, entry } = target;
switch (action) {
case 'ctx-open':
if (entry) panel.open(entry);
break;
case 'ctx-edit':
void this.actions.edit(panel);
break;
case 'ctx-copy':
void this.transfers.copyToOtherPanel(side);
break;
case 'ctx-rename':
this.actions.rename(panel);
break;
case 'ctx-chmod':
this.actions.chmod(panel);
break;
case 'ctx-delete':
this.actions.delete(panel);
break;
case 'ctx-trash':
if (entry) void this.actions.trash(panel, entry);
break;
case 'ctx-new-folder':
this.actions.mkdir(panel);
break;
case 'ctx-new-file':
this.actions.createFile(panel);
break;
case 'ctx-refresh':
void panel.refresh();
break;
}
}
toastIcon(kind: 'error' | 'info' | 'success'): string {
if (kind === 'error') return 'mynaXCircle';
if (kind === 'success') return 'mynaCheckCircle';
+30 -1
View File
@@ -2,11 +2,18 @@ import { Injectable, inject } from '@angular/core';
import type { FileEntry } from '@shared/types';
import { DialogsService } from './dialogs.service';
import { joinPath } from './format';
import { type PanelState } from './panels.service';
import { type PanelState, type Side } from './panels.service';
import { RpcService } from './rpc.service';
import { SettingsService } from './settings.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.
@@ -18,6 +25,28 @@ export class FileActionsService {
private readonly dialogs = inject(DialogsService);
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> {
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,
});
}
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(`Moved "${entry.name}" to Trash`);
await panel.refresh();
}
/** Selection if non-empty, else the cursor entry. Never the ".." parent row. */
targets(panel: PanelState): FileEntry[] {
const selected = panel.selectedEntries();
+2
View File
@@ -210,6 +210,7 @@
tabindex="0"
(keydown)="onKeydown($event)"
(mousedown)="activate()"
(contextmenu)="listContextMenu($event)"
class="flex-1 overflow-y-auto outline-none"
>
@for (entry of panel().sorted(); track entry.name; let i = $index) {
@@ -217,6 +218,7 @@
[attr.data-index]="i"
(click)="rowClick($event, i)"
(dblclick)="openAt(i)"
(contextmenu)="rowContextMenu($event, i)"
class="grid h-(--row-h) grid-cols-[minmax(0,1fr)_5.5rem_8.5rem] items-center text-(length:--list-font)"
[class.bg-accent]="i === panel().cursor() && !isSelected(entry)"
[class.bg-primary/15]="isSelected(entry)"
+18
View File
@@ -142,6 +142,24 @@ export class FilePanel {
this.lastClickedIndex = index;
}
rowContextMenu(event: MouseEvent, index: number): void {
event.preventDefault();
event.stopPropagation();
this.activate();
const panel = this.panel();
const entry = panel.sorted()[index] ?? null;
// 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 });
}
listContextMenu(event: MouseEvent): void {
event.preventDefault();
this.activate();
void this.actions.openContextMenu({ side: this.side(), panel: this.panel(), entry: null });
}
openAt(index: number): void {
const entry = this.panel().sorted()[index];
if (entry) this.panel().open(entry);