From 2d7e7b44cd70d686152dd65da0ffe41e2dce7478 Mon Sep 17 00:00:00 2001 From: Jokin Date: Sat, 11 Jul 2026 19:17:25 +0200 Subject: [PATCH] macOS application menu Standard menus (App: hide/quit; Edit: clipboard roles enabling Cmd+C/V/X/Z; View: Refresh Cmd+R + fullscreen; Window: minimize/zoom/cycle) plus File with New/Close Tab (Cmd+T/W), New Folder/File, and a Connect submenu built from saved connections (folders as nested submenus, Connection Manager entry). Custom actions forward to the webview as menuAction messages; the menu rebuilds whenever connections change. macOS-only per electrobun support. Co-Authored-By: Claude Fable 5 --- src/bun/index.ts | 15 ++++++- src/bun/menu.ts | 88 +++++++++++++++++++++++++++++++++++++++ src/bun/rpc-handlers.ts | 19 +++++++-- src/shared/rpc.ts | 1 + ui/src/app/app.ts | 39 +++++++++++++++++ ui/src/app/rpc.service.ts | 8 ++++ 6 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 src/bun/menu.ts diff --git a/src/bun/index.ts b/src/bun/index.ts index 859d1a3..c3a6ae4 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -1,7 +1,8 @@ -import { BrowserWindow, Updater } from "electrobun/bun"; +import { ApplicationMenu, BrowserWindow, Updater } from "electrobun/bun"; import { BRANDING } from "../shared/branding"; import { effectiveConcurrency } from "../shared/types"; import { SessionRegistry } from "./fs/registry"; +import { applyApplicationMenu } from "./menu"; import { createAppRPC } from "./rpc-handlers"; import { ConnectionStore } from "./store/connections"; import { SettingsStore } from "./store/settings"; @@ -31,7 +32,10 @@ const registry = new SessionRegistry(); const engine = new TransferEngine(registry); const store = new ConnectionStore(); const settings = new SettingsStore(); -const events = { sessionClosed: (_info: { sessionId: string; reason: string }) => {} }; +const events = { + sessionClosed: (_info: { sessionId: string; reason: string }) => {}, + connectionsChanged: () => {}, +}; const rpc = createAppRPC({ registry, engine, store, settings, events }); const url = await getMainViewUrl(); @@ -59,6 +63,13 @@ new BrowserWindow({ engine.onUpdate = (snapshot) => rpc.send.transferUpdate(snapshot); events.sessionClosed = (info) => rpc.send.sessionClosed(info); +events.connectionsChanged = () => void applyApplicationMenu(store); void settings.load().then((s) => engine.setConcurrency(effectiveConcurrency(s))); +void applyApplicationMenu(store); +ApplicationMenu.on("application-menu-clicked", (event) => { + const action = (event as { data?: { action?: string } }).data?.action; + if (action) rpc.send.menuAction({ action }); +}); + console.log(`${BRANDING.appName} started (${url})`); diff --git a/src/bun/menu.ts b/src/bun/menu.ts new file mode 100644 index 0000000..3b0f541 --- /dev/null +++ b/src/bun/menu.ts @@ -0,0 +1,88 @@ +import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/bun"; +import type { SavedConnection } from "../shared/types"; +import type { ConnectionStore } from "./store/connections"; + +/** + * macOS application menu (electrobun menus are macOS-only). Standard roles plus + * app actions; custom actions are forwarded to the webview as menuAction + * messages. Rebuilt whenever saved connections change so the Connect submenu + * stays current. + */ +export async function applyApplicationMenu(store: ConnectionStore): Promise { + if (process.platform !== "darwin") return; + + const { folders, connections } = await store.load(); + const item = (c: SavedConnection): ApplicationMenuItemConfig => ({ + label: c.name, + action: `connect:${c.id}`, + }); + const rootItems = connections.filter((c) => !c.folderId).map(item); + const folderMenus: ApplicationMenuItemConfig[] = folders.map((folder) => ({ + label: folder.name, + submenu: connections.filter((c) => c.folderId === folder.id).map(item), + })); + + const connectSubmenu: ApplicationMenuItemConfig[] = [ + ...rootItems, + ...folderMenus, + ...(rootItems.length || folderMenus.length ? [{ type: "separator" } as const] : []), + { label: "Connection Manager…", action: "manage-connections" }, + ]; + + ApplicationMenu.setApplicationMenu([ + { + submenu: [ + { role: "hide" }, + { role: "hideOthers" }, + { role: "showAll" }, + { type: "separator" }, + { label: "Quit", role: "quit" }, + ], + }, + { + label: "File", + submenu: [ + { label: "New Tab", action: "new-tab", accelerator: "t" }, + { label: "Close Tab", action: "close-tab", accelerator: "w" }, + { type: "separator" }, + { label: "New Folder", action: "new-folder" }, + { label: "New File", action: "new-file" }, + { type: "separator" }, + { label: "Connect", submenu: connectSubmenu }, + ], + }, + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { role: "pasteAndMatchStyle" }, + { role: "delete" }, + { role: "selectAll" }, + ], + }, + { + label: "View", + submenu: [ + { label: "Refresh", action: "refresh-active", accelerator: "r" }, + { type: "separator" }, + { role: "toggleFullScreen" }, + ], + }, + { + label: "Window", + submenu: [ + { role: "minimize" }, + { role: "zoom" }, + { type: "separator" }, + { role: "cycleThroughWindows" }, + { role: "bringAllToFront" }, + ], + }, + ]); + console.log(`[menu] applied (${connections.length} connections)`); +} diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts index db21cdb..894bdb7 100644 --- a/src/bun/rpc-handlers.ts +++ b/src/bun/rpc-handlers.ts @@ -18,7 +18,10 @@ export interface AppDeps { store: ConnectionStore; settings: SettingsStore; /** Assigned after the rpc object exists (it needs rpc.send). */ - events: { sessionClosed: (info: { sessionId: string; reason: string }) => void }; + events: { + sessionClosed: (info: { sessionId: string; reason: string }) => void; + connectionsChanged: () => void; + }; } async function wrap(fn: () => Promise | T): Promise> { @@ -121,20 +124,30 @@ export function createAppRPC({ registry, engine, store, settings, events }: AppD getConnections: () => wrap(async () => ({ store: await store.load() })), upsertConnection: ({ connection }) => - wrap(async () => ({ id: await store.upsertConnection(connection) })), + wrap(async () => { + const id = await store.upsertConnection(connection); + events.connectionsChanged(); + return { id }; + }), deleteConnection: ({ id }) => wrap(async () => { await store.deleteConnection(id); + events.connectionsChanged(); return {}; }), upsertFolder: ({ folder }) => - wrap(async () => ({ id: await store.upsertFolder(folder) })), + wrap(async () => { + const id = await store.upsertFolder(folder); + events.connectionsChanged(); + return { id }; + }), deleteFolder: ({ id }) => wrap(async () => { await store.deleteFolder(id); + events.connectionsChanged(); return {}; }), diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index a366c25..3eb3eb4 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -82,6 +82,7 @@ export type AppRPC = { messages: { transferUpdate: TransferSnapshot; sessionClosed: { sessionId: string; reason: string }; + menuAction: { action: string }; }; }; }; diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 46f36a5..6e8429f 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -2,11 +2,13 @@ import { Component, inject } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import { BRANDING } from '@shared/branding'; import { ConnectionManager } from './connection-manager/connection-manager'; +import { ConnectionsService } from './connections.service'; import { ChmodDialog } from './dialogs/chmod-dialog'; import { ConfirmDialog } from './dialogs/confirm-dialog'; import { OverwriteDialog } from './dialogs/overwrite-dialog'; import { PromptDialog } from './dialogs/prompt-dialog'; import { DialogsService } from './dialogs.service'; +import { FileActionsService } from './file-actions.service'; import { FilePanel } from './file-panel/file-panel'; import { PanelsService } from './panels.service'; import { RpcService } from './rpc.service'; @@ -37,9 +39,46 @@ export class App { readonly toast = inject(ToastService); readonly dialogs = inject(DialogsService); readonly panels = inject(PanelsService); + readonly actions = inject(FileActionsService); + readonly connections = inject(ConnectionsService); constructor() { document.title = BRANDING.appName; + if (this.rpc.available) { + this.rpc.onMenuAction((action) => this.handleMenuAction(action)); + } + } + + /** Native application-menu actions forwarded from the bun process. */ + private handleMenuAction(action: string): void { + if (action.startsWith('connect:')) { + const id = action.slice('connect:'.length); + const connection = this.connections.connections().find((c) => c.id === id); + if (connection) { + void this.panels.connectPanel(this.panels.activeSide(), connection, connection.name); + } + return; + } + switch (action) { + case 'new-tab': + this.panels.newWorkspace(); + break; + case 'close-tab': + this.panels.closeWorkspace(this.panels.activeWorkspaceId()); + break; + case 'new-folder': + this.actions.mkdir(this.panels.active()); + break; + case 'new-file': + this.actions.createFile(this.panels.active()); + break; + case 'refresh-active': + void this.panels.active().refresh(); + break; + case 'manage-connections': + this.dialogs.manager.set(true); + break; + } } /** Tab (workspace) shortcuts — panel-level keys live on the panels themselves. */ diff --git a/ui/src/app/rpc.service.ts b/ui/src/app/rpc.service.ts index 74d7cbd..b787ddf 100644 --- a/ui/src/app/rpc.service.ts +++ b/ui/src/app/rpc.service.ts @@ -18,6 +18,7 @@ export class RpcService { // (this froze the app), and signal coalescing would drop progress snapshots. private transferListeners = new Set<(s: TransferSnapshot) => void>(); private closedListeners = new Set<(e: SessionClosedEvent) => void>(); + private menuListeners = new Set<(action: string) => void>(); private rpc: ReturnType> | undefined; private ready: Promise = Promise.resolve(); @@ -36,6 +37,9 @@ export class RpcService { sessionClosed: (event) => { for (const listener of this.closedListeners) listener(event); }, + menuAction: ({ action }) => { + for (const listener of this.menuListeners) listener(action); + }, }, }, }); @@ -60,6 +64,10 @@ export class RpcService { this.closedListeners.add(listener); } + onMenuAction(listener: (action: string) => void): void { + this.menuListeners.add(listener); + } + /** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */ get request() { const rpc = this.rpc;