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
+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 {};
}),
},
},
});
}