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:
+18
-3
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user