import { Component, ElementRef, Injector, afterNextRender, computed, effect, inject, signal, viewChild, } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import { I18nService } from '@core/i18n.service'; import type { SavedConnection, Stack } from '@shared/types'; import { ConnectionsService } from '@core/connections.service'; import { ConfirmDialog } from '../dialogs/confirm-dialog'; import { PromptDialog } from '../dialogs/prompt-dialog'; import { DialogsService } from '@core/dialogs.service'; import { cycleFocus } from '@core/focus-cycle'; import { FocusService } from '@core/focus.service'; import { blockNativeContextMenu, blockReloadKeys, IS_MAC_CHROME } from '@core/platform'; import { RpcService } from '@core/rpc.service'; import { SettingsService } from '@core/settings.service'; import { ShortcutsService, type ShortcutId } from '@core/shortcuts.service'; import { Toasts } from '../toasts/toasts'; interface Draft { id: string; name: string; stackId: string | null; host: string; port: number; username: string; authKind: 'password' | 'key'; password: string; privateKeyPath: string; passphrase: string; initialPath: string; localDir: string; } function toDraft(c: SavedConnection): Draft { return { id: c.id, name: c.name, stackId: c.stackId, host: c.host, port: c.port, username: c.username, // agent auth only exists on ~/.ssh/config hosts, which never reach this form authKind: c.auth.kind === 'password' ? 'password' : 'key', password: c.auth.kind === 'password' ? c.auth.password : '', privateKeyPath: c.auth.kind === 'key' ? c.auth.privateKeyPath : '', passphrase: c.auth.kind === 'key' ? (c.auth.passphrase ?? '') : '', initialPath: c.initialPath ?? '', localDir: c.localDir ?? '', }; } function fromDraft(d: Draft): SavedConnection { return { id: d.id, name: d.name.trim() || `${d.username}@${d.host}`, stackId: d.stackId, host: d.host.trim(), port: d.port || 22, username: d.username.trim(), auth: d.authKind === 'password' ? { kind: 'password', password: d.password } : { kind: 'key', privateKeyPath: d.privateKeyPath.trim(), ...(d.passphrase ? { passphrase: d.passphrase } : {}), }, initialPath: d.initialPath.trim() || null, localDir: d.localDir.trim() || null, }; } /** * 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-root', imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts], templateUrl: './connection-manager.html', host: { '(window:keydown)': 'onWindowKeydown($event)', '(window:focusin)': 'rememberFocus($event)', '(window:contextmenu)': 'blockContextMenu($event)', }, }) export class ConnectionManager { readonly i18n = inject(I18nService); readonly store = inject(ConnectionsService); readonly dialogs = inject(DialogsService); 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 shortcuts = inject(ShortcutsService); private readonly focus = inject(FocusService); private readonly injector = inject(Injector); private readonly list = viewChild.required>('list'); private readonly nameBox = viewChild>('nameBox'); /** Where focus was outside a dialog, to come back to when it closes. */ private lastFocus: HTMLElement | null = null; readonly draft = signal(null); readonly busy = signal(false); readonly importing = signal(false); /** The More… section (local directory) in the edit form. */ readonly moreOpen = signal(false); /** Connection id being dragged between stacks. */ readonly draggedId = signal(null); /** Highlighted drop target: a stack id, 'loose', or null. */ readonly dropTarget = signal(null); constructor() { // the keyboard starts in the list — and lands there again whenever focus // fell to (the store loads after the first render; a deleted row // takes its focus with it) effect(() => { this.store.grouped(); afterNextRender(() => this.focusFallback(), { injector: this.injector }); }); // dialogs bump FocusService when they close: return to where the user was effect(() => { this.focus.tick(); afterNextRender( () => { if (this.lastFocus?.isConnected) this.lastFocus.focus(); else this.focusFallback(); }, { injector: this.injector }, ); }); } readonly valid = computed(() => { const d = this.draft(); if (!d) return false; if (!d.host.trim() || !d.username.trim()) return false; if (d.authKind === 'key' && !d.privateKeyPath.trim()) return false; return true; }); patch(key: K, value: Draft[K]): void { this.draft.update((d) => (d ? { ...d, [key]: value } : d)); } newConnection(): void { this.moreOpen.set(false); this.draft.set({ id: '', name: '', stackId: null, host: '', port: 22, username: 'root', authKind: 'password', password: '', privateKeyPath: '', passphrase: '', initialPath: '', localDir: '', }); } edit(connection: SavedConnection): void { this.draft.set(toDraft(connection)); this.moreOpen.set(!!connection.localDir); } close(): void { void this.rpc.request.managerClose({}); } /** * This window's keys (the `manager` scope), then the fixed ones: Esc closes * the window — from a text field it first just leaves the field, so a * half-typed connection isn't thrown away — and Tab is walked by hand * (WKWebView skips buttons and radios on Tab otherwise). The dialogs take * their own Esc and stop it, so it never reaches here. */ onWindowKeydown(event: KeyboardEvent): void { const id = this.shortcuts.claim(event, 'manager'); if (id) this.runCommand(id); else if (event.key === 'Escape' && !this.dialogs.anyOpen) this.onEscape(event); else cycleFocus(document.body, event); blockReloadKeys(event); } private onEscape(event: KeyboardEvent): void { event.preventDefault(); if (this.shortcuts.isTypingTarget(event)) { (event.target as HTMLElement).blur(); this.focusFallback(true); } else { this.close(); } } private runCommand(id: ShortcutId): void { const row = this.focusedRow(); switch (id) { case 'close-window': this.close(); break; case 'conn-new': this.newConnection(); this.focusName(); break; case 'conn-new-stack': this.newStack(); break; case 'conn-save': void this.save(); break; case 'conn-connect': // the focused stack opens in tabs; a focused connection, or the form, connects if (row?.stack) this.openStack(row.stack.id); else { if (row?.connection) this.edit(row.connection); void this.saveAndConnect(); } break; case 'conn-delete': if (row?.stack) this.deleteStack(row.stack); else { if (row?.connection) this.edit(row.connection); this.deleteCurrent(); } break; case 'conn-rename-stack': if (row?.stack) this.renameStack(row.stack); break; case 'conn-import': void this.importWinScp(); break; case 'scale-up': this.settings.setUiScale(this.settings.uiScale() + 0.1); break; case 'scale-down': this.settings.setUiScale(this.settings.uiScale() - 0.1); break; case 'scale-reset': this.settings.setUiScale(1); break; } } // ---- keyboard: the list ---- /** The stack or connection row the focus sits in (its buttons included). */ private focusedRow(): { stack?: Stack; connection?: SavedConnection } | null { const el = document.activeElement as HTMLElement | null; const stackId = el?.closest('[data-stack]')?.dataset['stack']; if (stackId) { const stack = this.store.stacks().find((s) => s.id === stackId); return stack ? { stack } : null; } const connId = el?.closest('[data-conn]')?.dataset['conn']; const connection = connId && this.store.connections().find((c) => c.id === connId); return connection ? { connection } : null; } private rows(): HTMLElement[] { return [...this.list().nativeElement.querySelectorAll('[data-row]')]; } /** * ↑ ↓ Home End move between rows (focus only — loading a connection would * replace the form without asking); ← / → collapse and expand a stack, ← on * a connection goes up to its stack; Enter on a connection opens it in the * form (Enter / Space on a stack toggles it, natively). */ onListKeydown(event: KeyboardEvent): void { if (event.metaKey || event.ctrlKey || event.altKey) return; const rows = this.rows(); const at = rows.indexOf(document.activeElement as HTMLElement); const row = this.focusedRow(); let target: HTMLElement | undefined; switch (event.key) { case 'ArrowDown': target = rows[Math.min(rows.length - 1, at + 1)]; break; case 'ArrowUp': target = rows[Math.max(0, at - 1)]; break; case 'Home': target = rows[0]; break; case 'End': target = rows.at(-1); break; case 'ArrowLeft': if (row?.stack && !row.stack.collapsed) this.toggleStack(row.stack); else if (row?.connection?.stackId) target = this.stackRow(row.connection.stackId); else return; break; case 'ArrowRight': if (row?.stack?.collapsed) this.toggleStack(row.stack); else return; break; case 'Enter': if (!row?.connection) return; this.edit(row.connection); this.focusName(); break; default: return; } event.preventDefault(); target?.focus(); } private stackRow(stackId: string): HTMLElement | undefined { return this.rows().find((el) => el.closest('[data-stack]')?.dataset['stack'] === stackId); } /** Enter in a form field = Connect, the primary button (not mid-IME). */ onFormEnter(event: Event): void { const key = event as KeyboardEvent; const target = key.target as HTMLElement; if (key.isComposing || !target.matches('input:not([type=radio]):not([type=checkbox])')) return; key.preventDefault(); if (this.valid()) void this.saveAndConnect(); } rememberFocus(event: FocusEvent): void { const target = event.target as HTMLElement; if (target instanceof HTMLElement && !target.closest('[role=dialog]')) this.lastFocus = target; } /** Focus the edited connection's row, else the first row — only when focus * is nowhere (on ), unless forced. */ private focusFallback(force = false): void { if (!force && document.activeElement !== document.body) return; const id = this.draft()?.id; const own = id ? this.rows().find((el) => el.closest('[data-conn]')?.dataset['conn'] === id) : undefined; (own ?? this.rows()[0])?.focus(); } private focusName(): void { afterNextRender(() => this.nameBox()?.nativeElement.focus(), { injector: this.injector }); } blockContextMenu(event: Event): void { blockNativeContextMenu(event); } async save(): Promise { const d = this.draft(); if (!d || !this.valid()) return null; const id = await this.store.upsert(fromDraft(d)); if (id) this.patch('id', id); return id; } async saveAndConnect(): Promise { if (!this.draft()) return; this.busy.set(true); try { const id = await this.save(); if (!id) return; // 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); } } deleteCurrent(): void { const d = this.draft(); if (!d?.id) { this.draft.set(null); return; } this.dialogs.confirm.set({ title: this.i18n.t('connMgr.deleteConnectionTitle'), body: this.i18n.t('connMgr.deleteConnectionBody', { name: d.name || d.host }), confirmLabel: this.i18n.t('common.delete'), destructive: true, onConfirm: async () => { await this.store.remove(d.id); this.draft.set(null); }, }); } async pickKeyFile(): Promise { const res = await this.rpc.request.pickKeyFile({}); if (res.ok && res.path) this.patch('privateKeyPath', res.path); } async importWinScp(): Promise { this.importing.set(true); try { await this.store.importWinScp(); } finally { this.importing.set(false); } } // ---- stacks ---- 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 { void this.store.upsertStack({ ...stack, collapsed: !stack.collapsed }); } newStack(): void { this.dialogs.prompt.set({ title: this.i18n.t('connMgr.newStackTitle'), placeholder: this.i18n.t('connMgr.newStackPrompt'), confirmLabel: this.i18n.t('fileActions.createConfirm'), onConfirm: (name) => void this.store.upsertStack({ id: '', name }), }); } renameStack(stack: Stack): void { this.dialogs.prompt.set({ title: this.i18n.t('connMgr.renameStackTitle'), initial: stack.name, confirmLabel: this.i18n.t('connMgr.renameStack'), onConfirm: (name) => void this.store.upsertStack({ ...stack, name }), }); } deleteStack(stack: Stack): void { this.dialogs.confirm.set({ title: this.i18n.t('connMgr.deleteStackTitle'), body: this.i18n.t('connMgr.deleteStackBody', { name: stack.name }), confirmLabel: this.i18n.t('common.delete'), destructive: true, onConfirm: () => void this.store.removeStack(stack.id), }); } // ---- drag & drop (move a connection between stacks / loose) ---- onDragStart(event: DragEvent, connection: SavedConnection): void { this.draggedId.set(connection.id); event.dataTransfer?.setData('text/plain', connection.id); if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'; } onDragOver(event: DragEvent, target: string): void { if (!this.draggedId()) return; event.preventDefault(); this.dropTarget.set(target); } onDrop(event: DragEvent, stackId: string | null): void { event.preventDefault(); const id = this.draggedId(); this.draggedId.set(null); this.dropTarget.set(null); const connection = this.store.connections().find((c) => c.id === id); if (!connection || connection.stackId === stackId) return; void this.store.upsert({ ...connection, stackId }); } onDragEnd(): void { this.draggedId.set(null); this.dropTarget.set(null); } }