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
+18 -3
View File
@@ -21,10 +21,25 @@
<app-status-bar />
}
<!-- dialogs -->
@if (dialogs.manager()) {
<app-connection-manager />
<!-- modal block: the connection-manager window owns the app until closed -->
@if (managerOpen()) {
<div
class="fixed inset-0 z-60 flex items-center justify-center bg-black/40"
(mousedown)="focusManager()"
>
<div class="rounded-lg border border-border bg-card px-5 py-4 text-center shadow-xl">
<ng-icon name="mynaServers" size="1.25rem" class="text-muted-foreground" />
<p class="mt-1.5 text-xs text-muted-foreground">The connection manager is open.</p>
<button
class="mt-3 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
Show Connections
</button>
</div>
</div>
}
<!-- dialogs -->
@if (dialogs.prompt(); as req) {
<app-prompt-dialog [request]="req" />
}
+32 -11
View File
@@ -1,7 +1,6 @@
import { Component, inject } from '@angular/core';
import { Component, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { BRANDING } from '@shared/branding';
import { ConnectionManager } from './connection-manager/connection-manager';
import { ConnectionsService } from './connections.service';
import { ChmodDialog } from './dialogs/chmod-dialog';
import { ConfirmDialog } from './dialogs/confirm-dialog';
@@ -32,7 +31,6 @@ import { TransfersService } from './transfers.service';
OverwriteDialog,
Toasts,
TransferBar,
ConnectionManager,
],
templateUrl: './app.html',
host: { '(window:keydown)': 'onWindowKeydown($event)' },
@@ -46,14 +44,22 @@ export class App {
readonly connections = inject(ConnectionsService);
private readonly transfers = inject(TransfersService);
/** The connection-manager window is open — this window is modal-blocked. */
readonly managerOpen = signal(false);
constructor() {
document.title = BRANDING.appName;
if (this.rpc.available) {
this.rpc.onMenuAction((action) => this.handleMenuAction(action));
this.rpc.onMenuAction((action) => void this.handleMenuAction(action));
this.rpc.onManagerState((open) => this.managerOpen.set(open));
void this.promptForEditLeftovers();
}
}
focusManager(): void {
void this.rpc.request.openConnectionManager({});
}
/** Temp files from external edits in a previous run (crash / quit mid-edit). */
private async promptForEditLeftovers(): Promise<void> {
const res = await this.rpc.request.getEditLeftovers({});
@@ -69,15 +75,22 @@ export class App {
});
}
/** Native application-menu actions forwarded from the bun process. */
private handleMenuAction(action: string): void {
/** A connection by id; reloads once if unseen (a manager-window save can
* race the connectionsChanged push). */
private async findConnection(id: string) {
const lookup = () => this.connections.connections().find((c) => c.id === id);
if (!lookup()) await this.connections.reload();
return lookup();
}
/** Native application-menu / manager-window actions forwarded from bun. */
private async handleMenuAction(action: string): Promise<void> {
if (action.startsWith('ctx-')) {
this.handleContextAction(action);
return;
}
if (action.startsWith('connect:')) {
const id = action.slice('connect:'.length);
const connection = this.connections.connections().find((c) => c.id === id);
const connection = await this.findConnection(action.slice('connect:'.length));
if (connection) {
void this.panels.connectPanel(
this.panels.activeSide(),
@@ -88,6 +101,16 @@ export class App {
}
return;
}
if (action.startsWith('open-stack:')) {
const id = action.slice('open-stack:'.length);
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
if (!stackConnections.length) {
await this.connections.reload();
stackConnections = this.connections.connections().filter((c) => c.stackId === id);
}
if (stackConnections.length) this.panels.openStack(stackConnections);
return;
}
switch (action) {
case 'new-tab':
this.panels.newWorkspace();
@@ -107,14 +130,12 @@ export class App {
case 'refresh-active':
void this.panels.active().refresh();
break;
case 'manage-connections':
this.dialogs.manager.set(true);
break;
}
}
/** Tab (workspace) shortcuts — panel-level keys live on the panels themselves. */
onWindowKeydown(event: KeyboardEvent): void {
if (this.managerOpen()) return; // modal-blocked behind the manager
const mod = event.metaKey || event.ctrlKey;
if (mod && event.key.toLowerCase() === 't') {
event.preventDefault();
@@ -1,17 +1,20 @@
<div class="fixed inset-0 z-30 grid place-items-center bg-black/40" (mousedown)="close()">
<div
class="flex h-[440px] w-[680px] flex-col rounded-lg border border-border bg-card text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
<div class="flex h-screen flex-col bg-background text-foreground">
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
elsewhere the native titlebar sits above and this is a compact strip -->
<header
class="flex shrink-0 items-center gap-2 border-b border-border pr-2"
[class.h-10]="isMacChrome"
[class.pl-24]="isMacChrome"
[class.electrobun-webkit-app-region-drag]="isMacChrome"
[class.h-9]="!isMacChrome"
[class.pl-3]="!isMacChrome"
>
<!-- title bar -->
<div class="flex items-center justify-between border-b border-border px-4 py-2.5">
<h2 class="text-sm font-semibold">Connections</h2>
<button (click)="close()" class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
<ng-icon name="mynaX" size="1rem" />
</button>
</div>
<ng-icon name="mynaServers" size="0.95rem" class="shrink-0 text-muted-foreground" />
<h1 class="text-sm font-semibold">Connections</h1>
<div class="flex-1"></div>
</header>
<div class="flex min-h-0 flex-1">
<div class="flex min-h-0 flex-1">
<!-- stacks + connections list -->
<div class="flex w-60 shrink-0 flex-col border-r border-border">
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
@@ -92,7 +95,7 @@
</button>
@if (group.connections.length) {
<button
(click)="openStack(group.connections)"
(click)="openStack(group.stack.id)"
title="Open stack in tabs"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-primary"
>
@@ -358,7 +361,16 @@
</div>
</div>
}
</div>
</div>
</div>
<!-- in-window dialogs (safe here: they depend only on FocusService) -->
@if (dialogs.prompt(); as req) {
<app-prompt-dialog [request]="req" />
}
@if (dialogs.confirm(); as req) {
<app-confirm-dialog [request]="req" />
}
<app-toasts />
</div>
@@ -2,9 +2,13 @@ import { Component, computed, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import type { SavedConnection, Stack } from '@shared/types';
import { ConnectionsService } from '../connections.service';
import { ConfirmDialog } from '../dialogs/confirm-dialog';
import { PromptDialog } from '../dialogs/prompt-dialog';
import { DialogsService } from '../dialogs.service';
import { PanelsService } from '../panels.service';
import { IS_MAC_CHROME } from '../platform';
import { RpcService } from '../rpc.service';
import { SettingsService } from '../settings.service';
import { Toasts } from '../toasts/toasts';
interface Draft {
id: string;
@@ -60,16 +64,25 @@ function fromDraft(d: Draft): SavedConnection {
};
}
/**
* Root component of the connection-manager window (__APP_MODE === 'manager').
* Modal to the main window (enforced bun-side); connect/open-stack requests
* are executed by the MAIN window — this window never touches panels.
*/
@Component({
selector: 'app-connection-manager',
imports: [NgIcon],
selector: 'app-root',
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts],
templateUrl: './connection-manager.html',
host: { '(window:keydown.escape)': 'onEscape()' },
})
export class ConnectionManager {
readonly store = inject(ConnectionsService);
readonly dialogs = inject(DialogsService);
readonly panels = inject(PanelsService);
readonly isMacChrome = IS_MAC_CHROME;
private readonly rpc = inject(RpcService);
// injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root
private readonly settings = inject(SettingsService);
readonly draft = signal<Draft | null>(null);
readonly busy = signal(false);
@@ -117,8 +130,12 @@ export class ConnectionManager {
}
close(): void {
this.dialogs.manager.set(false);
this.panels.refocus();
void this.rpc.request.managerClose({});
}
onEscape(): void {
// in-window dialogs (confirm/prompt) handle their own Escape first
if (!this.dialogs.anyOpen) this.close();
}
async save(): Promise<string | null> {
@@ -130,20 +147,14 @@ export class ConnectionManager {
}
async saveAndConnect(): Promise<void> {
const d = this.draft();
if (!d) return;
if (!this.draft()) return;
this.busy.set(true);
try {
const id = await this.save();
if (!id) return;
const connection = fromDraft({ ...d, id });
const ok = await this.panels.connectPanel(
this.panels.activeSide(),
connection,
connection.name,
connection.localDir,
);
if (ok) this.close();
// the main window connects (and this window closes); a failed connect
// surfaces as an error there
await this.rpc.request.managerConnect({ id });
} finally {
this.busy.set(false);
}
@@ -183,9 +194,9 @@ export class ConnectionManager {
// ---- stacks ----
openStack(connections: SavedConnection[]): void {
this.panels.openStack(connections);
this.close();
openStack(stackId: string): void {
// opened as tabs in the main window; bun closes this window after
void this.rpc.request.managerOpenStack({ id: stackId });
}
toggleStack(stack: Stack): void {
+2
View File
@@ -29,6 +29,8 @@ export class ConnectionsService {
if (this.rpc.available) {
void this.reload();
void this.reloadSshHosts();
// the connection-manager window edited the store — pick it up
this.rpc.onConnectionsChanged(() => void this.reload());
}
}
-1
View File
@@ -39,7 +39,6 @@ export class DialogsService {
readonly confirm = signal<ConfirmRequest | null>(null);
readonly chmod = signal<ChmodRequest | null>(null);
readonly overwrite = signal<OverwriteRequest | null>(null);
readonly manager = signal(false);
/** Ask what to do about one name collision; resolves with the user's choice. */
askOverwrite(request: Omit<OverwriteRequest, 'resolve'>): Promise<OverwriteChoice> {
-8
View File
@@ -140,14 +140,6 @@
}
}
<div class="my-1 border-t border-border"></div>
<button
(click)="openManager()"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="mynaCog" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
Manage connections…
</button>
</div>
}
</div>
-6
View File
@@ -121,12 +121,6 @@ export class FilePanel {
return `${host.username}@${host.hostname}:${host.port}`;
}
openManager(): void {
this.menuOpen.set(false);
this.activate();
this.dialogs.manager.set(true);
}
iconFor(entry: FileEntry): string {
return fileGlyph(entry, this.settings.iconColors() === 'multi');
}
+3 -3
View File
@@ -6,8 +6,8 @@ import type { EditorOpenContext } from '@shared/rpc';
declare global {
interface Window {
__APP_PLATFORM?: string;
/** Which root component this window boots: the full app or one editor. */
__APP_MODE?: 'main' | 'editor';
/** Which root component this window boots. */
__APP_MODE?: 'main' | 'editor' | 'manager';
/** Window-kind specific context (EditorOpenContext for editor windows). */
__APP_CTX?: unknown;
}
@@ -18,7 +18,7 @@ export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?
/** True when the window uses macOS hiddenInset chrome (header fused with titlebar). */
export const IS_MAC_CHROME = PLATFORM === 'darwin';
export const APP_MODE: 'main' | 'editor' =
export const APP_MODE: 'main' | 'editor' | 'manager' =
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
export const EDITOR_CONTEXT =
+16
View File
@@ -22,6 +22,8 @@ export class RpcService {
private settingsListeners = new Set<(settings: AppSettings) => void>();
private pathRefreshListeners = new Set<(e: { sessionId: string; dir: string }) => void>();
private editorCloseListeners = new Set<() => void>();
private managerStateListeners = new Set<(open: boolean) => void>();
private connectionsListeners = new Set<() => void>();
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
private ready: Promise<void> = Promise.resolve();
@@ -52,6 +54,12 @@ export class RpcService {
editorCloseRequest: () => {
for (const listener of this.editorCloseListeners) listener();
},
managerState: ({ open }) => {
for (const listener of this.managerStateListeners) listener(open);
},
connectionsChanged: () => {
for (const listener of this.connectionsListeners) listener();
},
},
},
});
@@ -92,6 +100,14 @@ export class RpcService {
this.editorCloseListeners.add(listener);
}
onManagerState(listener: (open: boolean) => void): void {
this.managerStateListeners.add(listener);
}
onConnectionsChanged(listener: () => void): void {
this.connectionsListeners.add(listener);
}
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
get request() {
const rpc = this.rpc;
+13
View File
@@ -101,6 +101,15 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<div class="flex-1"></div>
<!-- connection manager (own native window) -->
<button
(click)="openManager()"
title="Manage connections"
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaCog" size="0.9rem" />
</button>
<!-- global options -->
<button
(click)="settingsOpen.set(!settingsOpen())"
@@ -379,6 +388,10 @@ export class TabBar {
return base.replace(/\.app$/i, '');
}
openManager(): void {
void this.rpc.request.openConnectionManager({});
}
async pickEditorApp(): Promise<void> {
const res = await this.rpc.request.pickEditorApp({});
if (res.ok && res.path) this.settings.setEditorApp(res.path);
+3 -2
View File
@@ -1,9 +1,10 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
import { ConnectionManager } from './app/connection-manager/connection-manager';
import { EditorWindow } from './app/editor-window/editor-window';
import { APP_MODE } from './app/platform';
// One bundle, two window kinds: the preload global picks the root component.
const root = APP_MODE === 'editor' ? EditorWindow : App;
// One bundle, three window kinds: the preload global picks the root component.
const root = APP_MODE === 'editor' ? EditorWindow : APP_MODE === 'manager' ? ConnectionManager : App;
bootstrapApplication(root, appConfig).catch((err) => console.error(err));