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:
Jokin
2026-07-11 19:17:25 +02:00
co-authored by Claude Fable 5
parent 20e5703d29
commit 2d7e7b44cd
6 changed files with 165 additions and 5 deletions
+13 -2
View File
@@ -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})`);
+88
View File
@@ -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
View File
@@ -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 {};
}),
+1
View File
@@ -82,6 +82,7 @@ export type AppRPC = {
messages: {
transferUpdate: TransferSnapshot;
sessionClosed: { sessionId: string; reason: string };
menuAction: { action: string };
};
};
};