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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -47,8 +47,10 @@ export class DialogsService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Any dialog up — the overwrite question included. Reads signals, so an
|
||||
* effect or computed that asks re-runs when a dialog opens or closes. */
|
||||
get anyOpen(): boolean {
|
||||
return !!(this.prompt() || this.confirm() || this.chmod());
|
||||
return !!(this.prompt() || this.confirm() || this.chmod() || this.overwrite());
|
||||
}
|
||||
|
||||
closeAll(): void {
|
||||
|
||||
@@ -1,25 +1,50 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import type { FileEntry } from '@shared/types';
|
||||
import { ContextMenuService } from './context-menu.service';
|
||||
import { ContextMenuService, type MenuPoint } from './context-menu.service';
|
||||
import { DialogsService } from './dialogs.service';
|
||||
import { joinPath } from './format';
|
||||
import { type PanelState, type Side } from './panels.service';
|
||||
import { type PanelState, type Side, type SortKey } from './panels.service';
|
||||
import { IS_WINDOWS } from './platform';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
import type { ShortcutId } from './shortcuts.service';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { ToastService } from './toast.service';
|
||||
import { TransfersService } from './transfers.service';
|
||||
|
||||
/** The row (or empty space) the user last right-clicked; ctx-* actions apply here. */
|
||||
export interface MenuTarget {
|
||||
/** What a file command acts on: a panel, and the row it was invoked on (the
|
||||
* cursor row from the keyboard, the right-clicked row from a menu; null =
|
||||
* the folder itself). */
|
||||
export interface FileTarget {
|
||||
side: Side;
|
||||
panel: PanelState;
|
||||
entry: FileEntry | null;
|
||||
}
|
||||
|
||||
/** The keymap commands that act on files — everything the keyboard, the
|
||||
* right-click menu, the macOS menu bar and the panel toolbar run, all through
|
||||
* run() so the four entry points can't drift apart. */
|
||||
export type FileCommand = Extract<
|
||||
ShortcutId,
|
||||
| 'open'
|
||||
| 'open-default'
|
||||
| 'send'
|
||||
| 'edit'
|
||||
| 'rename'
|
||||
| 'new-folder'
|
||||
| 'new-file'
|
||||
| 'chmod'
|
||||
| 'delete'
|
||||
| 'trash'
|
||||
| 'copy-path'
|
||||
| 'reveal'
|
||||
| 'refresh'
|
||||
| `sort-${string}`
|
||||
>;
|
||||
|
||||
/**
|
||||
* File operations invoked from a panel's toolbar / keyboard. They act on the
|
||||
* GIVEN panel: on the selection if there is one, else on the cursor row.
|
||||
* File operations, always against an explicit panel: on its selection if there
|
||||
* is one, else on the target row.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FileActionsService {
|
||||
@@ -29,29 +54,101 @@ export class FileActionsService {
|
||||
private readonly dialogs = inject(DialogsService);
|
||||
private readonly menu = inject(ContextMenuService);
|
||||
private readonly settings = inject(SettingsService);
|
||||
private readonly transfers = inject(TransfersService);
|
||||
|
||||
menuTarget: MenuTarget | null = null;
|
||||
/** Run a file command. One switch for every entry point. */
|
||||
run(id: FileCommand, target: FileTarget): void {
|
||||
const { side, panel } = target;
|
||||
const local = panel.sessionId() === 'local';
|
||||
// the ".." row stands for the folder itself, as on the empty-space menu
|
||||
const entry = target.entry && target.entry.name !== '..' ? target.entry : null;
|
||||
switch (id) {
|
||||
case 'open':
|
||||
case 'open-default':
|
||||
// folders, symlinks and ".." navigate; a LOCAL file opens in its default
|
||||
// app — from the menu's Open always, from Enter / double-click only
|
||||
// when Settings → File list says so
|
||||
if (!target.entry) return;
|
||||
if (target.entry.type !== 'file') panel.open(target.entry);
|
||||
else if (local && (id === 'open-default' || this.settings.openFilesOnEnter())) {
|
||||
this.shell('open', target.entry.path);
|
||||
}
|
||||
return;
|
||||
case 'send':
|
||||
void this.transfers.copyToOtherPanel(side);
|
||||
return;
|
||||
case 'edit':
|
||||
void this.edit(panel);
|
||||
return;
|
||||
case 'rename':
|
||||
this.rename(panel);
|
||||
return;
|
||||
case 'new-folder':
|
||||
this.mkdir(panel);
|
||||
return;
|
||||
case 'new-file':
|
||||
this.createFile(panel);
|
||||
return;
|
||||
case 'chmod':
|
||||
this.chmod(panel);
|
||||
return;
|
||||
case 'delete':
|
||||
this.delete(panel);
|
||||
return;
|
||||
case 'trash':
|
||||
if (local) void this.trash(panel);
|
||||
return;
|
||||
case 'copy-path':
|
||||
this.shell('copyPath', entry?.path ?? panel.cwd());
|
||||
return;
|
||||
case 'reveal':
|
||||
if (local) this.shell('reveal', entry?.path ?? panel.cwd());
|
||||
return;
|
||||
case 'refresh':
|
||||
void panel.refresh();
|
||||
return;
|
||||
default:
|
||||
// sort-name, sort-size…: again = reverse
|
||||
if (id.startsWith('sort-')) panel.toggleSort(id.slice('sort-'.length) as SortKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** Right-click entry point: remember the target, then open the menu on it. */
|
||||
openContextMenu(event: MouseEvent, target: MenuTarget): void {
|
||||
this.menuTarget = target;
|
||||
const { panel, entry } = target;
|
||||
/** Open the row / empty-space menu on `target`; its picks run here. */
|
||||
openContextMenu(point: MenuPoint, target: FileTarget, keyboard = false): void {
|
||||
const { side, panel, entry } = target;
|
||||
this.menu.open(
|
||||
event,
|
||||
point,
|
||||
this.menu.rowItems({
|
||||
path: entry?.path ?? panel.cwd(),
|
||||
side,
|
||||
name: entry?.name ?? '',
|
||||
type: entry?.type ?? 'dir',
|
||||
isLocal: panel.sessionId() === 'local',
|
||||
hasEntry: !!entry,
|
||||
}),
|
||||
{ keyboard, onPick: (action) => this.run(action as FileCommand, target) },
|
||||
);
|
||||
}
|
||||
|
||||
async trash(panel: PanelState, entry: FileEntry): Promise<void> {
|
||||
const res = await this.rpc.request.moveToTrash({ path: entry.path });
|
||||
if (!res.ok) this.toast.error(res.error);
|
||||
else this.toast.info(this.i18n.t('toasts.movedToTrash', { name: entry.name }));
|
||||
/** Local shell integration — only bun can reach the OS shell / clipboard. */
|
||||
private shell(action: 'open' | 'reveal' | 'copyPath', path: string): void {
|
||||
void this.rpc.request.shellItemAction({ action, path });
|
||||
}
|
||||
|
||||
/** Move the selection (else the target row) to the Trash, one item at a time. */
|
||||
async trash(panel: PanelState): Promise<void> {
|
||||
const targets = this.targets(panel);
|
||||
if (!targets.length) return;
|
||||
let moved = 0;
|
||||
for (const entry of targets) {
|
||||
const res = await this.rpc.request.moveToTrash({ path: entry.path });
|
||||
if (!res.ok) {
|
||||
this.toast.error(res.error);
|
||||
break;
|
||||
}
|
||||
moved++;
|
||||
}
|
||||
if (moved === 1) this.toast.info(this.i18n.t('toasts.movedToTrash', { name: targets[0].name }));
|
||||
else if (moved > 1) this.toast.info(this.i18n.t('toasts.movedManyToTrash', { count: moved }));
|
||||
await panel.refresh();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/** What Tab can land on. Tab order is DOM order: the app sets no positive tabindex. */
|
||||
const FOCUSABLE = 'button, input, select, textarea, [tabindex]';
|
||||
|
||||
/**
|
||||
* Tab / Shift+Tab through the focusable controls under `root`, in DOM order,
|
||||
* wrapping. Done by hand because WKWebView (macOS) skips buttons, checkboxes
|
||||
* and radios on Tab unless the user turned on System Settings → Keyboard →
|
||||
* Keyboard navigation, which left dialogs, Settings and the connection manager
|
||||
* mouse-only there. It is also the focus trap the modal dialogs lacked. A radio
|
||||
* group is one stop (its checked radio), as natively; arrows move inside it.
|
||||
* Returns true when it took the key.
|
||||
*/
|
||||
export function cycleFocus(root: Element, event: KeyboardEvent): boolean {
|
||||
if (event.key !== 'Tab' || event.metaKey || event.ctrlKey || event.altKey) return false;
|
||||
const stops = [...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(isTabStop);
|
||||
if (!stops.length) return false;
|
||||
event.preventDefault();
|
||||
const at = stops.indexOf(document.activeElement as HTMLElement);
|
||||
const step = event.shiftKey ? -1 : 1;
|
||||
const next = at < 0 ? (step > 0 ? 0 : stops.length - 1) : (at + step + stops.length) % stops.length;
|
||||
stops[next].focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function isTabStop(el: HTMLElement): boolean {
|
||||
if (el.tabIndex < 0 || el.matches(':disabled')) return false;
|
||||
if (!el.getClientRects().length) return false; // display:none, or inside a hidden parent
|
||||
if (el instanceof HTMLInputElement && el.type === 'radio' && el.name) {
|
||||
const group = [
|
||||
...document.querySelectorAll<HTMLInputElement>(`input[type=radio][name="${CSS.escape(el.name)}"]`),
|
||||
];
|
||||
const checked = group.find((radio) => radio.checked);
|
||||
return el === (checked ?? group[0]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -352,6 +352,19 @@ export class PanelsService {
|
||||
this.focus.bump();
|
||||
}
|
||||
|
||||
/** Each file list's key handler. A key that lands on <body> — a mouse click
|
||||
* dropped focus (WebKit doesn't focus clicked buttons) — still reaches the
|
||||
* active list through forwardListKey instead of dying until the next click. */
|
||||
private readonly listKeys = new Map<Side, (event: KeyboardEvent) => void>();
|
||||
|
||||
registerListKeys(side: Side, handler: (event: KeyboardEvent) => void): void {
|
||||
this.listKeys.set(side, handler);
|
||||
}
|
||||
|
||||
forwardListKey(event: KeyboardEvent): void {
|
||||
this.listKeys.get(this.activeSide())?.(event);
|
||||
}
|
||||
|
||||
// ---- workspace (tab) management ----
|
||||
|
||||
newWorkspace(): void {
|
||||
|
||||
@@ -21,10 +21,6 @@ export const IS_WINDOWS = PLATFORM === 'win32';
|
||||
|
||||
export const IS_LINUX = PLATFORM === 'linux';
|
||||
|
||||
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
|
||||
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
|
||||
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
|
||||
|
||||
export const APP_MODE: 'main' | 'editor' | 'manager' | 'settings' =
|
||||
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
||||
|
||||
@@ -35,6 +31,18 @@ export const EDITOR_CONTEXT =
|
||||
* hiddenInset layout, see chromeOptions), which reserves `pl-24` for them. */
|
||||
export const IS_MAC_CHROME = IS_MAC;
|
||||
|
||||
/**
|
||||
* WebView2 (Windows) reloads the whole page on F5 / Ctrl+R / Ctrl+F5 /
|
||||
* Ctrl+Shift+R unless the page claims the key — dropping every session and any
|
||||
* unsaved editor text. Every window root calls this for keys none of its
|
||||
* bindings took. Known ceiling: unverified whether Electrobun already turns
|
||||
* WebView2's browser keys off; harmless if it does.
|
||||
*/
|
||||
export function blockReloadKeys(event: KeyboardEvent): void {
|
||||
if (!IS_WINDOWS || event.defaultPrevented) return;
|
||||
if (event.key === 'F5' || (event.ctrlKey && event.code === 'KeyR')) event.preventDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the webview's default right-click menu (Reload, Inspect Element…)
|
||||
* everywhere the app doesn't show its own native menu. Text fields keep the
|
||||
|
||||
@@ -17,6 +17,9 @@ export class SettingsService {
|
||||
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
|
||||
readonly editor = signal<AppSettings['editor']>(DEFAULT_SETTINGS.editor);
|
||||
readonly editorApp = signal(DEFAULT_SETTINGS.editorApp);
|
||||
/** Settings → Keyboard overrides; ShortcutsService applies them to the keymap. */
|
||||
readonly keybindings = signal<AppSettings['keybindings']>(DEFAULT_SETTINGS.keybindings);
|
||||
readonly openFilesOnEnter = signal(DEFAULT_SETTINGS.openFilesOnEnter);
|
||||
// Auto-refresh is always on for now (see panels.service). To make it
|
||||
// user-controllable: uncomment the field in types.ts, then here add
|
||||
// readonly autoRefresh = signal(DEFAULT_SETTINGS.autoRefresh);
|
||||
@@ -25,6 +28,10 @@ export class SettingsService {
|
||||
|
||||
private readonly rpc = inject(RpcService);
|
||||
private readonly i18n = inject(I18nService);
|
||||
/** Saves are whole-object: one sent before the first getSettings lands would
|
||||
* write these defaults over the user's settings (a ⌘+ pressed as a window
|
||||
* opens would wipe every keyboard remap), so nothing persists until then. */
|
||||
private loaded = false;
|
||||
|
||||
/** Whether the app is actually rendering dark right now (drives the Tone row). */
|
||||
readonly effectiveDark = computed(
|
||||
@@ -45,6 +52,7 @@ export class SettingsService {
|
||||
}
|
||||
|
||||
private applyAll(settings: AppSettings): void {
|
||||
this.loaded = true;
|
||||
this.locale.set(settings.locale);
|
||||
this.theme.set(settings.theme);
|
||||
this.darkTone.set(settings.darkTone);
|
||||
@@ -56,6 +64,8 @@ export class SettingsService {
|
||||
this.autoClearCompleted.set(settings.autoClearCompleted);
|
||||
this.editor.set(settings.editor);
|
||||
this.editorApp.set(settings.editorApp);
|
||||
this.keybindings.set(settings.keybindings);
|
||||
this.openFilesOnEnter.set(settings.openFilesOnEnter);
|
||||
this.applyTheme();
|
||||
this.applyDarkTone();
|
||||
this.applyUiScale();
|
||||
@@ -123,6 +133,16 @@ export class SettingsService {
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
setKeybindings(keybindings: AppSettings['keybindings']): void {
|
||||
this.keybindings.set(keybindings);
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
setOpenFilesOnEnter(open: boolean): void {
|
||||
this.openFilesOnEnter.set(open);
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
private applyTheme(): void {
|
||||
const theme = this.theme();
|
||||
if (theme === 'system') {
|
||||
@@ -172,10 +192,13 @@ export class SettingsService {
|
||||
autoClearCompleted: this.autoClearCompleted(),
|
||||
editor: this.editor(),
|
||||
editorApp: this.editorApp(),
|
||||
keybindings: this.keybindings(),
|
||||
openFilesOnEnter: this.openFilesOnEnter(),
|
||||
};
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
if (!this.loaded) return;
|
||||
await this.rpc.request.saveSettings({ settings: this.settings() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +1,161 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, computed, inject } from '@angular/core';
|
||||
import {
|
||||
type Combo,
|
||||
EN_KEY_NAMES,
|
||||
effectiveKeymap,
|
||||
firesInTextField,
|
||||
formatCombo,
|
||||
isRepeatable,
|
||||
type KeyEventLike,
|
||||
type KeyNames,
|
||||
matchesEvent,
|
||||
parseCombo,
|
||||
type ResolvedBinding,
|
||||
type Scope,
|
||||
sameCombo,
|
||||
serializeCombo,
|
||||
type ShortcutId,
|
||||
} from '@shared/keymap';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { IS_MAC } from './platform';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
export type { ShortcutId } from '@shared/keymap';
|
||||
|
||||
/** A keydown as resolve() reads it: the matching fields plus where it came from. */
|
||||
type KeyInput = KeyEventLike & { target?: EventTarget | null; isComposing?: boolean; keyCode?: number };
|
||||
|
||||
/**
|
||||
* Every window-level shortcut the app answers to, by name. Components switch
|
||||
* on these ids instead of key-matching inline, so the whole keymap is ONE
|
||||
* table — the precondition for the vim-style bindings planned next, which
|
||||
* will need a mode/leader state machine wrapped around exactly this point.
|
||||
*/
|
||||
export type ShortcutId =
|
||||
// main window (workspace/tab management)
|
||||
| 'new-tab'
|
||||
| 'close-tab'
|
||||
| 'cycle-tab'
|
||||
| 'cycle-tab-back'
|
||||
| 'go-to-tab' // 1-9; the digit rides in ShortcutMatch.digit
|
||||
| 'open-settings'
|
||||
// editor window
|
||||
| 'save'
|
||||
| 'find'
|
||||
| 'close-window'
|
||||
| 'toggle-wrap'
|
||||
// any window
|
||||
| 'scale-up'
|
||||
| 'scale-down'
|
||||
| 'scale-reset';
|
||||
|
||||
export interface ShortcutMatch {
|
||||
id: ShortcutId;
|
||||
/** Only for go-to-tab: the digit pressed, 1–9. */
|
||||
digit?: number;
|
||||
}
|
||||
|
||||
/** Which keymap a window consults. 'manager' and 'settings' answer only to the
|
||||
* any-window shortcuts (scale). Escape/Tab stay component-local: their meaning
|
||||
* is contextual state (find bar open? close prompt up? which pane?), not a
|
||||
* global binding. */
|
||||
export type ShortcutScope = 'main' | 'editor' | 'manager' | 'settings';
|
||||
|
||||
/**
|
||||
* The keyboard layer: modifier helpers + one declarative keymap.
|
||||
* The keyboard layer over the shared keymap (src/shared/keymap.ts): resolves
|
||||
* keydowns against the EFFECTIVE keymap — defaults plus the Settings → Keyboard
|
||||
* remaps, live in every window — and formats the hints menus and tooltips show.
|
||||
*
|
||||
* House rules encoded here rather than re-derived per component (they were
|
||||
* previously copy-pasted inline in six places, drifting apart):
|
||||
* - `mod` is metaKey OR ctrlKey — ⌘ on macOS, Ctrl elsewhere. The keyboard
|
||||
* does not change with the window chrome (platform.ts says the same about
|
||||
* MOD_LABEL).
|
||||
* - Layout-dependent keys match on `event.code`, not `event.key`: digits
|
||||
* (AZERTY's digit row shifts), +/- (key varies wildly across layouts), and
|
||||
* letter chords with Alt (macOS Option produces Ω for ⌥Z). Plain letter
|
||||
* chords match on `key` so they follow the user's layout.
|
||||
* - isTypingTarget guards shortcuts that would be hostile mid-typing. It is
|
||||
* deliberately NOT applied inside resolve(): the existing bindings rely on
|
||||
* the path bar's stopPropagation and the editor WANTS Mod+S while typing.
|
||||
* Callers opt in per shortcut (the scale keys do).
|
||||
* - Matching is exact: ⌃F is not ⌘F, Ctrl+Tab is not Tab. `Mod` is ⌘ on
|
||||
* macOS and Ctrl elsewhere.
|
||||
* - On macOS the PAGE sees ⌘-keys first: WKWebView hands key equivalents to the
|
||||
* page before the app menu (WebKit's WebViewImpl::performKeyEquivalent), so a
|
||||
* key a window claims here (claim() → preventDefault) never reaches the menu
|
||||
* bar, whose accelerators bun builds from the same table. Keys nobody claims
|
||||
* fall through to it.
|
||||
* - From a text field only firesInTextField() combos count (⌘S yes, Delete no).
|
||||
* - Escape, the arrows inside menus and dialog Enter stay component-local:
|
||||
* they are fixed, so nobody can remap themselves out of a dialog.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ShortcutsService {
|
||||
/** The platform command modifier: ⌘ on macOS, Ctrl elsewhere. Mouse events
|
||||
* qualify too — Mod+click toggle-select in the panels uses the same rule. */
|
||||
isMod(event: KeyboardEvent | MouseEvent): boolean {
|
||||
return event.metaKey || event.ctrlKey;
|
||||
}
|
||||
private readonly settings = inject(SettingsService);
|
||||
private readonly i18n = inject(I18nService);
|
||||
|
||||
/** True when the event originates from something the user types into. */
|
||||
isTypingTarget(event: KeyboardEvent): boolean {
|
||||
const target = event.target as HTMLElement | null;
|
||||
return !!target?.closest('input, textarea, select, [contenteditable="true"]');
|
||||
readonly keymap = computed(() => effectiveKeymap(this.settings.keybindings(), IS_MAC));
|
||||
private readonly byId = computed(() => new Map(this.keymap().map((b) => [b.id, b])));
|
||||
/** Localized key names for the non-mac spelling ("Strg+Entf"); reading t()
|
||||
* makes every hint follow a language switch. */
|
||||
private readonly names = computed<KeyNames>(() => {
|
||||
const names = { ...EN_KEY_NAMES };
|
||||
for (const key of Object.keys(names) as (keyof KeyNames)[]) {
|
||||
names[key] = this.i18n.t(`keys.names.${key}`);
|
||||
}
|
||||
return names;
|
||||
});
|
||||
|
||||
/** The command a keydown triggers in `scope`, or null. Window scopes include
|
||||
* `any`; `panel` doesn't (the window root runs those). */
|
||||
resolve(event: KeyInput, scope: Scope): ShortcutId | null {
|
||||
// an IME's Enter commits a candidate, it is never a shortcut
|
||||
if (event.isComposing || event.keyCode === 229) return null;
|
||||
const typing = this.isTypingTarget(event);
|
||||
for (const binding of this.keymap()) {
|
||||
if (!binding.scopes.includes(scope) && (scope === 'panel' || !binding.scopes.includes('any'))) {
|
||||
continue;
|
||||
}
|
||||
for (const combo of binding.combos) {
|
||||
if (typing && !firesInTextField(combo, IS_MAC)) continue;
|
||||
if (matchesEvent(combo, event, IS_MAC)) return binding.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an event against the scope's keymap. Returns null for anything the
|
||||
* table does not claim — callers must then leave the event alone.
|
||||
* resolve() + take the key: a match is preventDefault-ed, so the macOS menu
|
||||
* never runs it a second time. A held key repeats only movement-like
|
||||
* commands; any other repeat is swallowed (holding ⌘W closes one tab).
|
||||
*/
|
||||
resolve(event: KeyboardEvent, scope: ShortcutScope): ShortcutMatch | null {
|
||||
const mod = this.isMod(event);
|
||||
const key = event.key.toLowerCase();
|
||||
claim(event: KeyboardEvent, scope: Scope): ShortcutId | null {
|
||||
const id = this.resolve(event, scope);
|
||||
if (!id) return null;
|
||||
event.preventDefault();
|
||||
return event.repeat && !isRepeatable(id) ? null : id;
|
||||
}
|
||||
|
||||
// ---- any window: interface scale ----
|
||||
// event.code, not key: '+' needs Shift on many layouts and '-' moves
|
||||
// around; the physical Equal/Minus keys (and the numpad pair) are stable.
|
||||
if (mod && !event.altKey) {
|
||||
if (event.code === 'Equal' || event.code === 'NumpadAdd') return { id: 'scale-up' };
|
||||
if (event.code === 'Minus' || event.code === 'NumpadSubtract') return { id: 'scale-down' };
|
||||
if (event.code === 'Digit0' || event.code === 'Numpad0') return { id: 'scale-reset' };
|
||||
/** Whether a keydown is one of `id`'s combos (an open menu matching its items). */
|
||||
matches(id: ShortcutId, event: KeyEventLike): boolean {
|
||||
return !!this.byId().get(id)?.combos.some((c) => matchesEvent(c, event, IS_MAC));
|
||||
}
|
||||
|
||||
binding(id: ShortcutId): ResolvedBinding | undefined {
|
||||
return this.byId().get(id);
|
||||
}
|
||||
|
||||
format(combo: Combo): string {
|
||||
return formatCombo(combo, IS_MAC, this.names());
|
||||
}
|
||||
|
||||
/** The hint menus show: the command's first key, or '' when unbound. */
|
||||
label(id: ShortcutId): string {
|
||||
const first = this.byId().get(id)?.combos[0];
|
||||
return first ? this.format(first) : '';
|
||||
}
|
||||
|
||||
labels(id: ShortcutId): string[] {
|
||||
return (this.byId().get(id)?.combos ?? []).map((c) => this.format(c));
|
||||
}
|
||||
|
||||
/** A tooltip with the command's keys: "Delete (F8 / Del)"; bare when unbound. */
|
||||
tip(text: string, id: ShortcutId): string {
|
||||
const keys = this.labels(id);
|
||||
return keys.length ? `${text} (${keys.join(' / ')})` : text;
|
||||
}
|
||||
|
||||
/** The same for a FIXED key — one no remap moves, such as Enter in the find
|
||||
* box — so it reads in the platform's spelling too (⇧↩ / Shift+Enter). */
|
||||
tipFixed(text: string, combo: string): string {
|
||||
const parsed = parseCombo(combo, IS_MAC);
|
||||
return parsed ? `${text} (${this.format(parsed)})` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store new combos for commands (Settings → Keyboard). Only rows that differ
|
||||
* from their defaults are kept, so a reset — or a remap back to the
|
||||
* default — leaves nothing behind.
|
||||
*/
|
||||
rebind(changes: Partial<Record<ShortcutId, Combo[]>>): void {
|
||||
const next = { ...this.settings.keybindings() };
|
||||
for (const [id, combos] of Object.entries(changes) as [ShortcutId, Combo[]][]) {
|
||||
const defaults = this.byId().get(id)?.defaults ?? [];
|
||||
const isDefault =
|
||||
combos.length === defaults.length && combos.every((c, i) => sameCombo(c, defaults[i]));
|
||||
if (isDefault) delete next[id];
|
||||
else next[id] = combos.map(serializeCombo);
|
||||
}
|
||||
this.settings.setKeybindings(next);
|
||||
}
|
||||
|
||||
if (scope === 'manager' || scope === 'settings') return null; // any-window shortcuts only
|
||||
resetAll(): void {
|
||||
this.settings.setKeybindings({});
|
||||
}
|
||||
|
||||
if (scope === 'main') {
|
||||
// on macOS the app menu's ⌘, intercepts first; this is the Windows/Linux path
|
||||
if (mod && key === ',') return { id: 'open-settings' };
|
||||
if (mod && key === 't') return { id: 'new-tab' };
|
||||
if (mod && key === 'w') return { id: 'close-tab' };
|
||||
// ctrlKey only, never meta: ⌘Tab is the macOS app switcher
|
||||
if (event.ctrlKey && event.key === 'Tab') {
|
||||
return { id: event.shiftKey ? 'cycle-tab-back' : 'cycle-tab' };
|
||||
}
|
||||
if (mod && !event.shiftKey && !event.altKey) {
|
||||
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1];
|
||||
if (digit) return { id: 'go-to-tab', digit: Number(digit) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/** The platform command modifier: ⌘ on macOS, Ctrl elsewhere — exactly one.
|
||||
* Mouse events too: Mod+click toggle-selects in the panels. */
|
||||
isMod(event: KeyboardEvent | MouseEvent): boolean {
|
||||
return IS_MAC ? event.metaKey : event.ctrlKey;
|
||||
}
|
||||
|
||||
// scope === 'editor'
|
||||
if (mod && key === 's') return { id: 'save' };
|
||||
if (mod && key === 'f') return { id: 'find' };
|
||||
// on macOS the app menu intercepts ⌘W before this ever fires; this arm is
|
||||
// the Windows/Linux path
|
||||
if (mod && key === 'w') return { id: 'close-window' };
|
||||
if (event.altKey && event.code === 'KeyZ') return { id: 'toggle-wrap' };
|
||||
return null;
|
||||
/** True when the event comes from something the user types into. */
|
||||
isTypingTarget(event: { target?: EventTarget | null }): boolean {
|
||||
const target = event.target as HTMLElement | null;
|
||||
return !!target?.closest?.(
|
||||
'textarea, select, [contenteditable="true"], input:not([type=checkbox]):not([type=radio]):not([type=button])',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,14 @@ export class UpdaterService {
|
||||
void this.rpc.request.applyUpdate({}); // quits + relaunches
|
||||
}
|
||||
|
||||
/** The pill's one action, also the "Install update" key: download when
|
||||
* available, apply (relaunch) once ready; nothing otherwise. */
|
||||
advance(): void {
|
||||
const state = this.state();
|
||||
if (state === 'available') void this.download();
|
||||
else if (state === 'ready') this.apply();
|
||||
}
|
||||
|
||||
/** Manual re-check (menu item). */
|
||||
async check(): Promise<void> {
|
||||
if (!this.rpc.available) return;
|
||||
|
||||
Reference in New Issue
Block a user