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,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,
|
||||
},
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user