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:
@@ -0,0 +1,91 @@
|
|||||||
|
import { ContextMenu, Utils, type ApplicationMenuItemConfig } from "electrobun/bun";
|
||||||
|
|
||||||
|
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;
|
||||||
|
const ctx = this.context;
|
||||||
|
if (!action || !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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "New Folder", action: "ctx-new-folder" });
|
||||||
|
items.push({ label: "New File", action: "ctx-new-file" });
|
||||||
|
items.push({ type: "separator" });
|
||||||
|
items.push({ label: "Refresh", action: "ctx-refresh" });
|
||||||
|
if (ctx.isLocal) {
|
||||||
|
items.push({ type: "separator" });
|
||||||
|
items.push({ label: "Reveal in Finder", action: "ctx-reveal" });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.type === "dir") {
|
||||||
|
items.push({ label: "Open", action: "ctx-open" });
|
||||||
|
} else {
|
||||||
|
if (ctx.isLocal) {
|
||||||
|
items.push({ label: "Open", action: "ctx-open-default", tooltip: "Open with the default application" });
|
||||||
|
}
|
||||||
|
items.push({ label: "Edit", action: "ctx-edit", accelerator: "e" });
|
||||||
|
}
|
||||||
|
items.push({ label: "Copy to Other Panel", action: "ctx-copy" });
|
||||||
|
items.push({ label: "Rename", action: "ctx-rename" });
|
||||||
|
items.push({ label: "Permissions", action: "ctx-chmod" });
|
||||||
|
items.push({ type: "separator" });
|
||||||
|
items.push({ label: "Copy Path", action: "ctx-copy-path" });
|
||||||
|
if (ctx.isLocal) {
|
||||||
|
items.push({ label: "Reveal in Finder", action: "ctx-reveal" });
|
||||||
|
}
|
||||||
|
items.push({ type: "separator" });
|
||||||
|
if (ctx.isLocal) {
|
||||||
|
items.push({
|
||||||
|
label: "Move to Trash",
|
||||||
|
action: "ctx-trash",
|
||||||
|
tooltip: "Finder's Restore is not available for items trashed from here",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items.push({ label: "Delete", action: "ctx-delete" });
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -1,6 +1,7 @@
|
|||||||
import { ApplicationMenu, BrowserWindow, Updater } from "electrobun/bun";
|
import { ApplicationMenu, BrowserWindow, Updater } from "electrobun/bun";
|
||||||
import { BRANDING } from "../shared/branding";
|
import { BRANDING } from "../shared/branding";
|
||||||
import { effectiveConcurrency } from "../shared/types";
|
import { effectiveConcurrency } from "../shared/types";
|
||||||
|
import { PanelContextMenu } from "./context-menu";
|
||||||
import { EditManager } from "./edits";
|
import { EditManager } from "./edits";
|
||||||
import { SessionRegistry } from "./fs/registry";
|
import { SessionRegistry } from "./fs/registry";
|
||||||
import { applyApplicationMenu } from "./menu";
|
import { applyApplicationMenu } from "./menu";
|
||||||
@@ -35,11 +36,12 @@ const engine = new TransferEngine(registry);
|
|||||||
const store = new ConnectionStore();
|
const store = new ConnectionStore();
|
||||||
const settings = new SettingsStore();
|
const settings = new SettingsStore();
|
||||||
const edits = new EditManager(registry, engine, settings);
|
const edits = new EditManager(registry, engine, settings);
|
||||||
|
const contextMenu = new PanelContextMenu();
|
||||||
const events = {
|
const events = {
|
||||||
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
|
sessionClosed: (_info: { sessionId: string; reason: string }) => {},
|
||||||
connectionsChanged: () => {},
|
connectionsChanged: () => {},
|
||||||
};
|
};
|
||||||
const rpc = createAppRPC({ registry, engine, store, settings, edits, events });
|
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events });
|
||||||
const url = await getMainViewUrl();
|
const url = await getMainViewUrl();
|
||||||
|
|
||||||
const isMac = process.platform === "darwin";
|
const isMac = process.platform === "darwin";
|
||||||
@@ -65,6 +67,7 @@ const win = new BrowserWindow({
|
|||||||
});
|
});
|
||||||
|
|
||||||
new AppTray(win);
|
new AppTray(win);
|
||||||
|
contextMenu.onAction = (action) => rpc.send.menuAction({ action });
|
||||||
|
|
||||||
engine.onUpdate = (snapshot) => rpc.send.transferUpdate(snapshot);
|
engine.onUpdate = (snapshot) => rpc.send.transferUpdate(snapshot);
|
||||||
events.sessionClosed = (info) => rpc.send.sessionClosed(info);
|
events.sessionClosed = (info) => rpc.send.sessionClosed(info);
|
||||||
|
|||||||
+24
-1
@@ -1,6 +1,7 @@
|
|||||||
import { BrowserView, Utils } from "electrobun/bun";
|
import { BrowserView, Utils } from "electrobun/bun";
|
||||||
import type { AppRPC } from "../shared/rpc";
|
import type { AppRPC } from "../shared/rpc";
|
||||||
import { effectiveConcurrency, type Result } from "../shared/types";
|
import { effectiveConcurrency, type Result } from "../shared/types";
|
||||||
|
import type { PanelContextMenu } from "./context-menu";
|
||||||
import { readFileText, writeFileText, type EditManager } from "./edits";
|
import { readFileText, writeFileText, type EditManager } from "./edits";
|
||||||
import type { SessionRegistry } from "./fs/registry";
|
import type { SessionRegistry } from "./fs/registry";
|
||||||
import { SftpProvider } from "./fs/sftp";
|
import { SftpProvider } from "./fs/sftp";
|
||||||
@@ -19,6 +20,7 @@ export interface AppDeps {
|
|||||||
store: ConnectionStore;
|
store: ConnectionStore;
|
||||||
settings: SettingsStore;
|
settings: SettingsStore;
|
||||||
edits: EditManager;
|
edits: EditManager;
|
||||||
|
contextMenu: PanelContextMenu;
|
||||||
/** Assigned after the rpc object exists (it needs rpc.send). */
|
/** Assigned after the rpc object exists (it needs rpc.send). */
|
||||||
events: {
|
events: {
|
||||||
sessionClosed: (info: { sessionId: string; reason: string }) => void;
|
sessionClosed: (info: { sessionId: string; reason: string }) => void;
|
||||||
@@ -34,7 +36,15 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAppRPC({ registry, engine, store, settings, edits, events }: AppDeps) {
|
export function createAppRPC({
|
||||||
|
registry,
|
||||||
|
engine,
|
||||||
|
store,
|
||||||
|
settings,
|
||||||
|
edits,
|
||||||
|
contextMenu,
|
||||||
|
events,
|
||||||
|
}: AppDeps) {
|
||||||
return BrowserView.defineRPC<AppRPC>({
|
return BrowserView.defineRPC<AppRPC>({
|
||||||
maxRequestTime: MAX_REQUEST_TIME_MS,
|
maxRequestTime: MAX_REQUEST_TIME_MS,
|
||||||
handlers: {
|
handlers: {
|
||||||
@@ -201,6 +211,19 @@ export function createAppRPC({ registry, engine, store, settings, edits, events
|
|||||||
await edits.clearLeftovers();
|
await edits.clearLeftovers();
|
||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ---- context menu + local shell integration ----
|
||||||
|
showRowMenu: (params) =>
|
||||||
|
wrap(async () => {
|
||||||
|
contextMenu.show(params);
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
|
moveToTrash: ({ path }) =>
|
||||||
|
wrap(async () => {
|
||||||
|
Utils.moveToTrash(path);
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -96,6 +96,18 @@ export type AppRPC = {
|
|||||||
response: Result<{ names: string[] }>;
|
response: Result<{ names: string[] }>;
|
||||||
};
|
};
|
||||||
clearEditLeftovers: { params: Record<string, never>; response: Result };
|
clearEditLeftovers: { params: Record<string, never>; response: Result };
|
||||||
|
// context menu + local shell integration
|
||||||
|
showRowMenu: {
|
||||||
|
params: {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
type: EntryType;
|
||||||
|
isLocal: boolean;
|
||||||
|
hasEntry: boolean;
|
||||||
|
};
|
||||||
|
response: Result;
|
||||||
|
};
|
||||||
|
moveToTrash: { params: { path: string }; response: Result };
|
||||||
};
|
};
|
||||||
messages: Record<never, never>;
|
messages: Record<never, never>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { StatusBar } from './status-bar/status-bar';
|
|||||||
import { TabBar } from './tab-bar/tab-bar';
|
import { TabBar } from './tab-bar/tab-bar';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
import { TransferBar } from './transfer-bar/transfer-bar';
|
import { TransferBar } from './transfer-bar/transfer-bar';
|
||||||
|
import { TransfersService } from './transfers.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -43,6 +44,7 @@ export class App {
|
|||||||
readonly panels = inject(PanelsService);
|
readonly panels = inject(PanelsService);
|
||||||
readonly actions = inject(FileActionsService);
|
readonly actions = inject(FileActionsService);
|
||||||
readonly connections = inject(ConnectionsService);
|
readonly connections = inject(ConnectionsService);
|
||||||
|
private readonly transfers = inject(TransfersService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
document.title = BRANDING.appName;
|
document.title = BRANDING.appName;
|
||||||
@@ -69,6 +71,10 @@ export class App {
|
|||||||
|
|
||||||
/** Native application-menu actions forwarded from the bun process. */
|
/** Native application-menu actions forwarded from the bun process. */
|
||||||
private handleMenuAction(action: string): void {
|
private handleMenuAction(action: string): void {
|
||||||
|
if (action.startsWith('ctx-')) {
|
||||||
|
this.handleContextAction(action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action.startsWith('connect:')) {
|
if (action.startsWith('connect:')) {
|
||||||
const id = action.slice('connect:'.length);
|
const id = action.slice('connect:'.length);
|
||||||
const connection = this.connections.connections().find((c) => c.id === id);
|
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 {
|
toastIcon(kind: 'error' | 'info' | 'success'): string {
|
||||||
if (kind === 'error') return 'mynaXCircle';
|
if (kind === 'error') return 'mynaXCircle';
|
||||||
if (kind === 'success') return 'mynaCheckCircle';
|
if (kind === 'success') return 'mynaCheckCircle';
|
||||||
|
|||||||
@@ -2,11 +2,18 @@ import { Injectable, inject } from '@angular/core';
|
|||||||
import type { FileEntry } from '@shared/types';
|
import type { FileEntry } from '@shared/types';
|
||||||
import { DialogsService } from './dialogs.service';
|
import { DialogsService } from './dialogs.service';
|
||||||
import { joinPath } from './format';
|
import { joinPath } from './format';
|
||||||
import { type PanelState } from './panels.service';
|
import { type PanelState, type Side } from './panels.service';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from './rpc.service';
|
||||||
import { SettingsService } from './settings.service';
|
import { SettingsService } from './settings.service';
|
||||||
import { ToastService } from './toast.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
|
* 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.
|
* 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 dialogs = inject(DialogsService);
|
||||||
private readonly settings = inject(SettingsService);
|
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. */
|
/** Selection if non-empty, else the cursor entry. Never the ".." parent row. */
|
||||||
targets(panel: PanelState): FileEntry[] {
|
targets(panel: PanelState): FileEntry[] {
|
||||||
const selected = panel.selectedEntries();
|
const selected = panel.selectedEntries();
|
||||||
|
|||||||
@@ -210,6 +210,7 @@
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
(keydown)="onKeydown($event)"
|
(keydown)="onKeydown($event)"
|
||||||
(mousedown)="activate()"
|
(mousedown)="activate()"
|
||||||
|
(contextmenu)="listContextMenu($event)"
|
||||||
class="flex-1 overflow-y-auto outline-none"
|
class="flex-1 overflow-y-auto outline-none"
|
||||||
>
|
>
|
||||||
@for (entry of panel().sorted(); track entry.name; let i = $index) {
|
@for (entry of panel().sorted(); track entry.name; let i = $index) {
|
||||||
@@ -217,6 +218,7 @@
|
|||||||
[attr.data-index]="i"
|
[attr.data-index]="i"
|
||||||
(click)="rowClick($event, i)"
|
(click)="rowClick($event, i)"
|
||||||
(dblclick)="openAt(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="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-accent]="i === panel().cursor() && !isSelected(entry)"
|
||||||
[class.bg-primary/15]="isSelected(entry)"
|
[class.bg-primary/15]="isSelected(entry)"
|
||||||
|
|||||||
@@ -142,6 +142,24 @@ export class FilePanel {
|
|||||||
this.lastClickedIndex = index;
|
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 {
|
openAt(index: number): void {
|
||||||
const entry = this.panel().sorted()[index];
|
const entry = this.panel().sorted()[index];
|
||||||
if (entry) this.panel().open(entry);
|
if (entry) this.panel().open(entry);
|
||||||
|
|||||||
Reference in New Issue
Block a user