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>
This commit is contained in:
2026-10-05 14:48:11 +02:00
co-authored by Claude
parent 0eb5608873
commit d361297690
44 changed files with 2531 additions and 700 deletions
+83 -38
View File
@@ -1,30 +1,50 @@
import { Injectable, inject, signal } from '@angular/core';
import type { ColumnKey, EntryType } from '@shared/types';
import type { FileCommand } from './file-actions.service';
import { I18nService } from './i18n.service';
import { IS_MAC, IS_WINDOWS, MOD_LABEL } from './platform';
import { stepIndex } from './menu-nav';
import type { Side } from './panels.service';
import { IS_MAC, IS_WINDOWS } from './platform';
import { RpcService } from './rpc.service';
import type { ShortcutId } from './shortcuts.service';
/** Optional list columns, in display order (Name is fixed). */
export const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group'];
export interface MenuItem {
type?: 'separator';
/** separator: a rule; header: a non-pickable section title (the source picker). */
type?: 'separator' | 'header';
label?: string;
/** Emitted verbatim to the action dispatcher (`ctx-*` / `col:*` / `tab:*`). */
/** Handed to the request's onPick, else emitted to the action dispatcher
* (`col:*` / `tab:*`). */
action?: string;
checked?: boolean;
tooltip?: string;
/** Shortcut hint, rendered right-aligned. */
accelerator?: string;
/** The keymap command the item runs: its first key shows as the soft hint,
* and pressing that key while the menu is open picks the item. */
shortcut?: ShortcutId;
/** A registered icon (core/icons.ts), drawn in the check column. */
icon?: string;
/** Greyed and unclickable. Menus disable rather than hide, so the set of
* commands stays in the same place from one right-click to the next. */
disabled?: boolean;
}
/** Anything with a viewport point: a MouseEvent, or a row's rect for a
* keyboard-opened menu. */
export interface MenuPoint {
clientX: number;
clientY: number;
}
export interface MenuRequest {
x: number;
y: number;
items: MenuItem[];
/** Highlighted on open: the first item when the keyboard opened the menu. */
initialIndex: number;
/** Where a pick goes instead of the shared dispatcher (App.handleMenuAction). */
onPick?: (action: string) => void;
}
export interface TabMenuContext {
@@ -38,25 +58,27 @@ export interface TabMenuContext {
}
export interface RowMenuContext {
path: string;
side: Side;
name: string;
type: EntryType;
isLocal: boolean;
hasEntry: boolean; // false = right-click on empty panel space
}
/** Items the highlight can land on. */
export const isSelectable = (item: MenuItem): boolean => !item.type && !item.disabled;
// PORTABILITY: shell-integration wording per OS (resolved at build time for locale)
const revealKey = (): string =>
IS_MAC ? 'ctx.revealFinder' : IS_WINDOWS ? 'ctx.revealExplorer' : 'ctx.revealFileManager';
const trashKey = (): string => (IS_WINDOWS ? 'ctx.moveToRecycleBin' : 'ctx.moveToTrash');
/**
* Builds and opens the panel right-click menus. This used to be a native
* Builds and opens the HTML context menus. This used to be a native
* electrobun ContextMenu on the bun side, but `showContextMenu` is a documented
* no-op on Linux (electrobun's own .hutch/devkit/api/sdks/main/proc/linux.md),
* which left the whole right-click surface dead there. The menu is plain HTML
* now — one code path on every platform — and still emits the exact same
* action strings, so the dispatcher in app.ts is unchanged.
* now — one code path on every platform.
*/
@Injectable({ providedIn: 'root' })
export class ContextMenuService {
@@ -65,76 +87,91 @@ export class ContextMenuService {
readonly request = signal<MenuRequest | null>(null);
open(event: MouseEvent, items: MenuItem[]): void {
this.request.set({ x: event.clientX, y: event.clientY, items });
open(
point: MenuPoint,
items: MenuItem[],
opts: { keyboard?: boolean; onPick?: (action: string) => void } = {},
): void {
const initialIndex = opts.keyboard ? stepIndex(items.map(isSelectable), -1, 1) : -1;
this.request.set({ x: point.clientX, y: point.clientY, items, initialIndex, onPick: opts.onPick });
}
close(): void {
this.request.set(null);
}
/** A picked item goes through the same dispatcher the app menubar uses. */
onPick(action: string): void {
/** A pick without its own handler goes through the dispatcher the app
* menubar uses. */
emit(action: string): void {
this.rpc.emitMenuAction(action);
}
/** Row / empty-space menu for a file panel. */
/**
* Row / empty-space menu for a file panel. Actions are FileCommand ids,
* picked through FileActionsService.run. Icons only on the everyday verbs —
* the same glyphs as their toolbar buttons.
*/
rowItems(ctx: RowMenuContext): MenuItem[] {
const t = (key: string) => this.i18n.t(key);
const cmd = (id: FileCommand, label: string, extra: Partial<MenuItem> = {}): MenuItem => ({
label,
action: id,
shortcut: id,
...extra,
});
const items: MenuItem[] = [];
if (!ctx.hasEntry || ctx.name === '..') {
// empty space (or the parent row): folder-level actions
items.push({ label: t('ctx.newFolder'), action: 'ctx-new-folder' });
items.push({ label: t('ctx.newFile'), action: 'ctx-new-file' });
items.push(cmd('new-folder', t('ctx.newFolder'), { icon: 'tdesignFolderAdd' }));
items.push(cmd('new-file', t('ctx.newFile')));
items.push({ type: 'separator' });
items.push({ label: t('ctx.refresh'), action: 'ctx-refresh' });
items.push(cmd('refresh', t('ctx.refresh')));
if (ctx.isLocal) {
items.push({ type: 'separator' });
items.push({ label: t(revealKey()), action: 'ctx-reveal' });
items.push(cmd('reveal', t(revealKey())));
}
return items;
}
items.push({ label: t('ctx.copyToOther'), action: 'ctx-copy' });
// the arrow points at the other panel, as on the toolbar's Send button
const sendIcon = ctx.side === 'left' ? 'tdesignArrowRight' : 'tdesignArrowLeft';
items.push(cmd('send', t('ctx.copyToOther'), { icon: sendIcon }));
if (ctx.type === 'dir') {
items.push({ label: t('ctx.open'), action: 'ctx-open' });
items.push(cmd('open', t('ctx.open')));
} else {
if (ctx.isLocal) {
items.push({ label: t('ctx.open'), action: 'ctx-open-default', tooltip: t('ctx.openDefault') });
items.push(cmd('open-default', t('ctx.open'), { tooltip: t('ctx.openDefault') }));
}
items.push({ label: t('ctx.edit'), action: 'ctx-edit', accelerator: 'F4' });
items.push(cmd('edit', t('ctx.edit'), { icon: 'tdesignFile1' }));
}
items.push({ label: t('ctx.rename'), action: 'ctx-rename' });
items.push(cmd('rename', t('ctx.rename'), { icon: 'tdesignEdit' }));
// chmod on Windows local files only flips the read-only bit — hide the lie
if (!(ctx.isLocal && IS_WINDOWS)) {
items.push({ label: t('ctx.permissions'), action: 'ctx-chmod' });
items.push(cmd('chmod', t('ctx.permissions')));
}
items.push({ type: 'separator' });
items.push({ label: t('ctx.copyPath'), action: 'ctx-copy-path' });
items.push(cmd('copy-path', t('ctx.copyPath')));
if (ctx.isLocal) {
items.push({ label: t(revealKey()), action: 'ctx-reveal' });
items.push(cmd('reveal', t(revealKey())));
}
items.push({ type: 'separator' });
if (ctx.isLocal) {
items.push({
label: t(trashKey()),
action: 'ctx-trash',
...(IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}),
});
items.push(cmd('trash', t(trashKey()), IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}));
}
items.push({ label: t('ctx.delete'), action: 'ctx-delete' });
items.push(cmd('delete', t('ctx.delete'), { icon: 'tdesignDelete' }));
return items;
}
/**
* The workspace-tab menu. Actions carry the target tab's id, because the
* right-clicked tab is not necessarily the active one — nothing here may
* assume `activeWorkspace`.
* assume `activeWorkspace`. (The hints name the keys, which act on the
* ACTIVE tab; picking by key while this menu is open acts on THIS one.)
*
* Everything illegal is DISABLED, not omitted: a menu whose items move
* between right-clicks is worse than a dead row, and "Close Tab" greyed out explains
* why the last tab cannot be closed better than its absence does.
* between right-clicks is worse than a dead row, and "Close Tab" greyed out
* explains why the last tab cannot be closed better than its absence does.
*
* Duplicate/Rename/Save-session are deliberately absent until tabs have an
* identity to duplicate (a stored connection id) — the sessions round.
@@ -143,24 +180,32 @@ export class ContextMenuService {
const t = (key: string) => this.i18n.t(key);
const last = ctx.tabCount <= 1;
return [
{ label: t('ctx.tabNew'), action: 'tab:new', accelerator: `${MOD_LABEL}T` },
{ label: t('ctx.tabNew'), action: 'tab:new', shortcut: 'new-tab', icon: 'tdesignAdd' },
{ type: 'separator' },
{
label: t('ctx.tabClose'),
action: `tab:close:${ctx.id}`,
accelerator: `${MOD_LABEL}W`,
shortcut: 'close-tab',
icon: 'tdesignClose',
disabled: last,
},
{
label: t('ctx.tabCloseOthers'),
action: `tab:close-others:${ctx.id}`,
shortcut: 'close-other-tabs',
disabled: last,
},
{ label: t('ctx.tabCloseOthers'), action: `tab:close-others:${ctx.id}`, disabled: last },
{
label: t('ctx.tabCloseRight'),
action: `tab:close-right:${ctx.id}`,
shortcut: 'close-tabs-right',
disabled: ctx.index >= ctx.tabCount - 1,
},
{ type: 'separator' },
{
label: t('ctx.tabDisconnect'),
action: `tab:disconnect:${ctx.id}`,
shortcut: 'disconnect-tab',
disabled: !ctx.isRemote,
},
];
+3 -1
View File
@@ -47,8 +47,10 @@ export class DialogsService {
});
}
/** Any dialog up — the overwrite question included. Reads signals, so an
* effect or computed that asks re-runs when a dialog opens or closes. */
get anyOpen(): boolean {
return !!(this.prompt() || this.confirm() || this.chmod());
return !!(this.prompt() || this.confirm() || this.chmod() || this.overwrite());
}
closeAll(): void {
+114 -17
View File
@@ -1,25 +1,50 @@
import { Injectable, inject } from '@angular/core';
import type { FileEntry } from '@shared/types';
import { ContextMenuService } from './context-menu.service';
import { ContextMenuService, type MenuPoint } from './context-menu.service';
import { DialogsService } from './dialogs.service';
import { joinPath } from './format';
import { type PanelState, type Side } from './panels.service';
import { type PanelState, type Side, type SortKey } from './panels.service';
import { IS_WINDOWS } from './platform';
import { RpcService } from './rpc.service';
import { SettingsService } from './settings.service';
import type { ShortcutId } from './shortcuts.service';
import { I18nService } from './i18n.service';
import { ToastService } from './toast.service';
import { TransfersService } from './transfers.service';
/** The row (or empty space) the user last right-clicked; ctx-* actions apply here. */
export interface MenuTarget {
/** What a file command acts on: a panel, and the row it was invoked on (the
* cursor row from the keyboard, the right-clicked row from a menu; null =
* the folder itself). */
export interface FileTarget {
side: Side;
panel: PanelState;
entry: FileEntry | null;
}
/** The keymap commands that act on files — everything the keyboard, the
* right-click menu, the macOS menu bar and the panel toolbar run, all through
* run() so the four entry points can't drift apart. */
export type FileCommand = Extract<
ShortcutId,
| 'open'
| 'open-default'
| 'send'
| 'edit'
| 'rename'
| 'new-folder'
| 'new-file'
| 'chmod'
| 'delete'
| 'trash'
| 'copy-path'
| 'reveal'
| 'refresh'
| `sort-${string}`
>;
/**
* File operations invoked from a panel's toolbar / keyboard. They act on the
* GIVEN panel: on the selection if there is one, else on the cursor row.
* File operations, always against an explicit panel: on its selection if there
* is one, else on the target row.
*/
@Injectable({ providedIn: 'root' })
export class FileActionsService {
@@ -29,29 +54,101 @@ export class FileActionsService {
private readonly dialogs = inject(DialogsService);
private readonly menu = inject(ContextMenuService);
private readonly settings = inject(SettingsService);
private readonly transfers = inject(TransfersService);
menuTarget: MenuTarget | null = null;
/** Run a file command. One switch for every entry point. */
run(id: FileCommand, target: FileTarget): void {
const { side, panel } = target;
const local = panel.sessionId() === 'local';
// the ".." row stands for the folder itself, as on the empty-space menu
const entry = target.entry && target.entry.name !== '..' ? target.entry : null;
switch (id) {
case 'open':
case 'open-default':
// folders, symlinks and ".." navigate; a LOCAL file opens in its default
// app — from the menu's Open always, from Enter / double-click only
// when Settings → File list says so
if (!target.entry) return;
if (target.entry.type !== 'file') panel.open(target.entry);
else if (local && (id === 'open-default' || this.settings.openFilesOnEnter())) {
this.shell('open', target.entry.path);
}
return;
case 'send':
void this.transfers.copyToOtherPanel(side);
return;
case 'edit':
void this.edit(panel);
return;
case 'rename':
this.rename(panel);
return;
case 'new-folder':
this.mkdir(panel);
return;
case 'new-file':
this.createFile(panel);
return;
case 'chmod':
this.chmod(panel);
return;
case 'delete':
this.delete(panel);
return;
case 'trash':
if (local) void this.trash(panel);
return;
case 'copy-path':
this.shell('copyPath', entry?.path ?? panel.cwd());
return;
case 'reveal':
if (local) this.shell('reveal', entry?.path ?? panel.cwd());
return;
case 'refresh':
void panel.refresh();
return;
default:
// sort-name, sort-size…: again = reverse
if (id.startsWith('sort-')) panel.toggleSort(id.slice('sort-'.length) as SortKey);
}
}
/** Right-click entry point: remember the target, then open the menu on it. */
openContextMenu(event: MouseEvent, target: MenuTarget): void {
this.menuTarget = target;
const { panel, entry } = target;
/** Open the row / empty-space menu on `target`; its picks run here. */
openContextMenu(point: MenuPoint, target: FileTarget, keyboard = false): void {
const { side, panel, entry } = target;
this.menu.open(
event,
point,
this.menu.rowItems({
path: entry?.path ?? panel.cwd(),
side,
name: entry?.name ?? '',
type: entry?.type ?? 'dir',
isLocal: panel.sessionId() === 'local',
hasEntry: !!entry,
}),
{ keyboard, onPick: (action) => this.run(action as FileCommand, target) },
);
}
async trash(panel: PanelState, entry: FileEntry): Promise<void> {
const res = await this.rpc.request.moveToTrash({ path: entry.path });
if (!res.ok) this.toast.error(res.error);
else this.toast.info(this.i18n.t('toasts.movedToTrash', { name: entry.name }));
/** Local shell integration — only bun can reach the OS shell / clipboard. */
private shell(action: 'open' | 'reveal' | 'copyPath', path: string): void {
void this.rpc.request.shellItemAction({ action, path });
}
/** Move the selection (else the target row) to the Trash, one item at a time. */
async trash(panel: PanelState): Promise<void> {
const targets = this.targets(panel);
if (!targets.length) return;
let moved = 0;
for (const entry of targets) {
const res = await this.rpc.request.moveToTrash({ path: entry.path });
if (!res.ok) {
this.toast.error(res.error);
break;
}
moved++;
}
if (moved === 1) this.toast.info(this.i18n.t('toasts.movedToTrash', { name: targets[0].name }));
else if (moved > 1) this.toast.info(this.i18n.t('toasts.movedManyToTrash', { count: moved }));
await panel.refresh();
}
+36
View File
@@ -0,0 +1,36 @@
/** What Tab can land on. Tab order is DOM order: the app sets no positive tabindex. */
const FOCUSABLE = 'button, input, select, textarea, [tabindex]';
/**
* Tab / Shift+Tab through the focusable controls under `root`, in DOM order,
* wrapping. Done by hand because WKWebView (macOS) skips buttons, checkboxes
* and radios on Tab unless the user turned on System Settings → Keyboard →
* Keyboard navigation, which left dialogs, Settings and the connection manager
* mouse-only there. It is also the focus trap the modal dialogs lacked. A radio
* group is one stop (its checked radio), as natively; arrows move inside it.
* Returns true when it took the key.
*/
export function cycleFocus(root: Element, event: KeyboardEvent): boolean {
if (event.key !== 'Tab' || event.metaKey || event.ctrlKey || event.altKey) return false;
const stops = [...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(isTabStop);
if (!stops.length) return false;
event.preventDefault();
const at = stops.indexOf(document.activeElement as HTMLElement);
const step = event.shiftKey ? -1 : 1;
const next = at < 0 ? (step > 0 ? 0 : stops.length - 1) : (at + step + stops.length) % stops.length;
stops[next].focus();
return true;
}
function isTabStop(el: HTMLElement): boolean {
if (el.tabIndex < 0 || el.matches(':disabled')) return false;
if (!el.getClientRects().length) return false; // display:none, or inside a hidden parent
if (el instanceof HTMLInputElement && el.type === 'radio' && el.name) {
const group = [
...document.querySelectorAll<HTMLInputElement>(`input[type=radio][name="${CSS.escape(el.name)}"]`),
];
const checked = group.find((radio) => radio.checked);
return el === (checked ?? group[0]);
}
return true;
}
+13
View File
@@ -352,6 +352,19 @@ export class PanelsService {
this.focus.bump();
}
/** Each file list's key handler. A key that lands on <body> — a mouse click
* dropped focus (WebKit doesn't focus clicked buttons) — still reaches the
* active list through forwardListKey instead of dying until the next click. */
private readonly listKeys = new Map<Side, (event: KeyboardEvent) => void>();
registerListKeys(side: Side, handler: (event: KeyboardEvent) => void): void {
this.listKeys.set(side, handler);
}
forwardListKey(event: KeyboardEvent): void {
this.listKeys.get(this.activeSide())?.(event);
}
// ---- workspace (tab) management ----
newWorkspace(): void {
+12 -4
View File
@@ -21,10 +21,6 @@ export const IS_WINDOWS = PLATFORM === 'win32';
export const IS_LINUX = PLATFORM === 'linux';
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
export const APP_MODE: 'main' | 'editor' | 'manager' | 'settings' =
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
@@ -35,6 +31,18 @@ export const EDITOR_CONTEXT =
* hiddenInset layout, see chromeOptions), which reserves `pl-24` for them. */
export const IS_MAC_CHROME = IS_MAC;
/**
* WebView2 (Windows) reloads the whole page on F5 / Ctrl+R / Ctrl+F5 /
* Ctrl+Shift+R unless the page claims the key — dropping every session and any
* unsaved editor text. Every window root calls this for keys none of its
* bindings took. Known ceiling: unverified whether Electrobun already turns
* WebView2's browser keys off; harmless if it does.
*/
export function blockReloadKeys(event: KeyboardEvent): void {
if (!IS_WINDOWS || event.defaultPrevented) return;
if (event.key === 'F5' || (event.ctrlKey && event.code === 'KeyR')) event.preventDefault();
}
/**
* Kill the webview's default right-click menu (Reload, Inspect Element…)
* everywhere the app doesn't show its own native menu. Text fields keep the
+23
View File
@@ -17,6 +17,9 @@ export class SettingsService {
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
readonly editor = signal<AppSettings['editor']>(DEFAULT_SETTINGS.editor);
readonly editorApp = signal(DEFAULT_SETTINGS.editorApp);
/** Settings → Keyboard overrides; ShortcutsService applies them to the keymap. */
readonly keybindings = signal<AppSettings['keybindings']>(DEFAULT_SETTINGS.keybindings);
readonly openFilesOnEnter = signal(DEFAULT_SETTINGS.openFilesOnEnter);
// Auto-refresh is always on for now (see panels.service). To make it
// user-controllable: uncomment the field in types.ts, then here add
// readonly autoRefresh = signal(DEFAULT_SETTINGS.autoRefresh);
@@ -25,6 +28,10 @@ export class SettingsService {
private readonly rpc = inject(RpcService);
private readonly i18n = inject(I18nService);
/** Saves are whole-object: one sent before the first getSettings lands would
* write these defaults over the user's settings (a ⌘+ pressed as a window
* opens would wipe every keyboard remap), so nothing persists until then. */
private loaded = false;
/** Whether the app is actually rendering dark right now (drives the Tone row). */
readonly effectiveDark = computed(
@@ -45,6 +52,7 @@ export class SettingsService {
}
private applyAll(settings: AppSettings): void {
this.loaded = true;
this.locale.set(settings.locale);
this.theme.set(settings.theme);
this.darkTone.set(settings.darkTone);
@@ -56,6 +64,8 @@ export class SettingsService {
this.autoClearCompleted.set(settings.autoClearCompleted);
this.editor.set(settings.editor);
this.editorApp.set(settings.editorApp);
this.keybindings.set(settings.keybindings);
this.openFilesOnEnter.set(settings.openFilesOnEnter);
this.applyTheme();
this.applyDarkTone();
this.applyUiScale();
@@ -123,6 +133,16 @@ export class SettingsService {
void this.persist();
}
setKeybindings(keybindings: AppSettings['keybindings']): void {
this.keybindings.set(keybindings);
void this.persist();
}
setOpenFilesOnEnter(open: boolean): void {
this.openFilesOnEnter.set(open);
void this.persist();
}
private applyTheme(): void {
const theme = this.theme();
if (theme === 'system') {
@@ -172,10 +192,13 @@ export class SettingsService {
autoClearCompleted: this.autoClearCompleted(),
editor: this.editor(),
editorApp: this.editorApp(),
keybindings: this.keybindings(),
openFilesOnEnter: this.openFilesOnEnter(),
};
}
private async persist(): Promise<void> {
if (!this.loaded) return;
await this.rpc.request.saveSettings({ settings: this.settings() });
}
}
+143 -96
View File
@@ -1,114 +1,161 @@
import { Injectable } from '@angular/core';
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 };
/**
* Every window-level shortcut the app answers to, by name. Components switch
* on these ids instead of key-matching inline, so the whole keymap is ONE
* table — the precondition for the vim-style bindings planned next, which
* will need a mode/leader state machine wrapped around exactly this point.
*/
export type ShortcutId =
// main window (workspace/tab management)
| 'new-tab'
| 'close-tab'
| 'cycle-tab'
| 'cycle-tab-back'
| 'go-to-tab' // 1-9; the digit rides in ShortcutMatch.digit
| 'open-settings'
// editor window
| 'save'
| 'find'
| 'close-window'
| 'toggle-wrap'
// any window
| 'scale-up'
| 'scale-down'
| 'scale-reset';
export interface ShortcutMatch {
id: ShortcutId;
/** Only for go-to-tab: the digit pressed, 1–9. */
digit?: number;
}
/** Which keymap a window consults. 'manager' and 'settings' answer only to the
* any-window shortcuts (scale). Escape/Tab stay component-local: their meaning
* is contextual state (find bar open? close prompt up? which pane?), not a
* global binding. */
export type ShortcutScope = 'main' | 'editor' | 'manager' | 'settings';
/**
* The keyboard layer: modifier helpers + one declarative keymap.
* 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.
*
* House rules encoded here rather than re-derived per component (they were
* previously copy-pasted inline in six places, drifting apart):
* - `mod` is metaKey OR ctrlKey — ⌘ on macOS, Ctrl elsewhere. The keyboard
* does not change with the window chrome (platform.ts says the same about
* MOD_LABEL).
* - Layout-dependent keys match on `event.code`, not `event.key`: digits
* (AZERTY's digit row shifts), +/- (key varies wildly across layouts), and
* letter chords with Alt (macOS Option produces Ω for ⌥Z). Plain letter
* chords match on `key` so they follow the user's layout.
* - isTypingTarget guards shortcuts that would be hostile mid-typing. It is
* deliberately NOT applied inside resolve(): the existing bindings rely on
* the path bar's stopPropagation and the editor WANTS Mod+S while typing.
* Callers opt in per shortcut (the scale keys do).
* - 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 {
/** The platform command modifier: ⌘ on macOS, Ctrl elsewhere. Mouse events
* qualify too — Mod+click toggle-select in the panels uses the same rule. */
isMod(event: KeyboardEvent | MouseEvent): boolean {
return event.metaKey || event.ctrlKey;
}
private readonly settings = inject(SettingsService);
private readonly i18n = inject(I18nService);
/** True when the event originates from something the user types into. */
isTypingTarget(event: KeyboardEvent): boolean {
const target = event.target as HTMLElement | null;
return !!target?.closest('input, textarea, select, [contenteditable="true"]');
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;
}
/**
* Match an event against the scope's keymap. Returns null for anything the
* table does not claim — callers must then leave the event alone.
* 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).
*/
resolve(event: KeyboardEvent, scope: ShortcutScope): ShortcutMatch | null {
const mod = this.isMod(event);
const key = event.key.toLowerCase();
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;
}
// ---- any window: interface scale ----
// event.code, not key: '+' needs Shift on many layouts and '-' moves
// around; the physical Equal/Minus keys (and the numpad pair) are stable.
if (mod && !event.altKey) {
if (event.code === 'Equal' || event.code === 'NumpadAdd') return { id: 'scale-up' };
if (event.code === 'Minus' || event.code === 'NumpadSubtract') return { id: 'scale-down' };
if (event.code === 'Digit0' || event.code === 'Numpad0') return { id: 'scale-reset' };
/** 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);
}
if (scope === 'manager' || scope === 'settings') return null; // any-window shortcuts only
resetAll(): void {
this.settings.setKeybindings({});
}
if (scope === 'main') {
// on macOS the app menu's ⌘, intercepts first; this is the Windows/Linux path
if (mod && key === ',') return { id: 'open-settings' };
if (mod && key === 't') return { id: 'new-tab' };
if (mod && key === 'w') return { id: 'close-tab' };
// ctrlKey only, never meta: ⌘Tab is the macOS app switcher
if (event.ctrlKey && event.key === 'Tab') {
return { id: event.shiftKey ? 'cycle-tab-back' : 'cycle-tab' };
}
if (mod && !event.shiftKey && !event.altKey) {
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1];
if (digit) return { id: 'go-to-tab', digit: Number(digit) };
}
return null;
}
/** 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;
}
// scope === 'editor'
if (mod && key === 's') return { id: 'save' };
if (mod && key === 'f') return { id: 'find' };
// on macOS the app menu intercepts ⌘W before this ever fires; this arm is
// the Windows/Linux path
if (mod && key === 'w') return { id: 'close-window' };
if (event.altKey && event.code === 'KeyZ') return { id: 'toggle-wrap' };
return null;
/** 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])',
);
}
}
+8
View File
@@ -45,6 +45,14 @@ export class UpdaterService {
void this.rpc.request.applyUpdate({}); // quits + relaunches
}
/** The pill's one action, also the "Install update" key: download when
* available, apply (relaunch) once ready; nothing otherwise. */
advance(): void {
const state = this.state();
if (state === 'available') void this.download();
else if (state === 'ready') this.apply();
}
/** Manual re-check (menu item). */
async check(): Promise<void> {
if (!this.rpc.available) return;