Files
miniSCP/ui/src/app/core/context-menu.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

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),
}));
}
}