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