Connection manager in its own native window, modal to main

Single window centered over the main app. Electrobun has no native
modality, so it's approximated: bun bounces the main window's focus back
to the manager, the main webview shows a click-swallowing overlay, and
main-targeted menu/keyboard actions are dropped while open. Editor
windows stay fully usable. Connect/open-stack execute in the main
window via the existing connect: action channel; store edits push a
connectionsChanged refresh. Trigger moved from the panel dropdown to a
tab-bar button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-13 23:11:58 +02:00
co-authored by Claude Fable 5
parent 27d0ac81d6
commit 7dd59a9752
17 changed files with 420 additions and 136 deletions
+37 -4
View File
@@ -6,6 +6,7 @@ import { PanelContextMenu } from "./context-menu";
import { EditManager } from "./edits";
import { EditorWindows } from "./editor-windows";
import { SessionRegistry } from "./fs/registry";
import { ManagerWindow } from "./manager-window";
import { applyApplicationMenu } from "./menu";
import { createAppRPC } from "./rpc-handlers";
import { ConnectionStore } from "./store/connections";
@@ -49,6 +50,7 @@ const events = {
connectionsChanged: () => {},
openEditor: (_ctx: EditorOpenContext) => {},
settingsChanged: (_settings: AppSettings) => {},
openConnectionManager: () => {},
};
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events });
const url = await getMainViewUrl();
@@ -83,17 +85,42 @@ const editors = new EditorWindows({
mainFrame: () => win.getFrame(),
notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) },
});
const manager = new ManagerWindow({
store,
settings,
url,
mainFrame: () => win.getFrame(),
notifyMain: {
managerState: (info) => rpc.send.managerState(info),
connectionsChanged: () => rpc.send.connectionsChanged({}),
menuAction: (info) => rpc.send.menuAction(info),
},
rebuildMenu: () => void applyApplicationMenu(store),
});
events.openEditor = (ctx) => editors.open(ctx);
events.settingsChanged = (s) => editors.broadcastSettings(s);
// clean editors close with the main window; dirty ones get to ask first
win.on("close", () => editors.closeAllForAppExit());
events.settingsChanged = (s) => {
editors.broadcastSettings(s);
manager.broadcastSettings(s);
};
events.openConnectionManager = () => manager.open();
// clean editors close with the main window; dirty ones get to ask first,
// and the modal manager has nothing to guard
win.on("close", () => {
manager.close();
editors.closeAllForAppExit();
});
new AppTray(win);
contextMenu.onAction = (action) => rpc.send.menuAction({ action });
// notify on finished transfers while the window is in the background
let windowFocused = true;
win.on("focus", () => (windowFocused = true));
win.on("focus", () => {
windowFocused = true;
// modal bounce: the main window can't take focus while the manager is open
if (manager.isOpen()) manager.activate();
});
win.on("blur", () => (windowFocused = false));
const notifiedStates = new Map<string, string>();
@@ -173,6 +200,12 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
return;
}
}
if (action === "manage-connections") {
manager.open();
return;
}
// the manager is modal to the main window — don't drive main behind it
if (manager.isOpen()) return;
rpc.send.menuAction({ action });
});
+103
View File
@@ -0,0 +1,103 @@
import type { BrowserWindow } from "electrobun/bun";
import type { AppSettings } from "../shared/types";
import { createManagerRPC } from "./rpc-handlers";
import type { ConnectionStore } from "./store/connections";
import type { SettingsStore } from "./store/settings";
import { openSecondaryWindow } from "./windows";
const MANAGER_WIDTH = 760;
const MANAGER_HEIGHT = 520;
export interface ManagerWindowDeps {
store: ConnectionStore;
settings: SettingsStore;
/** The once-resolved dev/prod view url. */
url: string;
mainFrame: () => { x: number; y: number; width: number; height: number };
/** Late-bound senders targeting the MAIN window's webview. */
notifyMain: {
managerState: (info: { open: boolean }) => void;
connectionsChanged: () => void;
menuAction: (info: { action: string }) => void;
};
/** Rebuilds the native app menu after store changes. */
rebuildMenu: () => void;
}
/**
* The single connection-manager window, modal to the main window. electrobun
* has no native modality, so it's approximated from two sides: index.ts
* bounces the main window's focus back here while open, and the main webview
* shows a click-swallowing overlay (driven by the managerState message).
* Editor windows are deliberately unaffected.
*/
export class ManagerWindow {
private win: BrowserWindow | null = null;
private rpc: ReturnType<typeof createManagerRPC> | null = null;
constructor(private deps: ManagerWindowDeps) {}
isOpen(): boolean {
return this.win !== null;
}
activate(): void {
this.win?.activate();
}
open(): void {
if (this.win) {
this.win.activate();
return;
}
this.rpc = createManagerRPC(
{ store: this.deps.store, settings: this.deps.settings },
{
onChanged: () => {
this.deps.rebuildMenu();
this.deps.notifyMain.connectionsChanged();
},
// connect/open-stack run in the main window; the manager's job is done
onConnect: (id) => {
this.deps.notifyMain.menuAction({ action: `connect:${id}` });
this.close();
},
onOpenStack: (id) => {
this.deps.notifyMain.menuAction({ action: `open-stack:${id}` });
this.close();
},
onClose: () => this.close(),
},
);
const main = this.deps.mainFrame();
this.win = openSecondaryWindow({
title: "Connections",
url: this.deps.url,
mode: "manager",
frame: {
x: Math.max(0, main.x + (main.width - MANAGER_WIDTH) / 2),
y: Math.max(0, main.y + (main.height - MANAGER_HEIGHT) / 2),
width: MANAGER_WIDTH,
height: MANAGER_HEIGHT,
},
rpc: this.rpc,
});
this.win.on("close", () => {
this.win = null;
this.rpc = null;
this.deps.notifyMain.managerState({ open: false });
});
this.deps.notifyMain.managerState({ open: true });
console.log("[manager] window opened");
}
close(): void {
this.win?.close(); // close event does the cleanup + notification
}
broadcastSettings(settings: AppSettings): void {
this.rpc?.send.settingsChanged({ settings });
}
}
+124 -63
View File
@@ -29,6 +29,7 @@ export interface AppDeps {
connectionsChanged: () => void;
openEditor: (ctx: EditorOpenContext) => void;
settingsChanged: (settings: AppSettings) => void;
openConnectionManager: () => void;
};
}
@@ -40,6 +41,76 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
}
}
/** Connection-store requests, shared by the main and manager window rpcs. */
function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) {
return {
getConnections: () => wrap(async () => ({ store: await store.load() })),
upsertConnection: ({ connection }: { connection: Parameters<ConnectionStore["upsertConnection"]>[0] }) =>
wrap(async () => {
const id = await store.upsertConnection(connection);
onChanged();
return { id };
}),
deleteConnection: ({ id }: { id: string }) =>
wrap(async () => {
await store.deleteConnection(id);
onChanged();
return {};
}),
upsertStack: ({ stack }: { stack: Parameters<ConnectionStore["upsertStack"]>[0] }) =>
wrap(async () => {
const id = await store.upsertStack(stack);
onChanged();
return { id };
}),
deleteStack: ({ id }: { id: string }) =>
wrap(async () => {
await store.deleteStack(id);
onChanged();
return {};
}),
importWinScp: () =>
wrap(async () => {
const { homedir } = await import("node:os");
const picked = await Utils.openFileDialog({
startingFolder: homedir(),
allowedFileTypes: "*",
canChooseFiles: true,
canChooseDirectory: false,
allowsMultipleSelection: false,
});
const file = picked.find((p) => p.trim().length > 0);
// dialog cancelled — all-zero result, UI shows nothing
if (!file) return { connections: 0, stacks: 0, skipped: 0 };
const text = await (await import("node:fs/promises")).readFile(file, "utf8");
const result = await importWinScpIni(text, store);
onChanged();
return result;
}),
getSshConfigHosts: () => wrap(async () => ({ hosts: loadSshConfigHosts() })),
};
}
const pickKeyFileHandler = () =>
wrap(async () => {
const { homedir } = await import("node:os");
const picked = await Utils.openFileDialog({
startingFolder: `${homedir()}/.ssh`,
allowedFileTypes: "*",
canChooseFiles: true,
canChooseDirectory: false,
allowsMultipleSelection: false,
});
const path = picked.find((p) => p.trim().length > 0) ?? null;
return { path };
});
export function createAppRPC({
registry,
engine,
@@ -139,56 +210,7 @@ export function createAppRPC({
}),
// ---- connection store ----
getConnections: () => wrap(async () => ({ store: await store.load() })),
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 {};
}),
upsertStack: ({ stack }) =>
wrap(async () => {
const id = await store.upsertStack(stack);
events.connectionsChanged();
return { id };
}),
deleteStack: ({ id }) =>
wrap(async () => {
await store.deleteStack(id);
events.connectionsChanged();
return {};
}),
importWinScp: () =>
wrap(async () => {
const { homedir } = await import("node:os");
const picked = await Utils.openFileDialog({
startingFolder: homedir(),
allowedFileTypes: "*",
canChooseFiles: true,
canChooseDirectory: false,
allowsMultipleSelection: false,
});
const file = picked.find((p) => p.trim().length > 0);
// dialog cancelled — all-zero result, UI shows nothing
if (!file) return { connections: 0, stacks: 0, skipped: 0 };
const text = await (await import("node:fs/promises")).readFile(file, "utf8");
const result = await importWinScpIni(text, store);
events.connectionsChanged();
return result;
}),
getSshConfigHosts: () => wrap(async () => ({ hosts: loadSshConfigHosts() })),
...connectionStoreHandlers(store, () => events.connectionsChanged()),
// ---- settings ----
getSettings: () => wrap(async () => ({ settings: await settings.load() })),
@@ -229,19 +251,7 @@ export function createAppRPC({
return { path };
}),
pickKeyFile: () =>
wrap(async () => {
const { homedir } = await import("node:os");
const picked = await Utils.openFileDialog({
startingFolder: `${homedir()}/.ssh`,
allowedFileTypes: "*",
canChooseFiles: true,
canChooseDirectory: false,
allowsMultipleSelection: false,
});
const path = picked.find((p) => p.trim().length > 0) ?? null;
return { path };
}),
pickKeyFile: pickKeyFileHandler,
getEditLeftovers: () =>
wrap(async () => ({ names: await edits.listLeftovers() })),
@@ -271,6 +281,13 @@ export function createAppRPC({
events.openEditor(ctx);
return {};
}),
// ---- connection-manager window ----
openConnectionManager: () =>
wrap(async () => {
events.openConnectionManager();
return {};
}),
},
},
});
@@ -326,3 +343,47 @@ export function createEditorRPC(
},
});
}
export interface ManagerRpcHooks {
/** Store changed: rebuild the app menu + tell the main window to reload. */
onChanged: () => void;
/** Connect / open-stack execute in the MAIN window's workspace. */
onConnect: (id: string) => void;
onOpenStack: (id: string) => void;
onClose: () => void;
}
/** RPC for the single connection-manager window (per-window instance). */
export function createManagerRPC(
deps: { store: ConnectionStore; settings: SettingsStore },
hooks: ManagerRpcHooks,
) {
return BrowserView.defineRPC<AppRPC>({
maxRequestTime: MAX_REQUEST_TIME_MS,
handlers: {
requests: {
...connectionStoreHandlers(deps.store, hooks.onChanged),
pickKeyFile: pickKeyFileHandler,
getSettings: () => wrap(async () => ({ settings: await deps.settings.load() })),
managerConnect: ({ id }) =>
wrap(async () => {
hooks.onConnect(id);
return {};
}),
managerOpenStack: ({ id }) =>
wrap(async () => {
hooks.onOpenStack(id);
return {};
}),
managerClose: () =>
wrap(async () => {
hooks.onClose();
return {};
}),
},
},
});
}
+2 -2
View File
@@ -7,7 +7,7 @@ const isMac = process.platform === "darwin";
* reads them in ui/src/app/platform.ts before first paint (window chrome and
* root-component selection both depend on them).
*/
export function buildPreload(mode: "main" | "editor", ctx?: unknown): string {
export function buildPreload(mode: "main" | "editor" | "manager", ctx?: unknown): string {
let script =
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
`window.__APP_MODE=${JSON.stringify(mode)};`;
@@ -19,7 +19,7 @@ export interface SecondaryWindowOptions {
title: string;
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
url: string;
mode: "editor"; // widens when more window kinds exist (connection manager…)
mode: "editor" | "manager";
ctx?: unknown;
frame: { x: number; y: number; width: number; height: number };
/** Per-window rpc instance. NEVER share one across windows — each