Files
miniSCP/ui/src/app/skins/shadlike/connection-manager/connection-manager.ts
T
jgamboaandClaude e28314054f Drive the connection manager from the keyboard
The manager had only Esc and the scale keys: its list took one Tab stop
per row with no arrows, a stack's rename / delete / open-in-tabs buttons
existed only on hover, and no action had a key.

- Commands (the manager keymap scope, remappable like the rest): new
  connection with focus in Name, new stack, save, connect — or open the
  focused stack in tabs — delete the focused row, rename the focused
  stack, import from WinSCP, close the window.
- The list: up/down/Home/End move focus between rows (focus only:
  loading a connection would discard a half-edited form), Enter opens the
  focused connection, left/right collapse and expand a stack, and a
  stack's actions show while the keyboard is in its row.
- Enter in a form field connects; Esc leaves a field before it closes the
  window; Tab is walked by hand (WKWebView skips buttons and radios); focus
  comes back after a dialog and never stays lost on <body>.
- Tooltips show the keys; the Settings → Keyboard fixed-key list gains the
  manager's.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-05 14:59:37 +02:00

479 lines
15 KiB
TypeScript

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<ElementRef<HTMLElement>>('list');
private readonly nameBox = viewChild<ElementRef<HTMLInputElement>>('nameBox');
/** Where focus was outside a dialog, to come back to when it closes. */
private lastFocus: HTMLElement | null = null;
readonly draft = signal<Draft | null>(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<string | null>(null);
/** Highlighted drop target: a stack id, 'loose', or null. */
readonly dropTarget = signal<string | null>(null);
constructor() {
// the keyboard starts in the list — and lands there again whenever focus
// fell to <body> (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<K extends keyof Draft>(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<HTMLElement>('[data-stack]')?.dataset['stack'];
if (stackId) {
const stack = this.store.stacks().find((s) => s.id === stackId);
return stack ? { stack } : null;
}
const connId = el?.closest<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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 <body>), 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<HTMLElement>('[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<string | null> {
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<void> {
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<void> {
const res = await this.rpc.request.pickKeyFile({});
if (res.ok && res.path) this.patch('privateKeyPath', res.path);
}
async importWinScp(): Promise<void> {
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);
}
}