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 <noreply@anthropic.com>
This commit is contained in:
+13
-2
@@ -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})`);
|
||||
|
||||
@@ -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<void> {
|
||||
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)`);
|
||||
}
|
||||
+16
-3
@@ -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<T extends object>(fn: () => Promise<T> | T): Promise<Result<T>> {
|
||||
@@ -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 {};
|
||||
}),
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ export type AppRPC = {
|
||||
messages: {
|
||||
transferUpdate: TransferSnapshot;
|
||||
sessionClosed: { sessionId: string; reason: string };
|
||||
menuAction: { action: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
||||
private ready: Promise<void> = 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;
|
||||
|
||||
Reference in New Issue
Block a user