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:
+37
-4
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user