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>
337 lines
12 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|