diff --git a/src/bun/context-menu.ts b/src/bun/context-menu.ts new file mode 100644 index 0000000..f1b9dd8 --- /dev/null +++ b/src/bun/context-menu.ts @@ -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; + } +} diff --git a/src/bun/index.ts b/src/bun/index.ts index 997b45b..00f91ce 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -1,6 +1,7 @@ import { ApplicationMenu, BrowserWindow, Updater } from "electrobun/bun"; import { BRANDING } from "../shared/branding"; import { effectiveConcurrency } from "../shared/types"; +import { PanelContextMenu } from "./context-menu"; import { EditManager } from "./edits"; import { SessionRegistry } from "./fs/registry"; import { applyApplicationMenu } from "./menu"; @@ -35,11 +36,12 @@ const engine = new TransferEngine(registry); const store = new ConnectionStore(); const settings = new SettingsStore(); const edits = new EditManager(registry, engine, settings); +const contextMenu = new PanelContextMenu(); const events = { sessionClosed: (_info: { sessionId: string; reason: string }) => {}, 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 isMac = process.platform === "darwin"; @@ -65,6 +67,7 @@ const win = new BrowserWindow({ }); new AppTray(win); +contextMenu.onAction = (action) => rpc.send.menuAction({ action }); engine.onUpdate = (snapshot) => rpc.send.transferUpdate(snapshot); events.sessionClosed = (info) => rpc.send.sessionClosed(info); diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts index fcf367a..c91841c 100644 --- a/src/bun/rpc-handlers.ts +++ b/src/bun/rpc-handlers.ts @@ -1,6 +1,7 @@ import { BrowserView, Utils } from "electrobun/bun"; import type { AppRPC } from "../shared/rpc"; import { effectiveConcurrency, type Result } from "../shared/types"; +import type { PanelContextMenu } from "./context-menu"; import { readFileText, writeFileText, type EditManager } from "./edits"; import type { SessionRegistry } from "./fs/registry"; import { SftpProvider } from "./fs/sftp"; @@ -19,6 +20,7 @@ export interface AppDeps { store: ConnectionStore; settings: SettingsStore; edits: EditManager; + contextMenu: PanelContextMenu; /** Assigned after the rpc object exists (it needs rpc.send). */ events: { sessionClosed: (info: { sessionId: string; reason: string }) => void; @@ -34,7 +36,15 @@ async function wrap(fn: () => Promise | T): Promise({ maxRequestTime: MAX_REQUEST_TIME_MS, handlers: { @@ -201,6 +211,19 @@ export function createAppRPC({ registry, engine, store, settings, edits, events await edits.clearLeftovers(); return {}; }), + + // ---- context menu + local shell integration ---- + showRowMenu: (params) => + wrap(async () => { + contextMenu.show(params); + return {}; + }), + + moveToTrash: ({ path }) => + wrap(async () => { + Utils.moveToTrash(path); + return {}; + }), }, }, }); diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index 3c2ff18..5cc1b80 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -96,6 +96,18 @@ export type AppRPC = { response: Result<{ names: string[] }>; }; clearEditLeftovers: { params: Record; 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; }; diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index a45f3c1..577baa9 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -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'; diff --git a/ui/src/app/file-actions.service.ts b/ui/src/app/file-actions.service.ts index ee248e9..b6c182a 100644 --- a/ui/src/app/file-actions.service.ts +++ b/ui/src/app/file-actions.service.ts @@ -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 { + 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 { + 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(); diff --git a/ui/src/app/file-panel/file-panel.html b/ui/src/app/file-panel/file-panel.html index 62f1b5a..293a535 100644 --- a/ui/src/app/file-panel/file-panel.html +++ b/ui/src/app/file-panel/file-panel.html @@ -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)" diff --git a/ui/src/app/file-panel/file-panel.ts b/ui/src/app/file-panel/file-panel.ts index 86ebb5b..374bf6d 100644 --- a/ui/src/app/file-panel/file-panel.ts +++ b/ui/src/app/file-panel/file-panel.ts @@ -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);