Files
miniSCP/ui/src/app/skins/shadlike/app.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

337 lines
12 KiB
TypeScript

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, type FileCommand } from '@core/file-actions.service';
import { FilePanel } from './file-panel/file-panel';
import { PanelsService } from '@core/panels.service';
import { blockNativeContextMenu, blockReloadKeys } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.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';
import { Toasts } from './toasts/toasts';
import { TransferBar } from './transfer-bar/transfer-bar';
import { TransfersService } from '@core/transfers.service';
import { UpdaterService } from '@core/updater.service';
@Component({
selector: 'app-root',
imports: [
NgIcon,
FilePanel,
TabBar,
StatusBar,
PromptDialog,
ConfirmDialog,
ChmodDialog,
OverwriteDialog,
Toasts,
TransferBar,
ContextMenu,
],
templateUrl: './app.html',
host: {
'(window:keydown)': 'onWindowKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)',
'(mousedown)': 'keepListFocus($event)',
},
})
export class App {
readonly i18n = inject(I18nService);
readonly rpc = inject(RpcService);
readonly toast = inject(ToastService);
readonly dialogs = inject(DialogsService);
readonly panels = inject(PanelsService);
readonly actions = inject(FileActionsService);
readonly connections = inject(ConnectionsService);
private readonly transfers = inject(TransfersService);
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);
constructor() {
document.title = BRANDING.appName;
if (this.rpc.available) {
this.rpc.onMenuAction((action) => void this.handleMenuAction(action));
this.rpc.onManagerState((open) => this.managerOpen.set(open));
void this.promptForEditLeftovers();
}
}
focusManager(): void {
void this.rpc.request.openConnectionManager({});
}
blockContextMenu(event: Event): void {
blockNativeContextMenu(event);
}
/** Temp files from external edits in a previous run (crash / quit mid-edit). */
private async promptForEditLeftovers(): Promise<void> {
const res = await this.rpc.request.getEditLeftovers({});
if (!res.ok || !res.names.length) return;
const shown = res.names.slice(0, 5).join(', ');
const more =
res.names.length > 5 ? this.i18n.t('toasts.leftoverMore', { count: res.names.length - 5 }) : '';
this.dialogs.confirm.set({
title: this.i18n.t('toasts.leftoverTitle'),
body: this.i18n.t('toasts.leftoverBody', { count: res.names.length, names: shown + more }),
confirmLabel: this.i18n.t('toasts.leftoverClear'),
destructive: true,
onConfirm: () => void this.rpc.request.clearEditLeftovers({}),
});
}
/** A connection by id; reloads once if unseen (a manager-window save can
* race the connectionsChanged push). */
private async findConnection(id: string) {
const lookup = () => this.connections.connections().find((c) => c.id === id);
if (!lookup()) await this.connections.reload();
return lookup();
}
/** Native application-menu / manager-window actions forwarded from bun. */
private async handleMenuAction(action: string): Promise<void> {
if (action.startsWith('connect:')) {
const connection = await this.findConnection(action.slice('connect:'.length));
if (connection) {
// remote goes right, local stays left — the commander convention
void this.panels.connectPanel('right', connection, connection.name, connection.localDir);
}
return;
}
if (action.startsWith('col:')) {
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
return;
}
if (action.startsWith('tab:')) {
this.handleTabAction(action.slice('tab:'.length));
return;
}
if (action.startsWith('open-stack:')) {
const id = action.slice('open-stack:'.length);
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
if (!stackConnections.length) {
await this.connections.reload();
stackConnections = this.connections.connections().filter((c) => c.stackId === id);
}
if (stackConnections.length) this.panels.openStack(stackConnections);
return;
}
switch (action) {
case 'new-tab':
case 'close-tab':
this.runWindowCommand(action);
break;
// the macOS File / View items act on the active panel, exactly like their keys
case 'new-folder':
case 'new-file':
this.runFileCommand(action);
break;
case 'edit-file':
this.runFileCommand('edit');
break;
case 'refresh-active':
this.runFileCommand('refresh');
break;
case 'check-for-updates':
void this.updater.check();
break;
}
}
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 {
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(active);
break;
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 'prev-tab':
this.panels.cycleWorkspace(-1);
break;
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;
case 'scale-down':
this.settings.setUiScale(this.settings.uiScale() - 0.1);
break;
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();
}
}
/**
* Workspace-tab menu actions. The target id travels INSIDE the action string
* because the right-clicked tab is not necessarily the active one — nothing
* here may fall back to activeWorkspaceId.
*/
private handleTabAction(action: string): void {
if (action === 'new') {
this.panels.newWorkspace();
return;
}
const parsed = /^([a-z-]+):(\d+)$/.exec(action);
if (!parsed) return;
const id = Number(parsed[2]);
switch (parsed[1]) {
case 'close':
this.panels.closeWorkspace(id);
break;
case 'close-others':
this.panels.closeOtherWorkspaces(id);
break;
case 'close-right':
this.panels.closeWorkspacesToRight(id);
break;
case 'disconnect':
void this.panels.disconnectWorkspace(id);
break;
}
}
}