Files
miniSCP/ui/src/app/core/shortcuts.service.ts
T
jgamboaandClaude d361297690 Put every shortcut on one shared keymap and make the app keyboard-reachable
The keymap was scattered over ShortcutsService if-chains, the file panel's
key switch, macOS-only menu accelerators and hand-typed hints in 16
tooltips x 7 locales, and large parts of the app had no keyboard path at
all. src/shared/keymap.ts is now the one table: scopes, per-platform
defaults, exact matching, formatting with localized key names, conflicts,
reserved combos and native-menu accelerators, proven by keymap.spec.ts.

- ShortcutsService resolves against the effective keymap: claim() takes
  the key (preventDefault, held keys repeat only movement, never mid-IME),
  and from a text field only chords and F-keys fire. The file panel, App,
  editor, manager and Settings windows all dispatch through it.
- FileActionsService.run is the one file-command dispatcher for the list
  keys, the row menu, the macOS menu bar and the toolbar.
- New keys: the row menu (Shift+F10 / Menu / Ctrl+Return), the source
  picker (Mod+K), the path bar (Mod+L), transfers (Mod+J), sorting
  (Alt+1-7), Shift+movement selection, close other tabs, move and
  disconnect tabs, refresh and edit on Windows/Linux, permissions, copy
  path, reveal, trash, open in default app, editor find next/previous.
- Right-click menus: soft shortcut hints, icons on the everyday verbs
  (the toolbar's glyphs), section headers, scrolling, item keys while
  open, a highlighted first item when opened from the keyboard. The panel
  source picker now renders through the same menu.
- Dialogs: role=dialog, a Tab trap via cycleFocus (WKWebView skips
  buttons on Tab without Keyboard navigation), initial focus, Esc that
  stops propagating; the chmod dialog takes focus and applies on Enter.
- Fixed: Ctrl+Tab also flipped the active panel; Esc in a manager dialog
  closed the manager; Ctrl-F/T fired Cmd shortcuts on macOS; shortcuts
  fired under dialogs; keys died after any button click on macOS; the
  editor's save prompt and find box never got focus; WebView2 reload keys;
  a window could save default settings before its first load.
- Settings closes on its own close-window key (new settingsClose request).

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

162 lines
6.2 KiB
TypeScript

import { Injectable, computed, inject } from '@angular/core';
import {
type Combo,
EN_KEY_NAMES,
effectiveKeymap,
firesInTextField,
formatCombo,
isRepeatable,
type KeyEventLike,
type KeyNames,
matchesEvent,
parseCombo,
type ResolvedBinding,
type Scope,
sameCombo,
serializeCombo,
type ShortcutId,
} from '@shared/keymap';
import { I18nService } from './i18n.service';
import { IS_MAC } from './platform';
import { SettingsService } from './settings.service';
export type { ShortcutId } from '@shared/keymap';
/** A keydown as resolve() reads it: the matching fields plus where it came from. */
type KeyInput = KeyEventLike & { target?: EventTarget | null; isComposing?: boolean; keyCode?: number };
/**
* The keyboard layer over the shared keymap (src/shared/keymap.ts): resolves
* keydowns against the EFFECTIVE keymap — defaults plus the Settings → Keyboard
* remaps, live in every window — and formats the hints menus and tooltips show.
*
* - Matching is exact: ⌃F is not ⌘F, Ctrl+Tab is not Tab. `Mod` is ⌘ on
* macOS and Ctrl elsewhere.
* - On macOS the PAGE sees ⌘-keys first: WKWebView hands key equivalents to the
* page before the app menu (WebKit's WebViewImpl::performKeyEquivalent), so a
* key a window claims here (claim() → preventDefault) never reaches the menu
* bar, whose accelerators bun builds from the same table. Keys nobody claims
* fall through to it.
* - From a text field only firesInTextField() combos count (⌘S yes, Delete no).
* - Escape, the arrows inside menus and dialog Enter stay component-local:
* they are fixed, so nobody can remap themselves out of a dialog.
*/
@Injectable({ providedIn: 'root' })
export class ShortcutsService {
private readonly settings = inject(SettingsService);
private readonly i18n = inject(I18nService);
readonly keymap = computed(() => effectiveKeymap(this.settings.keybindings(), IS_MAC));
private readonly byId = computed(() => new Map(this.keymap().map((b) => [b.id, b])));
/** Localized key names for the non-mac spelling ("Strg+Entf"); reading t()
* makes every hint follow a language switch. */
private readonly names = computed<KeyNames>(() => {
const names = { ...EN_KEY_NAMES };
for (const key of Object.keys(names) as (keyof KeyNames)[]) {
names[key] = this.i18n.t(`keys.names.${key}`);
}
return names;
});
/** The command a keydown triggers in `scope`, or null. Window scopes include
* `any`; `panel` doesn't (the window root runs those). */
resolve(event: KeyInput, scope: Scope): ShortcutId | null {
// an IME's Enter commits a candidate, it is never a shortcut
if (event.isComposing || event.keyCode === 229) return null;
const typing = this.isTypingTarget(event);
for (const binding of this.keymap()) {
if (!binding.scopes.includes(scope) && (scope === 'panel' || !binding.scopes.includes('any'))) {
continue;
}
for (const combo of binding.combos) {
if (typing && !firesInTextField(combo, IS_MAC)) continue;
if (matchesEvent(combo, event, IS_MAC)) return binding.id;
}
}
return null;
}
/**
* resolve() + take the key: a match is preventDefault-ed, so the macOS menu
* never runs it a second time. A held key repeats only movement-like
* commands; any other repeat is swallowed (holding ⌘W closes one tab).
*/
claim(event: KeyboardEvent, scope: Scope): ShortcutId | null {
const id = this.resolve(event, scope);
if (!id) return null;
event.preventDefault();
return event.repeat && !isRepeatable(id) ? null : id;
}
/** Whether a keydown is one of `id`'s combos (an open menu matching its items). */
matches(id: ShortcutId, event: KeyEventLike): boolean {
return !!this.byId().get(id)?.combos.some((c) => matchesEvent(c, event, IS_MAC));
}
binding(id: ShortcutId): ResolvedBinding | undefined {
return this.byId().get(id);
}
format(combo: Combo): string {
return formatCombo(combo, IS_MAC, this.names());
}
/** The hint menus show: the command's first key, or '' when unbound. */
label(id: ShortcutId): string {
const first = this.byId().get(id)?.combos[0];
return first ? this.format(first) : '';
}
labels(id: ShortcutId): string[] {
return (this.byId().get(id)?.combos ?? []).map((c) => this.format(c));
}
/** A tooltip with the command's keys: "Delete (F8 / Del)"; bare when unbound. */
tip(text: string, id: ShortcutId): string {
const keys = this.labels(id);
return keys.length ? `${text} (${keys.join(' / ')})` : text;
}
/** The same for a FIXED key — one no remap moves, such as Enter in the find
* box — so it reads in the platform's spelling too (⇧↩ / Shift+Enter). */
tipFixed(text: string, combo: string): string {
const parsed = parseCombo(combo, IS_MAC);
return parsed ? `${text} (${this.format(parsed)})` : text;
}
/**
* Store new combos for commands (Settings → Keyboard). Only rows that differ
* from their defaults are kept, so a reset — or a remap back to the
* default — leaves nothing behind.
*/
rebind(changes: Partial<Record<ShortcutId, Combo[]>>): void {
const next = { ...this.settings.keybindings() };
for (const [id, combos] of Object.entries(changes) as [ShortcutId, Combo[]][]) {
const defaults = this.byId().get(id)?.defaults ?? [];
const isDefault =
combos.length === defaults.length && combos.every((c, i) => sameCombo(c, defaults[i]));
if (isDefault) delete next[id];
else next[id] = combos.map(serializeCombo);
}
this.settings.setKeybindings(next);
}
resetAll(): void {
this.settings.setKeybindings({});
}
/** The platform command modifier: ⌘ on macOS, Ctrl elsewhere — exactly one.
* Mouse events too: Mod+click toggle-selects in the panels. */
isMod(event: KeyboardEvent | MouseEvent): boolean {
return IS_MAC ? event.metaKey : event.ctrlKey;
}
/** True when the event comes from something the user types into. */
isTypingTarget(event: { target?: EventTarget | null }): boolean {
const target = event.target as HTMLElement | null;
return !!target?.closest?.(
'textarea, select, [contenteditable="true"], input:not([type=checkbox]):not([type=radio]):not([type=button])',
);
}
}