From 7dd59a97520c259690e42014732da3c0217a5e77 Mon Sep 17 00:00:00 2001 From: Jokin Date: Mon, 13 Jul 2026 23:11:58 +0200 Subject: [PATCH] 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 --- src/bun/index.ts | 41 +++- src/bun/manager-window.ts | 103 ++++++++++ src/bun/rpc-handlers.ts | 187 ++++++++++++------ src/bun/windows.ts | 4 +- src/shared/rpc.ts | 11 ++ ui/src/app/app.html | 21 +- ui/src/app/app.ts | 43 ++-- .../connection-manager.html | 40 ++-- .../connection-manager/connection-manager.ts | 49 +++-- ui/src/app/connections.service.ts | 2 + ui/src/app/dialogs.service.ts | 1 - ui/src/app/file-panel/file-panel.html | 8 - ui/src/app/file-panel/file-panel.ts | 6 - ui/src/app/platform.ts | 6 +- ui/src/app/rpc.service.ts | 16 ++ ui/src/app/tab-bar/tab-bar.ts | 13 ++ ui/src/main.ts | 5 +- 17 files changed, 420 insertions(+), 136 deletions(-) create mode 100644 src/bun/manager-window.ts diff --git a/src/bun/index.ts b/src/bun/index.ts index c9128ba..03228c7 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -6,6 +6,7 @@ import { PanelContextMenu } from "./context-menu"; import { EditManager } from "./edits"; import { EditorWindows } from "./editor-windows"; import { SessionRegistry } from "./fs/registry"; +import { ManagerWindow } from "./manager-window"; import { applyApplicationMenu } from "./menu"; import { createAppRPC } from "./rpc-handlers"; import { ConnectionStore } from "./store/connections"; @@ -49,6 +50,7 @@ const events = { connectionsChanged: () => {}, openEditor: (_ctx: EditorOpenContext) => {}, settingsChanged: (_settings: AppSettings) => {}, + openConnectionManager: () => {}, }; const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events }); const url = await getMainViewUrl(); @@ -83,17 +85,42 @@ const editors = new EditorWindows({ mainFrame: () => win.getFrame(), 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.settingsChanged = (s) => editors.broadcastSettings(s); -// clean editors close with the main window; dirty ones get to ask first -win.on("close", () => editors.closeAllForAppExit()); +events.settingsChanged = (s) => { + editors.broadcastSettings(s); + 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); contextMenu.onAction = (action) => rpc.send.menuAction({ action }); // notify on finished transfers while the window is in the background 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)); const notifiedStates = new Map(); @@ -173,6 +200,12 @@ ApplicationMenu.on("application-menu-clicked", (event) => { 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 }); }); diff --git a/src/bun/manager-window.ts b/src/bun/manager-window.ts new file mode 100644 index 0000000..17c05fd --- /dev/null +++ b/src/bun/manager-window.ts @@ -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 | 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 }); + } +} diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts index 3118d09..49fe370 100644 --- a/src/bun/rpc-handlers.ts +++ b/src/bun/rpc-handlers.ts @@ -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(fn: () => Promise | T): Promise void) { + return { + getConnections: () => wrap(async () => ({ store: await store.load() })), + + upsertConnection: ({ connection }: { connection: Parameters[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[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({ + 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 {}; + }), + }, + }, + }); +} diff --git a/src/bun/windows.ts b/src/bun/windows.ts index 310b3b7..062397d 100644 --- a/src/bun/windows.ts +++ b/src/bun/windows.ts @@ -7,7 +7,7 @@ const isMac = process.platform === "darwin"; * reads them in ui/src/app/platform.ts before first paint (window chrome and * 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 = `window.__APP_PLATFORM=${JSON.stringify(process.platform)};` + `window.__APP_MODE=${JSON.stringify(mode)};`; @@ -19,7 +19,7 @@ export interface SecondaryWindowOptions { title: string; /** The once-resolved dev/prod url (see getMainViewUrl in index.ts). */ url: string; - mode: "editor"; // widens when more window kinds exist (connection manager…) + mode: "editor" | "manager"; ctx?: unknown; frame: { x: number; y: number; width: number; height: number }; /** Per-window rpc instance. NEVER share one across windows — each diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index 90074cb..0104e4f 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -140,6 +140,13 @@ export type AppRPC = { /** Editor windows only — handled by that window's own rpc closure. */ editorSave: { params: { content: string }; response: Result }; editorClose: { params: Record; response: Result }; + // connection-manager window + /** Main window: open (or focus) the single connection-manager window. */ + openConnectionManager: { params: Record; 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; response: Result }; }; messages: { /** Editor → bun draft mirror; safety net for the non-cancellable native close. */ @@ -158,6 +165,10 @@ export type AppRPC = { pathRefresh: { sessionId: string; dir: string }; /** bun → one editor: run the in-window close flow (dirty prompt). */ editorCloseRequest: Record; + /** bun → main: the modal connection manager opened/closed. */ + managerState: { open: boolean }; + /** bun → main: the manager window changed the connection store. */ + connectionsChanged: Record; }; }; }; diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 3afeec3..e3d8e2c 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -21,10 +21,25 @@ } - - @if (dialogs.manager()) { - + + @if (managerOpen()) { +
+
+ +

The connection manager is open.

+ +
+
} + + @if (dialogs.prompt(); as req) { } diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index cc3603e..8e6d4b2 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -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 { 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 { 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(); diff --git a/ui/src/app/connection-manager/connection-manager.html b/ui/src/app/connection-manager/connection-manager.html index 14fad00..6aad431 100644 --- a/ui/src/app/connection-manager/connection-manager.html +++ b/ui/src/app/connection-manager/connection-manager.html @@ -1,17 +1,20 @@ -
-
+ +
- -
-

Connections

- -
+ +

Connections

+
+
-
+
@@ -92,7 +95,7 @@ @if (group.connections.length) {
} -
+ + + @if (dialogs.prompt(); as req) { + + } + @if (dialogs.confirm(); as req) { + + } + +
diff --git a/ui/src/app/connection-manager/connection-manager.ts b/ui/src/app/connection-manager/connection-manager.ts index 5151951..28a9ca0 100644 --- a/ui/src/app/connection-manager/connection-manager.ts +++ b/ui/src/app/connection-manager/connection-manager.ts @@ -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(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 { @@ -130,20 +147,14 @@ export class ConnectionManager { } async saveAndConnect(): Promise { - 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 { diff --git a/ui/src/app/connections.service.ts b/ui/src/app/connections.service.ts index 7480fc1..f415691 100644 --- a/ui/src/app/connections.service.ts +++ b/ui/src/app/connections.service.ts @@ -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()); } } diff --git a/ui/src/app/dialogs.service.ts b/ui/src/app/dialogs.service.ts index b0b91d6..3a34e0a 100644 --- a/ui/src/app/dialogs.service.ts +++ b/ui/src/app/dialogs.service.ts @@ -39,7 +39,6 @@ export class DialogsService { readonly confirm = signal(null); readonly chmod = signal(null); readonly overwrite = signal(null); - readonly manager = signal(false); /** Ask what to do about one name collision; resolves with the user's choice. */ askOverwrite(request: Omit): Promise { diff --git a/ui/src/app/file-panel/file-panel.html b/ui/src/app/file-panel/file-panel.html index 4f2fd2e..30bd191 100644 --- a/ui/src/app/file-panel/file-panel.html +++ b/ui/src/app/file-panel/file-panel.html @@ -140,14 +140,6 @@ } } -
- } diff --git a/ui/src/app/file-panel/file-panel.ts b/ui/src/app/file-panel/file-panel.ts index 484b271..196b2cc 100644 --- a/ui/src/app/file-panel/file-panel.ts +++ b/ui/src/app/file-panel/file-panel.ts @@ -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'); } diff --git a/ui/src/app/platform.ts b/ui/src/app/platform.ts index 6f501cf..3a1722f 100644 --- a/ui/src/app/platform.ts +++ b/ui/src/app/platform.ts @@ -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 = diff --git a/ui/src/app/rpc.service.ts b/ui/src/app/rpc.service.ts index ae39167..6ea7f89 100644 --- a/ui/src/app/rpc.service.ts +++ b/ui/src/app/rpc.service.ts @@ -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> | undefined; private ready: Promise = 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; diff --git a/ui/src/app/tab-bar/tab-bar.ts b/ui/src/app/tab-bar/tab-bar.ts index e3a37cd..bb251ac 100644 --- a/ui/src/app/tab-bar/tab-bar.ts +++ b/ui/src/app/tab-bar/tab-bar.ts @@ -101,6 +101,15 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
+ + +