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>
223 lines
7.9 KiB
TypeScript
223 lines
7.9 KiB
TypeScript
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 { 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 {
|
|
/** separator: a rule; header: a non-pickable section title (the source picker). */
|
|
type?: 'separator' | 'header';
|
|
label?: string;
|
|
/** Handed to the request's onPick, else emitted to the action dispatcher
|
|
* (`col:*` / `tab:*`). */
|
|
action?: string;
|
|
checked?: boolean;
|
|
tooltip?: 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 {
|
|
/** The right-clicked tab (NOT necessarily the active one). */
|
|
id: number;
|
|
/** Position in the strip, for "close to the right". */
|
|
index: number;
|
|
tabCount: number;
|
|
/** Any pane in this tab holds a live remote session. */
|
|
isRemote: boolean;
|
|
}
|
|
|
|
export interface RowMenuContext {
|
|
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 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.
|
|
*/
|
|
@Injectable({ providedIn: 'root' })
|
|
export class ContextMenuService {
|
|
private readonly i18n = inject(I18nService);
|
|
private readonly rpc = inject(RpcService);
|
|
|
|
readonly request = signal<MenuRequest | null>(null);
|
|
|
|
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 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. 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(cmd('new-folder', t('ctx.newFolder'), { icon: 'tdesignFolderAdd' }));
|
|
items.push(cmd('new-file', t('ctx.newFile')));
|
|
items.push({ type: 'separator' });
|
|
items.push(cmd('refresh', t('ctx.refresh')));
|
|
if (ctx.isLocal) {
|
|
items.push({ type: 'separator' });
|
|
items.push(cmd('reveal', t(revealKey())));
|
|
}
|
|
return items;
|
|
}
|
|
|
|
// 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(cmd('open', t('ctx.open')));
|
|
} else {
|
|
if (ctx.isLocal) {
|
|
items.push(cmd('open-default', t('ctx.open'), { tooltip: t('ctx.openDefault') }));
|
|
}
|
|
items.push(cmd('edit', t('ctx.edit'), { icon: 'tdesignFile1' }));
|
|
}
|
|
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(cmd('chmod', t('ctx.permissions')));
|
|
}
|
|
items.push({ type: 'separator' });
|
|
items.push(cmd('copy-path', t('ctx.copyPath')));
|
|
if (ctx.isLocal) {
|
|
items.push(cmd('reveal', t(revealKey())));
|
|
}
|
|
items.push({ type: 'separator' });
|
|
if (ctx.isLocal) {
|
|
items.push(cmd('trash', t(trashKey()), IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}));
|
|
}
|
|
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`. (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.
|
|
*
|
|
* Duplicate/Rename/Save-session are deliberately absent until tabs have an
|
|
* identity to duplicate (a stored connection id) — the sessions round.
|
|
*/
|
|
tabItems(ctx: TabMenuContext): MenuItem[] {
|
|
const t = (key: string) => this.i18n.t(key);
|
|
const last = ctx.tabCount <= 1;
|
|
return [
|
|
{ label: t('ctx.tabNew'), action: 'tab:new', shortcut: 'new-tab', icon: 'tdesignAdd' },
|
|
{ type: 'separator' },
|
|
{
|
|
label: t('ctx.tabClose'),
|
|
action: `tab:close:${ctx.id}`,
|
|
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.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,
|
|
},
|
|
];
|
|
}
|
|
|
|
/** Column picker for the list headers: checkable toggles per column. */
|
|
columnItems(visible: ColumnKey[]): MenuItem[] {
|
|
return COLUMN_KEYS.map((key) => ({
|
|
label: this.i18n.t(`columns.${key}`),
|
|
action: `col:${key}`,
|
|
checked: visible.includes(key),
|
|
}));
|
|
}
|
|
}
|