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:
2026-10-05 14:59:37 +02:00
co-authored by Claude
parent 8f8f230711
commit e28314054f
12 changed files with 262 additions and 28 deletions
+14
View File
@@ -121,6 +121,20 @@ main's `ConnectionsService` reloads; a reload-retry in `App.findConnection`
covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it
would float over editor windows). would float over editor windows).
Keyboard (the `manager` keymap scope): ⌘N new connection (focus lands in
Name), ⇧⌘N new stack, ⌘S save, ⌘↩ connect — or, on a focused stack row, open
it in tabs — ⌘⌫ / Del delete the focused row (with a confirm), F2 rename the
focused stack, ⌘O import, ⌘W close. The list's rows are real buttons
(`data-row`; `data-stack` / `data-conn` say what they are): ↑ ↓ Home End move
FOCUS only — loading a connection would replace a half-edited form without
asking — Enter opens the focused connection in the form, ← / → collapse and
expand a stack (← on a connection climbs to its stack), and a stack's
hover-only actions also show while the keyboard is in its row. Enter in a form
field = Connect; Esc leaves a field first, then closes the window. Focus
returns where it was after a dialog (`focusin` memory + the FocusService
tick) and falls back to the edited connection's row whenever it drops to
`<body>`.
### Settings window (`src/bun/settings-window.ts` + `ui/src/app/skins/shadlike/settings-window/`) ### Settings window (`src/bun/settings-window.ts` + `ui/src/app/skins/shadlike/settings-window/`)
Single instance, centered over main, **not modal**: every control is a plain Single instance, centered over main, **not modal**: every control is a plain
+4
View File
@@ -299,6 +299,10 @@ touching the adjacent code.
j/k) must resolve BEFORE it — and then that letter no longer starts a query j/k) must resolve BEFORE it — and then that letter no longer starts a query
(Settings warns). While a query is being typed, printable keys (Space too) (Settings warns). While a query is being typed, printable keys (Space too)
always extend it. always extend it.
- **In a master-detail list, selection must not follow focus** when the detail
is an unsaved form: the connection manager's `edit()` replaces the draft
without asking, so arrowing through the list would silently discard edits.
Arrows move focus; Enter loads.
- **A dialog's Esc must `stopPropagation`.** The connection manager closes on - **A dialog's Esc must `stopPropagation`.** The connection manager closes on
a window-level Escape; the dialog cleared its own signal first, so the a window-level Escape; the dialog cleared its own signal first, so the
window handler saw "no dialog open" and closed the whole window. window handler saw "no dialog open" and closed the whole window.
+1
View File
@@ -395,6 +395,7 @@ export const de = {
filter: "Tippen in der Dateiliste filtert sie; {backspace} löscht das letzte Zeichen des Filters", filter: "Tippen in der Dateiliste filtert sie; {backspace} löscht das letzte Zeichen des Filters",
shift: "Umschalt mit einer Bewegungstaste wählt die überfahrenen Zeilen aus", shift: "Umschalt mit einer Bewegungstaste wählt die überfahrenen Zeilen aus",
find: "{enter} und {shiftEnter} springen durch die Treffer der Suche", find: "{enter} und {shiftEnter} springen durch die Treffer der Suche",
manager: "↑ ↓ ← → und {enter} bewegen sich durch die Verbindungsliste",
sidebar: "↑ ↓ wechseln hier zwischen den Bereichen", sidebar: "↑ ↓ wechseln hier zwischen den Bereichen",
}, },
names: { names: {
+1
View File
@@ -403,6 +403,7 @@ export const en = {
filter: "Typing in the file list filters it; {backspace} trims the filter", filter: "Typing in the file list filters it; {backspace} trims the filter",
shift: "Shift with a movement key selects the rows the cursor passes", shift: "Shift with a movement key selects the rows the cursor passes",
find: "{enter} and {shiftEnter} step through the matches in the find box", find: "{enter} and {shiftEnter} step through the matches in the find box",
manager: "↑ ↓ ← → and {enter} move through the connection list",
sidebar: "↑ ↓ move between these sections", sidebar: "↑ ↓ move between these sections",
}, },
// key names for "Ctrl+Shift+Del"-style hints (not used on macOS, which shows ⌘⇧⌫) // key names for "Ctrl+Shift+Del"-style hints (not used on macOS, which shows ⌘⇧⌫)
+1
View File
@@ -395,6 +395,7 @@ export const es = {
filter: "Teclear en la lista de archivos la filtra; {backspace} borra el último carácter del filtro", filter: "Teclear en la lista de archivos la filtra; {backspace} borra el último carácter del filtro",
shift: "Mayús con una tecla de movimiento selecciona las filas por las que pasa el cursor", shift: "Mayús con una tecla de movimiento selecciona las filas por las que pasa el cursor",
find: "{enter} y {shiftEnter} recorren las coincidencias de la búsqueda", find: "{enter} y {shiftEnter} recorren las coincidencias de la búsqueda",
manager: "↑ ↓ ← → y {enter} recorren la lista de conexiones",
sidebar: "↑ ↓ cambian de sección aquí", sidebar: "↑ ↓ cambian de sección aquí",
}, },
names: { names: {
+1
View File
@@ -397,6 +397,7 @@ export const eu = {
filter: "Fitxategi-zerrendan idazteak iragazi egiten du; {backspace}(e)k iragazkiaren azken karakterea ezabatzen du", filter: "Fitxategi-zerrendan idazteak iragazi egiten du; {backspace}(e)k iragazkiaren azken karakterea ezabatzen du",
shift: "Maius mugimendu-tekla batekin kurtsoreak igarotako errenkadak hautatzen ditu", shift: "Maius mugimendu-tekla batekin kurtsoreak igarotako errenkadak hautatzen ditu",
find: "{enter} eta {shiftEnter} bilaketako bat-etortzeetan mugitzen dira", find: "{enter} eta {shiftEnter} bilaketako bat-etortzeetan mugitzen dira",
manager: "↑ ↓ ← → eta {enter} konexio-zerrendan mugitzen dira",
sidebar: "↑ ↓ atal batetik bestera mugitzen dira hemen", sidebar: "↑ ↓ atal batetik bestera mugitzen dira hemen",
}, },
names: { names: {
+1
View File
@@ -395,6 +395,7 @@ export const fr = {
filter: "Taper dans la liste des fichiers la filtre ; {backspace} efface le dernier caractère du filtre", filter: "Taper dans la liste des fichiers la filtre ; {backspace} efface le dernier caractère du filtre",
shift: "Maj avec une touche de déplacement sélectionne les lignes parcourues", shift: "Maj avec une touche de déplacement sélectionne les lignes parcourues",
find: "{enter} et {shiftEnter} parcourent les résultats de la recherche", find: "{enter} et {shiftEnter} parcourent les résultats de la recherche",
manager: "↑ ↓ ← → et {enter} parcourent la liste des connexions",
sidebar: "↑ ↓ changent de section ici", sidebar: "↑ ↓ changent de section ici",
}, },
names: { names: {
+1
View File
@@ -395,6 +395,7 @@ export const ja = {
filter: "ファイル一覧で入力すると絞り込まれます。{backspace} で最後の文字を消します", filter: "ファイル一覧で入力すると絞り込まれます。{backspace} で最後の文字を消します",
shift: "Shift と移動キーで、カーソルが通過した行を選択します", shift: "Shift と移動キーで、カーソルが通過した行を選択します",
find: "{enter} と {shiftEnter} で検索結果を移動します", find: "{enter} と {shiftEnter} で検索結果を移動します",
manager: "↑ ↓ ← → と {enter} で接続一覧を移動します",
sidebar: "↑ ↓ でここのセクションを切り替えます", sidebar: "↑ ↓ でここのセクションを切り替えます",
}, },
names: { names: {
+1
View File
@@ -395,6 +395,7 @@ export const zh = {
filter: "在文件列表中输入即可筛选;{backspace} 删除筛选的最后一个字符", filter: "在文件列表中输入即可筛选;{backspace} 删除筛选的最后一个字符",
shift: "Shift 加移动键会选中光标经过的行", shift: "Shift 加移动键会选中光标经过的行",
find: "{enter} 和 {shiftEnter} 在查找结果间跳转", find: "{enter} 和 {shiftEnter} 在查找结果间跳转",
manager: "↑ ↓ ← → 和 {enter} 在连接列表中移动",
sidebar: "↑ ↓ 在这些分区间切换", sidebar: "↑ ↓ 在这些分区间切换",
}, },
names: { names: {
@@ -15,7 +15,7 @@
<button <button
(click)="importWinScp()" (click)="importWinScp()"
[disabled]="importing()" [disabled]="importing()"
[title]="i18n.t('connMgr.importWinscpHint')" [title]="shortcuts.tip(i18n.t('connMgr.importWinscpHint'), 'conn-import')"
class="flex items-center gap-1.5 px-2 py-1 text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50" class="flex items-center gap-1.5 px-2 py-1 text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50"
> >
@if (importing()) { @if (importing()) {
@@ -33,21 +33,22 @@
<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()"
[title]="i18n.t('connMgr.newConnectionHint')" [title]="shortcuts.tip(i18n.t('connMgr.newConnectionHint'), 'conn-new')"
class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="tdesignAdd" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }} <ng-icon name="tdesignAdd" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }}
</button> </button>
<button <button
(click)="newStack()" (click)="newStack()"
[title]="i18n.t('connMgr.newStackHint')" [title]="shortcuts.tip(i18n.t('connMgr.newStackHint'), 'conn-new-stack')"
class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground" class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
> >
<ng-icon name="tdesignFolderAdd" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }} <ng-icon name="tdesignFolderAdd" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }}
</button> </button>
</div> </div>
<div class="flex-1 overflow-y-auto py-1"> <!-- ↑ ↓ ← → Home End Enter move through it (onListKeydown) -->
<div #list (keydown)="onListKeydown($event)" class="flex-1 overflow-y-auto py-1">
<!-- stacks --> <!-- stacks -->
@for (group of store.grouped().stacks; track group.stack.id) { @for (group of store.grouped().stacks; track group.stack.id) {
<div <div
@@ -59,8 +60,9 @@
[class.ring-inset]="dropTarget() === group.stack.id" [class.ring-inset]="dropTarget() === group.stack.id"
[class.ring-primary]="dropTarget() === group.stack.id" [class.ring-primary]="dropTarget() === group.stack.id"
> >
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5"> <div [attr.data-stack]="group.stack.id" class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
<button <button
data-row
(click)="toggleStack(group.stack)" (click)="toggleStack(group.stack)"
class="grid size-4 shrink-0 place-items-center text-muted-foreground hover:text-foreground" class="grid size-4 shrink-0 place-items-center text-muted-foreground hover:text-foreground"
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')" [title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
@@ -74,24 +76,25 @@
{{ group.stack.name }} {{ group.stack.name }}
</span> </span>
<div class="min-w-0 flex-1"></div> <div class="min-w-0 flex-1"></div>
<!-- count and actions swap on hover: they never share the space, <!-- count and actions swap on hover — and when the keyboard is in
so a long stack name can't collide with the buttons --> the row: they never share the space, so a long stack name
can't collide with the buttons -->
<span <span
class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-hover:hidden" class="shrink-0 text-[0.625rem] text-muted-foreground/70 tabular-nums group-focus-within:hidden group-hover:hidden"
> >
{{ group.connections.length }} {{ group.connections.length }}
</span> </span>
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex"> <div class="hidden shrink-0 items-center gap-0.5 group-focus-within:flex group-hover:flex">
<button <button
(click)="renameStack(group.stack)" (click)="renameStack(group.stack)"
[title]="i18n.t('connMgr.renameStack')" [title]="shortcuts.tip(i18n.t('connMgr.renameStack'), 'conn-rename-stack')"
class="grid size-4 place-items-center text-muted-foreground hover:text-foreground" class="grid size-4 place-items-center text-muted-foreground hover:text-foreground"
> >
<ng-icon name="tdesignEdit" size="0.75rem" /> <ng-icon name="tdesignEdit" size="0.75rem" />
</button> </button>
<button <button
(click)="deleteStack(group.stack)" (click)="deleteStack(group.stack)"
[title]="i18n.t('connMgr.deleteStack')" [title]="shortcuts.tip(i18n.t('connMgr.deleteStack'), 'conn-delete')"
class="grid size-4 place-items-center text-muted-foreground hover:text-destructive" class="grid size-4 place-items-center text-muted-foreground hover:text-destructive"
> >
<ng-icon name="tdesignDelete" size="0.75rem" /> <ng-icon name="tdesignDelete" size="0.75rem" />
@@ -99,7 +102,7 @@
@if (group.connections.length) { @if (group.connections.length) {
<button <button
(click)="openStack(group.stack.id)" (click)="openStack(group.stack.id)"
[title]="i18n.t('connMgr.openStack')" [title]="shortcuts.tip(i18n.t('connMgr.openStack'), 'conn-connect')"
class="grid size-4 place-items-center text-muted-foreground hover:text-primary" class="grid size-4 place-items-center text-muted-foreground hover:text-primary"
> >
<ng-icon name="tdesignArrowRight" size="0.8rem" /> <ng-icon name="tdesignArrowRight" size="0.8rem" />
@@ -110,6 +113,8 @@
@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
data-row
[attr.data-conn]="connection.id"
(click)="edit(connection)" (click)="edit(connection)"
draggable="true" draggable="true"
(dragstart)="onDragStart($event, connection)" (dragstart)="onDragStart($event, connection)"
@@ -146,6 +151,8 @@
} }
@for (connection of store.grouped().loose; track connection.id) { @for (connection of store.grouped().loose; track connection.id) {
<button <button
data-row
[attr.data-conn]="connection.id"
(click)="edit(connection)" (click)="edit(connection)"
draggable="true" draggable="true"
(dragstart)="onDragStart($event, connection)" (dragstart)="onDragStart($event, connection)"
@@ -174,10 +181,15 @@
<!-- 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-4 gap-y-3 overflow-y-auto p-5"> <!-- Enter in a field = Connect, the primary button -->
<div
(keydown.enter)="onFormEnter($event)"
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">{{ i18n.t('connMgr.name') }}</span> <span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
<input <input
#nameBox
[value]="d.name" [value]="d.name"
(input)="patch('name', $any($event.target).value)" (input)="patch('name', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.namePlaceholder')" [placeholder]="i18n.t('connMgr.namePlaceholder')"
@@ -327,6 +339,7 @@
<div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5"> <div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5">
<button <button
(click)="deleteCurrent()" (click)="deleteCurrent()"
[title]="shortcuts.tip(i18n.t('common.delete'), 'conn-delete')"
class="flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive" class="flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
> >
<ng-icon name="tdesignDelete" size="0.85rem" /> {{ i18n.t('common.delete') }} <ng-icon name="tdesignDelete" size="0.85rem" /> {{ i18n.t('common.delete') }}
@@ -341,6 +354,7 @@
<button <button
(click)="save()" (click)="save()"
[disabled]="!valid()" [disabled]="!valid()"
[title]="shortcuts.tip(i18n.t('common.save'), 'conn-save')"
class="px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50" class="px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
> >
{{ i18n.t('common.save') }} {{ i18n.t('common.save') }}
@@ -348,6 +362,7 @@
<button <button
(click)="saveAndConnect()" (click)="saveAndConnect()"
[disabled]="!valid() || busy()" [disabled]="!valid() || busy()"
[title]="shortcuts.tip(i18n.t('common.connect'), 'conn-connect')"
class="flex items-center gap-1.5 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50" class="flex items-center gap-1.5 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
@if (busy()) { @if (busy()) {
@@ -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 { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service'; import { I18nService } from '@core/i18n.service';
import type { SavedConnection, Stack } from '@shared/types'; import type { SavedConnection, Stack } from '@shared/types';
@@ -6,10 +16,12 @@ import { ConnectionsService } from '@core/connections.service';
import { ConfirmDialog } from '../dialogs/confirm-dialog'; import { ConfirmDialog } from '../dialogs/confirm-dialog';
import { PromptDialog } from '../dialogs/prompt-dialog'; import { PromptDialog } from '../dialogs/prompt-dialog';
import { DialogsService } from '@core/dialogs.service'; 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 { blockNativeContextMenu, blockReloadKeys, IS_MAC_CHROME } from '@core/platform';
import { RpcService } from '@core/rpc.service'; import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.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'; import { Toasts } from '../toasts/toasts';
interface Draft { interface Draft {
@@ -76,8 +88,8 @@ function fromDraft(d: Draft): SavedConnection {
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts], imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts],
templateUrl: './connection-manager.html', templateUrl: './connection-manager.html',
host: { host: {
'(window:keydown.escape)': 'onEscape()',
'(window:keydown)': 'onWindowKeydown($event)', '(window:keydown)': 'onWindowKeydown($event)',
'(window:focusin)': 'rememberFocus($event)',
'(window:contextmenu)': 'blockContextMenu($event)', '(window:contextmenu)': 'blockContextMenu($event)',
}, },
}) })
@@ -90,7 +102,13 @@ export class ConnectionManager {
// injected for its side effects: fetches settings and applies theme/accent/ // injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root // text-size data attributes to this window's document root
private readonly settings = inject(SettingsService); 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 draft = signal<Draft | null>(null);
readonly busy = signal(false); readonly busy = signal(false);
@@ -102,6 +120,27 @@ export class ConnectionManager {
/** Highlighted drop target: a stack id, 'loose', or null. */ /** Highlighted drop target: a stack id, 'loose', or null. */
readonly dropTarget = signal<string | null>(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(() => { readonly valid = computed(() => {
const d = this.draft(); const d = this.draft();
if (!d) return false; if (!d) return false;
@@ -141,21 +180,176 @@ export class ConnectionManager {
void this.rpc.request.managerClose({}); void this.rpc.request.managerClose({});
} }
onEscape(): void { /**
// in-window dialogs (confirm/prompt) handle their own Escape first * This window's keys (the `manager` scope), then the fixed ones: Esc closes
if (!this.dialogs.anyOpen) this.close(); * 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
/** Any-window shortcuts (interface scale). Escape has its own binding above. */ * their own Esc and stop it, so it never reaches here.
*/
onWindowKeydown(event: KeyboardEvent): void { onWindowKeydown(event: KeyboardEvent): void {
const id = this.shortcuts.claim(event, 'manager'); const id = this.shortcuts.claim(event, 'manager');
if (id === 'close-window') this.close(); if (id) this.runCommand(id);
else if (id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1); else if (event.key === 'Escape' && !this.dialogs.anyOpen) this.onEscape(event);
else if (id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1); else cycleFocus(document.body, event);
else if (id === 'scale-reset') this.settings.setUiScale(1);
blockReloadKeys(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 { blockContextMenu(event: Event): void {
blockNativeContextMenu(event); blockNativeContextMenu(event);
} }
@@ -36,7 +36,7 @@ const TRANSFER_MODES = ['single', 'multi'] as const;
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
/** The keys no remap moves, listed read-only under the editable ones. */ /** The keys no remap moves, listed read-only under the editable ones. */
const FIXED_LINES = ['escape', 'escList', 'dialogs', 'menus', 'filter', 'shift', 'find', 'sidebar'] as const; const FIXED_LINES = ['escape', 'escList', 'dialogs', 'menus', 'filter', 'shift', 'find', 'manager', 'sidebar'] as const;
/** A recording that could not simply be added: explained under its row. */ /** A recording that could not simply be added: explained under its row. */
interface CaptureNote { interface CaptureNote {