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:
@@ -1,22 +1,24 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Component, inject, signal, viewChild } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { BRANDING } from '@shared/branding';
|
||||
import { NATIVE_MENU_IDS } from '@shared/keymap';
|
||||
import type { ColumnKey } from '@shared/types';
|
||||
import { ConnectionsService } from '@core/connections.service';
|
||||
import { ContextMenuService } from '@core/context-menu.service';
|
||||
import { ContextMenu } from './context-menu';
|
||||
import { ChmodDialog } from './dialogs/chmod-dialog';
|
||||
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
||||
import { OverwriteDialog } from './dialogs/overwrite-dialog';
|
||||
import { PromptDialog } from './dialogs/prompt-dialog';
|
||||
import { DialogsService } from '@core/dialogs.service';
|
||||
import { FileActionsService } from '@core/file-actions.service';
|
||||
import { FileActionsService, type FileCommand } from '@core/file-actions.service';
|
||||
import { FilePanel } from './file-panel/file-panel';
|
||||
import { PanelsService } from '@core/panels.service';
|
||||
import { blockNativeContextMenu } from '@core/platform';
|
||||
import { blockNativeContextMenu, blockReloadKeys } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { ShortcutsService } from '@core/shortcuts.service';
|
||||
import { ShortcutsService, type ShortcutId } from '@core/shortcuts.service';
|
||||
import { StatusBar } from './status-bar/status-bar';
|
||||
import { TabBar } from './tab-bar/tab-bar';
|
||||
import { ToastService } from '@core/toast.service';
|
||||
@@ -44,6 +46,7 @@ import { UpdaterService } from '@core/updater.service';
|
||||
host: {
|
||||
'(window:keydown)': 'onWindowKeydown($event)',
|
||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||
'(mousedown)': 'keepListFocus($event)',
|
||||
},
|
||||
})
|
||||
export class App {
|
||||
@@ -58,6 +61,8 @@ export class App {
|
||||
private readonly settings = inject(SettingsService);
|
||||
private readonly shortcuts = inject(ShortcutsService);
|
||||
private readonly updater = inject(UpdaterService);
|
||||
private readonly menu = inject(ContextMenuService);
|
||||
private readonly transferBar = viewChild(TransferBar);
|
||||
|
||||
/** The connection-manager window is open — this window is modal-blocked. */
|
||||
readonly managerOpen = signal(false);
|
||||
@@ -105,10 +110,6 @@ export class App {
|
||||
|
||||
/** Native application-menu / manager-window actions forwarded from bun. */
|
||||
private async handleMenuAction(action: string): Promise<void> {
|
||||
if (action.startsWith('ctx-')) {
|
||||
this.handleContextAction(action);
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('connect:')) {
|
||||
const connection = await this.findConnection(action.slice('connect:'.length));
|
||||
if (connection) {
|
||||
@@ -137,22 +138,19 @@ export class App {
|
||||
}
|
||||
switch (action) {
|
||||
case 'new-tab':
|
||||
this.panels.newWorkspace();
|
||||
break;
|
||||
case 'close-tab':
|
||||
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
||||
this.runWindowCommand(action);
|
||||
break;
|
||||
// the macOS File / View items act on the active panel, exactly like their keys
|
||||
case 'new-folder':
|
||||
this.actions.mkdir(this.panels.active());
|
||||
break;
|
||||
case 'new-file':
|
||||
this.actions.createFile(this.panels.active());
|
||||
this.runFileCommand(action);
|
||||
break;
|
||||
case 'edit-file':
|
||||
void this.actions.edit(this.panels.active());
|
||||
this.runFileCommand('edit');
|
||||
break;
|
||||
case 'refresh-active':
|
||||
void this.panels.active().refresh();
|
||||
this.runFileCommand('refresh');
|
||||
break;
|
||||
case 'check-for-updates':
|
||||
void this.updater.check();
|
||||
@@ -160,37 +158,102 @@ export class App {
|
||||
}
|
||||
}
|
||||
|
||||
/** Window shortcuts — matching lives in ShortcutsService (one keymap for the
|
||||
* app); this switch only maps ids onto actions. Panel-level keys stay on
|
||||
* the panels themselves. */
|
||||
private runFileCommand(id: FileCommand): void {
|
||||
const panel = this.panels.active();
|
||||
const entry = panel.sorted()[panel.cursor()] ?? null;
|
||||
this.actions.run(id, { side: this.panels.activeSide(), panel, entry });
|
||||
}
|
||||
|
||||
/**
|
||||
* Window keys — the `main` scope of the shared keymap (ShortcutsService); the
|
||||
* focused file list handles its own `panel` keys first. Whatever nobody
|
||||
* claims ends at the WebView2 reload guard.
|
||||
*/
|
||||
onWindowKeydown(event: KeyboardEvent): void {
|
||||
if (this.managerOpen()) return; // modal-blocked behind the manager
|
||||
const match = this.shortcuts.resolve(event, 'main');
|
||||
if (!match) return;
|
||||
// scale keys are hostile mid-typing ('-' in a rename prompt must type '-');
|
||||
// the tab shortcuts keep firing there, as they always did
|
||||
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
|
||||
event.preventDefault();
|
||||
switch (match.id) {
|
||||
this.handleWindowKey(event);
|
||||
blockReloadKeys(event); // a no-op once something claimed the key
|
||||
}
|
||||
|
||||
private handleWindowKey(event: KeyboardEvent): void {
|
||||
// a component took it (the list, a menu, a dialog), or the connection
|
||||
// manager holds this window modal
|
||||
if (event.defaultPrevented || this.managerOpen()) return;
|
||||
if (this.dialogs.anyOpen || this.menu.request()) {
|
||||
this.swallowBehindOverlay(event);
|
||||
return;
|
||||
}
|
||||
if (event.target === document.body) {
|
||||
// a click dropped focus to <body>: the active list still gets its keys
|
||||
this.panels.forwardListKey(event);
|
||||
this.panels.refocus();
|
||||
if (event.defaultPrevented) return;
|
||||
}
|
||||
const id = this.shortcuts.claim(event, 'main');
|
||||
if (id) this.runWindowCommand(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* A dialog or menu owns the keyboard. Window commands are swallowed — an
|
||||
* unclaimed ⌘T would fall through to the macOS menu bar and open a tab
|
||||
* behind the dialog — and so are the panel commands the menu bar carries
|
||||
* (⌘N, ⌘E…). The interface-scale keys still work.
|
||||
*/
|
||||
private swallowBehindOverlay(event: KeyboardEvent): void {
|
||||
const id = this.shortcuts.resolve(event, 'main');
|
||||
if (id === 'scale-up' || id === 'scale-down' || id === 'scale-reset') {
|
||||
const claimed = this.shortcuts.claim(event, 'main');
|
||||
if (claimed) this.runWindowCommand(claimed);
|
||||
return;
|
||||
}
|
||||
const panelId = id ? null : this.shortcuts.resolve(event, 'panel');
|
||||
if (id || (panelId && NATIVE_MENU_IDS.includes(panelId))) event.preventDefault();
|
||||
}
|
||||
|
||||
private runWindowCommand(id: ShortcutId): void {
|
||||
const active = this.panels.activeWorkspaceId();
|
||||
switch (id) {
|
||||
case 'new-tab':
|
||||
this.panels.newWorkspace();
|
||||
break;
|
||||
case 'close-tab':
|
||||
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
||||
this.panels.closeWorkspace(active);
|
||||
break;
|
||||
case 'cycle-tab':
|
||||
case 'close-other-tabs':
|
||||
this.panels.closeOtherWorkspaces(active);
|
||||
break;
|
||||
case 'close-tabs-right':
|
||||
this.panels.closeWorkspacesToRight(active);
|
||||
break;
|
||||
case 'next-tab':
|
||||
this.panels.cycleWorkspace(1);
|
||||
break;
|
||||
case 'cycle-tab-back':
|
||||
case 'prev-tab':
|
||||
this.panels.cycleWorkspace(-1);
|
||||
break;
|
||||
case 'go-to-tab':
|
||||
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox
|
||||
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1);
|
||||
case 'go-to-last-tab':
|
||||
this.panels.goToWorkspace(-1);
|
||||
break;
|
||||
case 'move-tab-left':
|
||||
this.moveActiveTab(-1);
|
||||
break;
|
||||
case 'move-tab-right':
|
||||
this.moveActiveTab(1);
|
||||
break;
|
||||
case 'disconnect-tab':
|
||||
void this.panels.disconnectWorkspace(active);
|
||||
break;
|
||||
case 'open-settings':
|
||||
void this.rpc.request.openSettings({});
|
||||
break;
|
||||
case 'connection-manager':
|
||||
void this.rpc.request.openConnectionManager({});
|
||||
break;
|
||||
case 'toggle-transfers':
|
||||
this.toggleTransfers();
|
||||
break;
|
||||
case 'install-update':
|
||||
this.updater.advance();
|
||||
break;
|
||||
case 'scale-up':
|
||||
this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||
break;
|
||||
@@ -200,10 +263,48 @@ export class App {
|
||||
case 'scale-reset':
|
||||
this.settings.setUiScale(1);
|
||||
break;
|
||||
default: {
|
||||
// go-to-tab-1…8: that tab (go-to-last-tab is ⌘9, like Firefox)
|
||||
const n = this.shortcuts.binding(id)?.n;
|
||||
if (n) this.panels.goToWorkspace(n - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Move-tab keys: the keyboard's tab drag. */
|
||||
private moveActiveTab(delta: 1 | -1): void {
|
||||
const list = this.panels.workspaces();
|
||||
const index = list.findIndex((w) => w.id === this.panels.activeWorkspaceId());
|
||||
if (index >= 0) this.panels.moveWorkspace(list[index].id, index + delta);
|
||||
}
|
||||
|
||||
/** ⌘J: open the transfer list with focus in it (Esc goes back to the files),
|
||||
* or close it. It only renders with jobs, so with none there is nothing to show. */
|
||||
private toggleTransfers(): void {
|
||||
if (!this.transfers.hasJobs()) return;
|
||||
const open = !this.transfers.expanded();
|
||||
this.transfers.expanded.set(open);
|
||||
if (open) this.transferBar()?.focusFirst();
|
||||
else this.panels.refocus();
|
||||
}
|
||||
|
||||
/**
|
||||
* A click on a chrome button keeps keyboard focus where it was — normally the
|
||||
* file list. WebKit drops focus to <body> on a button click (every list key
|
||||
* then died until the list was clicked), Windows parks it on the button,
|
||||
* where Space would click it again. The click itself still fires. Text
|
||||
* fields, menus, dialogs and the draggable tabs keep their default.
|
||||
*/
|
||||
keepListFocus(event: MouseEvent): void {
|
||||
const target = event.target as Element;
|
||||
if (
|
||||
target.closest('button') &&
|
||||
!target.closest('input, select, textarea, [role=menu], [role=dialog], [draggable=true]')
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/** Context-menu actions apply to the row/panel remembered at right-click time. */
|
||||
/**
|
||||
* Workspace-tab menu actions. The target id travels INSIDE the action string
|
||||
* because the right-clicked tab is not necessarily the active one — nothing
|
||||
@@ -232,55 +333,4 @@ export class App {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleContextAction(action: string): void {
|
||||
const target = this.actions.menuTarget;
|
||||
if (!target) return;
|
||||
const { panel, side, entry } = target;
|
||||
// the empty-space menu acts on the directory itself
|
||||
const path = entry?.path ?? panel.cwd();
|
||||
switch (action) {
|
||||
case 'ctx-open':
|
||||
if (entry) panel.open(entry);
|
||||
break;
|
||||
// local shell integration — only bun can reach the OS shell/clipboard
|
||||
case 'ctx-reveal':
|
||||
void this.rpc.request.shellItemAction({ action: 'reveal', path });
|
||||
break;
|
||||
case 'ctx-open-default':
|
||||
void this.rpc.request.shellItemAction({ action: 'open', path });
|
||||
break;
|
||||
case 'ctx-copy-path':
|
||||
void this.rpc.request.shellItemAction({ action: 'copyPath', path });
|
||||
break;
|
||||
case 'ctx-edit':
|
||||
void this.actions.edit(panel);
|
||||
break;
|
||||
case 'ctx-copy':
|
||||
void this.transfers.copyToOtherPanel(side);
|
||||
break;
|
||||
case 'ctx-rename':
|
||||
this.actions.rename(panel);
|
||||
break;
|
||||
case 'ctx-chmod':
|
||||
this.actions.chmod(panel);
|
||||
break;
|
||||
case 'ctx-delete':
|
||||
this.actions.delete(panel);
|
||||
break;
|
||||
case 'ctx-trash':
|
||||
if (entry) void this.actions.trash(panel, entry);
|
||||
break;
|
||||
case 'ctx-new-folder':
|
||||
this.actions.mkdir(panel);
|
||||
break;
|
||||
case 'ctx-new-file':
|
||||
this.actions.createFile(panel);
|
||||
break;
|
||||
case 'ctx-refresh':
|
||||
void panel.refresh();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user