Compare commits

..
5 Commits
Author SHA1 Message Date
JokinandClaude Fable 5 485603fd30 Saved connections and stacks open remote on the right panel
Local left, remote right — the commander convention. Panel-dropdown
connects still target the panel they were opened from.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:18:34 +02:00
JokinandClaude Fable 5 d22bc9b4e2 Connection manager design refresh; fix stack-row button crowding
Import moves to the window header; sidebar widens with a muted tint;
the stack row's count and action buttons swap on hover instead of
sharing the row, so long names can't collide with them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:13:53 +02:00
JokinandClaude Fable 5 7dd59a9752 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>
2026-07-13 23:11:58 +02:00
Jokin 27d0ac81d6 file icons setting text 2026-07-13 23:04:16 +02:00
JokinandClaude Fable 5 62ff845944 Decouple dialog refocus from PanelsService via FocusService
Dialogs only needed refocus(); injecting PanelsService boots a whole
workspace, which secondary windows must not do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:02:25 +02:00
23 changed files with 501 additions and 197 deletions
+37 -4
View File
@@ -6,6 +6,7 @@ import { PanelContextMenu } from "./context-menu";
import { EditManager } from "./edits"; import { EditManager } from "./edits";
import { EditorWindows } from "./editor-windows"; import { EditorWindows } from "./editor-windows";
import { SessionRegistry } from "./fs/registry"; import { SessionRegistry } from "./fs/registry";
import { ManagerWindow } from "./manager-window";
import { applyApplicationMenu } from "./menu"; import { applyApplicationMenu } from "./menu";
import { createAppRPC } from "./rpc-handlers"; import { createAppRPC } from "./rpc-handlers";
import { ConnectionStore } from "./store/connections"; import { ConnectionStore } from "./store/connections";
@@ -49,6 +50,7 @@ const events = {
connectionsChanged: () => {}, connectionsChanged: () => {},
openEditor: (_ctx: EditorOpenContext) => {}, openEditor: (_ctx: EditorOpenContext) => {},
settingsChanged: (_settings: AppSettings) => {}, settingsChanged: (_settings: AppSettings) => {},
openConnectionManager: () => {},
}; };
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events }); const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events });
const url = await getMainViewUrl(); const url = await getMainViewUrl();
@@ -83,17 +85,42 @@ const editors = new EditorWindows({
mainFrame: () => win.getFrame(), mainFrame: () => win.getFrame(),
notifyMain: { pathRefresh: (info) => rpc.send.pathRefresh(info) }, 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.openEditor = (ctx) => editors.open(ctx);
events.settingsChanged = (s) => editors.broadcastSettings(s); events.settingsChanged = (s) => {
// clean editors close with the main window; dirty ones get to ask first editors.broadcastSettings(s);
win.on("close", () => editors.closeAllForAppExit()); 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); new AppTray(win);
contextMenu.onAction = (action) => rpc.send.menuAction({ action }); contextMenu.onAction = (action) => rpc.send.menuAction({ action });
// notify on finished transfers while the window is in the background // notify on finished transfers while the window is in the background
let windowFocused = true; 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)); win.on("blur", () => (windowFocused = false));
const notifiedStates = new Map<string, string>(); const notifiedStates = new Map<string, string>();
@@ -173,6 +200,12 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
return; 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 }); rpc.send.menuAction({ action });
}); });
+103
View File
@@ -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
View File
@@ -29,6 +29,7 @@ export interface AppDeps {
connectionsChanged: () => void; connectionsChanged: () => void;
openEditor: (ctx: EditorOpenContext) => void; openEditor: (ctx: EditorOpenContext) => void;
settingsChanged: (settings: AppSettings) => 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({ export function createAppRPC({
registry, registry,
engine, engine,
@@ -139,56 +210,7 @@ export function createAppRPC({
}), }),
// ---- connection store ---- // ---- connection store ----
getConnections: () => wrap(async () => ({ store: await store.load() })), ...connectionStoreHandlers(store, () => events.connectionsChanged()),
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() })),
// ---- settings ---- // ---- settings ----
getSettings: () => wrap(async () => ({ settings: await settings.load() })), getSettings: () => wrap(async () => ({ settings: await settings.load() })),
@@ -229,19 +251,7 @@ export function createAppRPC({
return { path }; return { path };
}), }),
pickKeyFile: () => pickKeyFile: 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 };
}),
getEditLeftovers: () => getEditLeftovers: () =>
wrap(async () => ({ names: await edits.listLeftovers() })), wrap(async () => ({ names: await edits.listLeftovers() })),
@@ -271,6 +281,13 @@ export function createAppRPC({
events.openEditor(ctx); events.openEditor(ctx);
return {}; 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
View File
@@ -7,7 +7,7 @@ const isMac = process.platform === "darwin";
* reads them in ui/src/app/platform.ts before first paint (window chrome and * reads them in ui/src/app/platform.ts before first paint (window chrome and
* root-component selection both depend on them). * 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 = let script =
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` + `window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
`window.__APP_MODE=${JSON.stringify(mode)};`; `window.__APP_MODE=${JSON.stringify(mode)};`;
@@ -19,7 +19,7 @@ export interface SecondaryWindowOptions {
title: string; title: string;
/** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */ /** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */
url: string; url: string;
mode: "editor"; // widens when more window kinds exist (connection manager…) mode: "editor" | "manager";
ctx?: unknown; ctx?: unknown;
frame: { x: number; y: number; width: number; height: number }; frame: { x: number; y: number; width: number; height: number };
/** Per-window rpc instance. NEVER share one across windows — each /** Per-window rpc instance. NEVER share one across windows — each
+11
View File
@@ -140,6 +140,13 @@ export type AppRPC = {
/** Editor windows only — handled by that window's own rpc closure. */ /** Editor windows only — handled by that window's own rpc closure. */
editorSave: { params: { content: string }; response: Result }; editorSave: { params: { content: string }; response: Result };
editorClose: { params: Record<string, never>; response: Result }; editorClose: { params: Record<string, never>; response: Result };
// connection-manager window
/** Main window: open (or focus) the single connection-manager window. */
openConnectionManager: { params: Record<string, never>; response: Result };
/** Manager window only: connect/open-stack run in the MAIN window. */
managerConnect: { params: { id: string }; response: Result };
managerOpenStack: { params: { id: string }; response: Result };
managerClose: { params: Record<string, never>; response: Result };
}; };
messages: { messages: {
/** Editor → bun draft mirror; safety net for the non-cancellable native close. */ /** Editor → bun draft mirror; safety net for the non-cancellable native close. */
@@ -158,6 +165,10 @@ export type AppRPC = {
pathRefresh: { sessionId: string; dir: string }; pathRefresh: { sessionId: string; dir: string };
/** bun → one editor: run the in-window close flow (dirty prompt). */ /** bun → one editor: run the in-window close flow (dirty prompt). */
editorCloseRequest: Record<string, never>; editorCloseRequest: Record<string, never>;
/** bun → main: the modal connection manager opened/closed. */
managerState: { open: boolean };
/** bun → main: the manager window changed the connection store. */
connectionsChanged: Record<string, never>;
}; };
}; };
}; };
+18 -3
View File
@@ -21,10 +21,25 @@
<app-status-bar /> <app-status-bar />
} }
<!-- dialogs --> <!-- modal block: the connection-manager window owns the app until closed -->
@if (dialogs.manager()) { @if (managerOpen()) {
<app-connection-manager /> <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) { @if (dialogs.prompt(); as req) {
<app-prompt-dialog [request]="req" /> <app-prompt-dialog [request]="req" />
} }
+34 -17
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 { NgIcon } from '@ng-icons/core';
import { BRANDING } from '@shared/branding'; import { BRANDING } from '@shared/branding';
import { ConnectionManager } from './connection-manager/connection-manager';
import { ConnectionsService } from './connections.service'; import { ConnectionsService } from './connections.service';
import { ChmodDialog } from './dialogs/chmod-dialog'; import { ChmodDialog } from './dialogs/chmod-dialog';
import { ConfirmDialog } from './dialogs/confirm-dialog'; import { ConfirmDialog } from './dialogs/confirm-dialog';
@@ -32,7 +31,6 @@ import { TransfersService } from './transfers.service';
OverwriteDialog, OverwriteDialog,
Toasts, Toasts,
TransferBar, TransferBar,
ConnectionManager,
], ],
templateUrl: './app.html', templateUrl: './app.html',
host: { '(window:keydown)': 'onWindowKeydown($event)' }, host: { '(window:keydown)': 'onWindowKeydown($event)' },
@@ -46,14 +44,22 @@ export class App {
readonly connections = inject(ConnectionsService); readonly connections = inject(ConnectionsService);
private readonly transfers = inject(TransfersService); private readonly transfers = inject(TransfersService);
/** The connection-manager window is open — this window is modal-blocked. */
readonly managerOpen = signal(false);
constructor() { constructor() {
document.title = BRANDING.appName; document.title = BRANDING.appName;
if (this.rpc.available) { 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(); void this.promptForEditLeftovers();
} }
} }
focusManager(): void {
void this.rpc.request.openConnectionManager({});
}
/** Temp files from external edits in a previous run (crash / quit mid-edit). */ /** Temp files from external edits in a previous run (crash / quit mid-edit). */
private async promptForEditLeftovers(): Promise<void> { private async promptForEditLeftovers(): Promise<void> {
const res = await this.rpc.request.getEditLeftovers({}); const res = await this.rpc.request.getEditLeftovers({});
@@ -69,25 +75,38 @@ export class App {
}); });
} }
/** Native application-menu actions forwarded from the bun process. */ /** A connection by id; reloads once if unseen (a manager-window save can
private handleMenuAction(action: string): void { * 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-')) { if (action.startsWith('ctx-')) {
this.handleContextAction(action); this.handleContextAction(action);
return; return;
} }
if (action.startsWith('connect:')) { if (action.startsWith('connect:')) {
const id = action.slice('connect:'.length); const connection = await this.findConnection(action.slice('connect:'.length));
const connection = this.connections.connections().find((c) => c.id === id);
if (connection) { if (connection) {
void this.panels.connectPanel( // remote goes right, local stays left — the commander convention
this.panels.activeSide(), void this.panels.connectPanel('right', connection, connection.name, connection.localDir);
connection,
connection.name,
connection.localDir,
);
} }
return; 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) { switch (action) {
case 'new-tab': case 'new-tab':
this.panels.newWorkspace(); this.panels.newWorkspace();
@@ -107,14 +126,12 @@ export class App {
case 'refresh-active': case 'refresh-active':
void this.panels.active().refresh(); void this.panels.active().refresh();
break; break;
case 'manage-connections':
this.dialogs.manager.set(true);
break;
} }
} }
/** Tab (workspace) shortcuts — panel-level keys live on the panels themselves. */ /** Tab (workspace) shortcuts — panel-level keys live on the panels themselves. */
onWindowKeydown(event: KeyboardEvent): void { onWindowKeydown(event: KeyboardEvent): void {
if (this.managerOpen()) return; // modal-blocked behind the manager
const mod = event.metaKey || event.ctrlKey; const mod = event.metaKey || event.ctrlKey;
if (mod && event.key.toLowerCase() === 't') { if (mod && event.key.toLowerCase() === 't') {
event.preventDefault(); event.preventDefault();
@@ -1,19 +1,35 @@
<div class="fixed inset-0 z-30 grid place-items-center bg-black/40" (mousedown)="close()"> <div class="flex h-screen flex-col bg-background text-foreground">
<div <!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
class="flex h-[440px] w-[680px] flex-col rounded-lg border border-border bg-card text-card-foreground shadow-xl" elsewhere the native titlebar sits above and this is a compact strip -->
(mousedown)="$event.stopPropagation()" <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 --> <ng-icon name="mynaServers" size="0.95rem" class="shrink-0 text-muted-foreground" />
<div class="flex items-center justify-between border-b border-border px-4 py-2.5"> <h1 class="text-sm font-semibold">Connections</h1>
<h2 class="text-sm font-semibold">Connections</h2> <div class="flex-1"></div>
<button (click)="close()" class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"> <button
<ng-icon name="mynaX" size="1rem" /> (click)="importWinScp()"
</button> [disabled]="importing()"
</div> title="Import sessions from a WinSCP.ini export"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50"
>
@if (importing()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
} @else {
<ng-icon name="mynaArchive" size="0.85rem" />
}
Import WinSCP
</button>
</header>
<div class="flex min-h-0 flex-1"> <div class="flex min-h-0 flex-1">
<!-- stacks + connections list --> <!-- stacks + connections list -->
<div class="flex w-60 shrink-0 flex-col border-r border-border"> <div class="flex w-72 shrink-0 flex-col border-r border-border bg-muted/30">
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5"> <div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
<button <button
(click)="newConnection()" (click)="newConnection()"
@@ -29,20 +45,6 @@
> >
<ng-icon name="mynaFolderPlus" size="0.85rem" /> Stack <ng-icon name="mynaFolderPlus" size="0.85rem" /> Stack
</button> </button>
<div class="flex-1"></div>
<button
(click)="importWinScp()"
[disabled]="importing()"
title="Import sessions from a WinSCP.ini export"
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
>
@if (importing()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
} @else {
<ng-icon name="mynaArchive" size="0.85rem" />
}
Import
</button>
</div> </div>
<div class="flex-1 overflow-y-auto py-1"> <div class="flex-1 overflow-y-auto py-1">
@@ -72,33 +74,39 @@
<span class="truncate text-[11px] font-medium text-muted-foreground uppercase"> <span class="truncate text-[11px] font-medium text-muted-foreground uppercase">
{{ group.stack.name }} {{ group.stack.name }}
</span> </span>
<span class="shrink-0 text-[10px] text-muted-foreground/70 tabular-nums"> <div class="min-w-0 flex-1"></div>
<!-- count and actions swap on hover: they never share the space,
so a long stack name can't collide with the buttons -->
<span
class="shrink-0 text-[10px] text-muted-foreground/70 tabular-nums group-hover:hidden"
>
{{ group.connections.length }} {{ group.connections.length }}
</span> </span>
<div class="min-w-0 flex-1"></div> <div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
<button
(click)="renameStack(group.stack)"
title="Rename stack"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground"
>
<ng-icon name="mynaEdit" size="0.75rem" />
</button>
<button
(click)="deleteStack(group.stack)"
title="Delete stack"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-destructive"
>
<ng-icon name="mynaTrash" size="0.75rem" />
</button>
@if (group.connections.length) {
<button <button
(click)="openStack(group.connections)" (click)="renameStack(group.stack)"
title="Open stack in tabs" title="Rename stack"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-primary" class="grid size-4 place-items-center rounded text-muted-foreground hover:text-foreground"
> >
<ng-icon name="mynaArrowRight" size="0.8rem" /> <ng-icon name="mynaEdit" size="0.75rem" />
</button> </button>
} <button
(click)="deleteStack(group.stack)"
title="Delete stack"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-destructive"
>
<ng-icon name="mynaTrash" size="0.75rem" />
</button>
@if (group.connections.length) {
<button
(click)="openStack(group.stack.id)"
title="Open stack in tabs"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-primary"
>
<ng-icon name="mynaArrowRight" size="0.8rem" />
</button>
}
</div>
</div> </div>
@if (!group.stack.collapsed) { @if (!group.stack.collapsed) {
@for (connection of group.connections; track connection.id) { @for (connection of group.connections; track connection.id) {
@@ -164,7 +172,7 @@
<!-- edit form --> <!-- edit form -->
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
@if (draft(); as d) { @if (draft(); as d) {
<div class="grid flex-1 auto-rows-min grid-cols-2 gap-x-3 gap-y-2.5 overflow-y-auto p-4"> <div class="grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5">
<label class="col-span-2 flex flex-col gap-1 text-xs"> <label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">Name</span> <span class="text-muted-foreground">Name</span>
<input <input
@@ -358,7 +366,16 @@
</div> </div>
</div> </div>
} }
</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> </div>
@@ -2,9 +2,13 @@ import { Component, computed, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import type { SavedConnection, Stack } from '@shared/types'; import type { SavedConnection, Stack } from '@shared/types';
import { ConnectionsService } from '../connections.service'; import { ConnectionsService } from '../connections.service';
import { ConfirmDialog } from '../dialogs/confirm-dialog';
import { PromptDialog } from '../dialogs/prompt-dialog';
import { DialogsService } from '../dialogs.service'; import { DialogsService } from '../dialogs.service';
import { PanelsService } from '../panels.service'; import { IS_MAC_CHROME } from '../platform';
import { RpcService } from '../rpc.service'; import { RpcService } from '../rpc.service';
import { SettingsService } from '../settings.service';
import { Toasts } from '../toasts/toasts';
interface Draft { interface Draft {
id: string; 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({ @Component({
selector: 'app-connection-manager', selector: 'app-root',
imports: [NgIcon], imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts],
templateUrl: './connection-manager.html', templateUrl: './connection-manager.html',
host: { '(window:keydown.escape)': 'onEscape()' },
}) })
export class ConnectionManager { export class ConnectionManager {
readonly store = inject(ConnectionsService); readonly store = inject(ConnectionsService);
readonly dialogs = inject(DialogsService); readonly dialogs = inject(DialogsService);
readonly panels = inject(PanelsService); readonly isMacChrome = IS_MAC_CHROME;
private readonly rpc = inject(RpcService); 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 draft = signal<Draft | null>(null);
readonly busy = signal(false); readonly busy = signal(false);
@@ -117,8 +130,12 @@ export class ConnectionManager {
} }
close(): void { close(): void {
this.dialogs.manager.set(false); void this.rpc.request.managerClose({});
this.panels.refocus(); }
onEscape(): void {
// in-window dialogs (confirm/prompt) handle their own Escape first
if (!this.dialogs.anyOpen) this.close();
} }
async save(): Promise<string | null> { async save(): Promise<string | null> {
@@ -130,20 +147,14 @@ export class ConnectionManager {
} }
async saveAndConnect(): Promise<void> { async saveAndConnect(): Promise<void> {
const d = this.draft(); if (!this.draft()) return;
if (!d) return;
this.busy.set(true); this.busy.set(true);
try { try {
const id = await this.save(); const id = await this.save();
if (!id) return; if (!id) return;
const connection = fromDraft({ ...d, id }); // the main window connects (and this window closes); a failed connect
const ok = await this.panels.connectPanel( // surfaces as an error there
this.panels.activeSide(), await this.rpc.request.managerConnect({ id });
connection,
connection.name,
connection.localDir,
);
if (ok) this.close();
} finally { } finally {
this.busy.set(false); this.busy.set(false);
} }
@@ -183,9 +194,9 @@ export class ConnectionManager {
// ---- stacks ---- // ---- stacks ----
openStack(connections: SavedConnection[]): void { openStack(stackId: string): void {
this.panels.openStack(connections); // opened as tabs in the main window; bun closes this window after
this.close(); void this.rpc.request.managerOpenStack({ id: stackId });
} }
toggleStack(stack: Stack): void { toggleStack(stack: Stack): void {
+2
View File
@@ -29,6 +29,8 @@ export class ConnectionsService {
if (this.rpc.available) { if (this.rpc.available) {
void this.reload(); void this.reload();
void this.reloadSshHosts(); 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 confirm = signal<ConfirmRequest | null>(null);
readonly chmod = signal<ChmodRequest | null>(null); readonly chmod = signal<ChmodRequest | null>(null);
readonly overwrite = signal<OverwriteRequest | 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. */ /** Ask what to do about one name collision; resolves with the user's choice. */
askOverwrite(request: Omit<OverwriteRequest, 'resolve'>): Promise<OverwriteChoice> { askOverwrite(request: Omit<OverwriteRequest, 'resolve'>): Promise<OverwriteChoice> {
+3 -3
View File
@@ -1,6 +1,6 @@
import { Component, computed, inject, input, signal } from '@angular/core'; import { Component, computed, inject, input, signal } from '@angular/core';
import { DialogsService, type ChmodRequest } from '../dialogs.service'; import { DialogsService, type ChmodRequest } from '../dialogs.service';
import { PanelsService } from '../panels.service'; import { FocusService } from '../focus.service';
const ROLES = ['Owner', 'Group', 'Others'] as const; const ROLES = ['Owner', 'Group', 'Others'] as const;
const PERMS = ['r', 'w', 'x'] as const; const PERMS = ['r', 'w', 'x'] as const;
@@ -81,7 +81,7 @@ export class ChmodDialog {
readonly perms = PERMS; readonly perms = PERMS;
private readonly dialogs = inject(DialogsService); private readonly dialogs = inject(DialogsService);
private readonly panels = inject(PanelsService); private readonly focus = inject(FocusService);
// mode is initialized lazily from the request on first read // mode is initialized lazily from the request on first read
private readonly modeOverride = signal<number | null>(null); private readonly modeOverride = signal<number | null>(null);
@@ -120,6 +120,6 @@ export class ChmodDialog {
private close(): void { private close(): void {
this.dialogs.chmod.set(null); this.dialogs.chmod.set(null);
this.panels.refocus(); this.focus.bump();
} }
} }
+3 -3
View File
@@ -1,6 +1,6 @@
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core'; import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
import { DialogsService, type ConfirmRequest } from '../dialogs.service'; import { DialogsService, type ConfirmRequest } from '../dialogs.service';
import { PanelsService } from '../panels.service'; import { FocusService } from '../focus.service';
@Component({ @Component({
selector: 'app-confirm-dialog', selector: 'app-confirm-dialog',
@@ -41,7 +41,7 @@ export class ConfirmDialog {
private readonly confirmBtn = viewChild.required<ElementRef<HTMLButtonElement>>('confirmBtn'); private readonly confirmBtn = viewChild.required<ElementRef<HTMLButtonElement>>('confirmBtn');
private readonly dialogs = inject(DialogsService); private readonly dialogs = inject(DialogsService);
private readonly panels = inject(PanelsService); private readonly focus = inject(FocusService);
constructor() { constructor() {
afterNextRender(() => this.confirmBtn().nativeElement.focus()); afterNextRender(() => this.confirmBtn().nativeElement.focus());
@@ -59,6 +59,6 @@ export class ConfirmDialog {
private close(): void { private close(): void {
this.dialogs.confirm.set(null); this.dialogs.confirm.set(null);
this.panels.refocus(); this.focus.bump();
} }
} }
+3 -3
View File
@@ -10,7 +10,7 @@ import {
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '../dialogs.service'; import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '../dialogs.service';
import { formatDate, formatSize } from '../format'; import { formatDate, formatSize } from '../format';
import { PanelsService } from '../panels.service'; import { FocusService } from '../focus.service';
@Component({ @Component({
selector: 'app-overwrite-dialog', selector: 'app-overwrite-dialog',
@@ -125,7 +125,7 @@ export class OverwriteDialog {
readonly formatDate = formatDate; readonly formatDate = formatDate;
private readonly dialogs = inject(DialogsService); private readonly dialogs = inject(DialogsService);
private readonly panels = inject(PanelsService); private readonly focus = inject(FocusService);
private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn'); private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn');
constructor() { constructor() {
@@ -145,7 +145,7 @@ export class OverwriteDialog {
pick(choice: OverwriteChoice): void { pick(choice: OverwriteChoice): void {
const req = this.request(); const req = this.request();
this.dialogs.overwrite.set(null); this.dialogs.overwrite.set(null);
this.panels.refocus(); this.focus.bump();
req.resolve(choice); req.resolve(choice);
} }
} }
+3 -3
View File
@@ -8,7 +8,7 @@ import {
viewChild, viewChild,
} from '@angular/core'; } from '@angular/core';
import { DialogsService, type PromptRequest } from '../dialogs.service'; import { DialogsService, type PromptRequest } from '../dialogs.service';
import { PanelsService } from '../panels.service'; import { FocusService } from '../focus.service';
@Component({ @Component({
selector: 'app-prompt-dialog', selector: 'app-prompt-dialog',
@@ -54,7 +54,7 @@ export class PromptDialog {
private readonly box = viewChild.required<ElementRef<HTMLInputElement>>('box'); private readonly box = viewChild.required<ElementRef<HTMLInputElement>>('box');
private readonly dialogs = inject(DialogsService); private readonly dialogs = inject(DialogsService);
private readonly panels = inject(PanelsService); private readonly focus = inject(FocusService);
constructor() { constructor() {
afterNextRender(() => { afterNextRender(() => {
@@ -79,6 +79,6 @@ export class PromptDialog {
private close(): void { private close(): void {
this.dialogs.prompt.set(null); this.dialogs.prompt.set(null);
this.panels.refocus(); this.focus.bump();
} }
} }
-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>
} }
</div> </div>
-6
View File
@@ -121,12 +121,6 @@ export class FilePanel {
return `${host.username}@${host.hostname}:${host.port}`; return `${host.username}@${host.hostname}:${host.port}`;
} }
openManager(): void {
this.menuOpen.set(false);
this.activate();
this.dialogs.manager.set(true);
}
iconFor(entry: FileEntry): string { iconFor(entry: FileEntry): string {
return fileGlyph(entry, this.settings.iconColors() === 'multi'); return fileGlyph(entry, this.settings.iconColors() === 'multi');
} }
+16
View File
@@ -0,0 +1,16 @@
import { Injectable, signal } from '@angular/core';
/**
* "Give keyboard focus back to the main surface" signal. Dependency-free so
* dialogs can bump it from ANY window kind — in the main window the file
* panels listen (via PanelsService.focusTick); elsewhere nobody does, which
* is exactly right.
*/
@Injectable({ providedIn: 'root' })
export class FocusService {
readonly tick = signal(0);
bump(): void {
this.tick.update((n) => n + 1);
}
}
+8 -5
View File
@@ -1,5 +1,6 @@
import { Injectable, computed, signal, type WritableSignal } from '@angular/core'; import { Injectable, computed, inject, signal, type WritableSignal } from '@angular/core';
import type { ConnectionConfig, FileEntry, SavedConnection } from '@shared/types'; import type { ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
import { FocusService } from './focus.service';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
import { ToastService } from './toast.service'; import { ToastService } from './toast.service';
@@ -201,8 +202,9 @@ export class PanelsService {
const list = this.workspaces(); const list = this.workspaces();
return list.find((w) => w.id === this.activeWorkspaceId()) ?? list[0]; return list.find((w) => w.id === this.activeWorkspaceId()) ?? list[0];
}); });
private readonly focus = inject(FocusService);
/** Bumped to tell the active panel to grab keyboard focus again (e.g. after a dialog closes). */ /** Bumped to tell the active panel to grab keyboard focus again (e.g. after a dialog closes). */
readonly focusTick = signal(0); readonly focusTick = this.focus.tick;
private nextWorkspaceId = 1; private nextWorkspaceId = 1;
@@ -265,7 +267,7 @@ export class PanelsService {
} }
refocus(): void { refocus(): void {
this.focusTick.update((n) => n + 1); this.focus.bump();
} }
// ---- workspace (tab) management ---- // ---- workspace (tab) management ----
@@ -333,8 +335,9 @@ export class PanelsService {
this.newWorkspace(); this.newWorkspace();
} }
// connectPanel resolves the panel synchronously (before awaiting), so // connectPanel resolves the panel synchronously (before awaiting), so
// firing these back-to-back binds each connection to its own tab // firing these back-to-back binds each connection to its own tab.
void this.connectPanel('left', connection, connection.name, connection.localDir); // Remote goes right, local stays left — the commander convention.
void this.connectPanel('right', connection, connection.name, connection.localDir);
} }
} }
+3 -3
View File
@@ -6,8 +6,8 @@ import type { EditorOpenContext } from '@shared/rpc';
declare global { declare global {
interface Window { interface Window {
__APP_PLATFORM?: string; __APP_PLATFORM?: string;
/** Which root component this window boots: the full app or one editor. */ /** Which root component this window boots. */
__APP_MODE?: 'main' | 'editor'; __APP_MODE?: 'main' | 'editor' | 'manager';
/** Window-kind specific context (EditorOpenContext for editor windows). */ /** Window-kind specific context (EditorOpenContext for editor windows). */
__APP_CTX?: unknown; __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). */ /** True when the window uses macOS hiddenInset chrome (header fused with titlebar). */
export const IS_MAC_CHROME = PLATFORM === 'darwin'; 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'; typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
export const EDITOR_CONTEXT = export const EDITOR_CONTEXT =
+16
View File
@@ -22,6 +22,8 @@ export class RpcService {
private settingsListeners = new Set<(settings: AppSettings) => void>(); private settingsListeners = new Set<(settings: AppSettings) => void>();
private pathRefreshListeners = new Set<(e: { sessionId: string; dir: string }) => void>(); private pathRefreshListeners = new Set<(e: { sessionId: string; dir: string }) => void>();
private editorCloseListeners = new Set<() => 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 rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
private ready: Promise<void> = Promise.resolve(); private ready: Promise<void> = Promise.resolve();
@@ -52,6 +54,12 @@ export class RpcService {
editorCloseRequest: () => { editorCloseRequest: () => {
for (const listener of this.editorCloseListeners) listener(); 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); 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. */ /** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
get request() { get request() {
const rpc = this.rpc; const rpc = this.rpc;
+14 -1
View File
@@ -101,6 +101,15 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
<div class="flex-1"></div> <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 --> <!-- global options -->
<button <button
(click)="settingsOpen.set(!settingsOpen())" (click)="settingsOpen.set(!settingsOpen())"
@@ -172,7 +181,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</div> </div>
<div class="mt-2.5 flex items-center justify-between gap-2"> <div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">File colors</span> <span class="text-xs text-muted-foreground">File icons</span>
<div class="flex rounded-md border border-border p-0.5"> <div class="flex rounded-md border border-border p-0.5">
@for (mode of iconColorModes; track mode) { @for (mode of iconColorModes; track mode) {
<button <button
@@ -379,6 +388,10 @@ export class TabBar {
return base.replace(/\.app$/i, ''); return base.replace(/\.app$/i, '');
} }
openManager(): void {
void this.rpc.request.openConnectionManager({});
}
async pickEditorApp(): Promise<void> { async pickEditorApp(): Promise<void> {
const res = await this.rpc.request.pickEditorApp({}); const res = await this.rpc.request.pickEditorApp({});
if (res.ok && res.path) this.settings.setEditorApp(res.path); 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 { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config'; import { appConfig } from './app/app.config';
import { App } from './app/app'; import { App } from './app/app';
import { ConnectionManager } from './app/connection-manager/connection-manager';
import { EditorWindow } from './app/editor-window/editor-window'; import { EditorWindow } from './app/editor-window/editor-window';
import { APP_MODE } from './app/platform'; import { APP_MODE } from './app/platform';
// One bundle, two window kinds: the preload global picks the root component. // One bundle, three window kinds: the preload global picks the root component.
const root = APP_MODE === 'editor' ? EditorWindow : App; const root = APP_MODE === 'editor' ? EditorWindow : APP_MODE === 'manager' ? ConnectionManager : App;
bootstrapApplication(root, appConfig).catch((err) => console.error(err)); bootstrapApplication(root, appConfig).catch((err) => console.error(err));