Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
485603fd30 | ||
|
|
d22bc9b4e2 | ||
|
|
7dd59a9752 | ||
|
|
27d0ac81d6 | ||
|
|
62ff845944 |
+37
-4
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()"
|
||||||
|
[disabled]="importing()"
|
||||||
|
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>
|
</button>
|
||||||
</div>
|
</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,34 +74,40 @@
|
|||||||
<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
|
<button
|
||||||
(click)="renameStack(group.stack)"
|
(click)="renameStack(group.stack)"
|
||||||
title="Rename 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"
|
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaEdit" size="0.75rem" />
|
<ng-icon name="mynaEdit" size="0.75rem" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="deleteStack(group.stack)"
|
(click)="deleteStack(group.stack)"
|
||||||
title="Delete 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"
|
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-destructive"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaTrash" size="0.75rem" />
|
<ng-icon name="mynaTrash" size="0.75rem" />
|
||||||
</button>
|
</button>
|
||||||
@if (group.connections.length) {
|
@if (group.connections.length) {
|
||||||
<button
|
<button
|
||||||
(click)="openStack(group.connections)"
|
(click)="openStack(group.stack.id)"
|
||||||
title="Open stack in tabs"
|
title="Open stack in tabs"
|
||||||
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-primary"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaArrowRight" size="0.8rem" />
|
<ng-icon name="mynaArrowRight" size="0.8rem" />
|
||||||
</button>
|
</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) {
|
||||||
<button
|
<button
|
||||||
@@ -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
|
||||||
@@ -360,5 +368,14 @@
|
|||||||
}
|
}
|
||||||
</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 {
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user