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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 ⌘⇧⌫)
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user