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>
This commit is contained in:
@@ -1,4 +1,14 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core';
|
||||
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';
|
||||
@@ -6,10 +16,12 @@ 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 } from '@core/shortcuts.service';
|
||||
import { ShortcutsService, type ShortcutId } from '@core/shortcuts.service';
|
||||
import { Toasts } from '../toasts/toasts';
|
||||
|
||||
interface Draft {
|
||||
@@ -76,8 +88,8 @@ function fromDraft(d: Draft): SavedConnection {
|
||||
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts],
|
||||
templateUrl: './connection-manager.html',
|
||||
host: {
|
||||
'(window:keydown.escape)': 'onEscape()',
|
||||
'(window:keydown)': 'onWindowKeydown($event)',
|
||||
'(window:focusin)': 'rememberFocus($event)',
|
||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||
},
|
||||
})
|
||||
@@ -90,7 +102,13 @@ export class ConnectionManager {
|
||||
// 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);
|
||||
private readonly shortcuts = inject(ShortcutsService);
|
||||
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);
|
||||
@@ -102,6 +120,27 @@ export class ConnectionManager {
|
||||
/** 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;
|
||||
@@ -141,21 +180,176 @@ export class ConnectionManager {
|
||||
void this.rpc.request.managerClose({});
|
||||
}
|
||||
|
||||
onEscape(): void {
|
||||
// in-window dialogs (confirm/prompt) handle their own Escape first
|
||||
if (!this.dialogs.anyOpen) this.close();
|
||||
}
|
||||
|
||||
/** Any-window shortcuts (interface scale). Escape has its own binding above. */
|
||||
/**
|
||||
* 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 === 'close-window') this.close();
|
||||
else if (id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||
else if (id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
||||
else if (id === 'scale-reset') this.settings.setUiScale(1);
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user