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;
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<ng-icon name="tdesignServer" size="1.25rem" class="text-muted-foreground" />
|
||||
<p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p>
|
||||
<button
|
||||
(click)="focusManager()"
|
||||
class="mt-3 bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{{ i18n.t('app.showConnections') }}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
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 } from '@core/file-actions.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 } from '@core/platform';
|
||||
import { blockNativeContextMenu, blockReloadKeys } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { ShortcutsService } from '@core/shortcuts.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';
|
||||
@@ -44,6 +46,7 @@ import { UpdaterService } from '@core/updater.service';
|
||||
host: {
|
||||
'(window:keydown)': 'onWindowKeydown($event)',
|
||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||
'(mousedown)': 'keepListFocus($event)',
|
||||
},
|
||||
})
|
||||
export class App {
|
||||
@@ -58,6 +61,8 @@ export class App {
|
||||
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);
|
||||
@@ -105,10 +110,6 @@ export class App {
|
||||
|
||||
/** Native application-menu / manager-window actions forwarded from bun. */
|
||||
private async handleMenuAction(action: string): Promise<void> {
|
||||
if (action.startsWith('ctx-')) {
|
||||
this.handleContextAction(action);
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('connect:')) {
|
||||
const connection = await this.findConnection(action.slice('connect:'.length));
|
||||
if (connection) {
|
||||
@@ -137,22 +138,19 @@ export class App {
|
||||
}
|
||||
switch (action) {
|
||||
case 'new-tab':
|
||||
this.panels.newWorkspace();
|
||||
break;
|
||||
case 'close-tab':
|
||||
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
|
||||
this.runWindowCommand(action);
|
||||
break;
|
||||
// the macOS File / View items act on the active panel, exactly like their keys
|
||||
case 'new-folder':
|
||||
this.actions.mkdir(this.panels.active());
|
||||
break;
|
||||
case 'new-file':
|
||||
this.actions.createFile(this.panels.active());
|
||||
this.runFileCommand(action);
|
||||
break;
|
||||
case 'edit-file':
|
||||
void this.actions.edit(this.panels.active());
|
||||
this.runFileCommand('edit');
|
||||
break;
|
||||
case 'refresh-active':
|
||||
void this.panels.active().refresh();
|
||||
this.runFileCommand('refresh');
|
||||
break;
|
||||
case 'check-for-updates':
|
||||
void this.updater.check();
|
||||
@@ -160,37 +158,102 @@ export class App {
|
||||
}
|
||||
}
|
||||
|
||||
/** Window shortcuts — matching lives in ShortcutsService (one keymap for the
|
||||
* app); this switch only maps ids onto actions. Panel-level keys stay on
|
||||
* the panels themselves. */
|
||||
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 {
|
||||
if (this.managerOpen()) return; // modal-blocked behind the manager
|
||||
const match = this.shortcuts.resolve(event, 'main');
|
||||
if (!match) return;
|
||||
// scale keys are hostile mid-typing ('-' in a rename prompt must type '-');
|
||||
// the tab shortcuts keep firing there, as they always did
|
||||
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
|
||||
event.preventDefault();
|
||||
switch (match.id) {
|
||||
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(this.panels.activeWorkspaceId());
|
||||
this.panels.closeWorkspace(active);
|
||||
break;
|
||||
case 'cycle-tab':
|
||||
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 'cycle-tab-back':
|
||||
case 'prev-tab':
|
||||
this.panels.cycleWorkspace(-1);
|
||||
break;
|
||||
case 'go-to-tab':
|
||||
// ⌘1–⌘8 jump to that tab, ⌘9 to the last, like Firefox
|
||||
this.panels.goToWorkspace(match.digit === 9 ? -1 : match.digit! - 1);
|
||||
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;
|
||||
@@ -200,10 +263,48 @@ export class App {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/** Context-menu actions apply to the row/panel remembered at right-click time. */
|
||||
/**
|
||||
* Workspace-tab menu actions. The target id travels INSIDE the action string
|
||||
* because the right-clicked tab is not necessarily the active one — nothing
|
||||
@@ -232,55 +333,4 @@ export class App {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleContextAction(action: string): void {
|
||||
const target = this.actions.menuTarget;
|
||||
if (!target) return;
|
||||
const { panel, side, entry } = target;
|
||||
// the empty-space menu acts on the directory itself
|
||||
const path = entry?.path ?? panel.cwd();
|
||||
switch (action) {
|
||||
case 'ctx-open':
|
||||
if (entry) panel.open(entry);
|
||||
break;
|
||||
// local shell integration — only bun can reach the OS shell/clipboard
|
||||
case 'ctx-reveal':
|
||||
void this.rpc.request.shellItemAction({ action: 'reveal', path });
|
||||
break;
|
||||
case 'ctx-open-default':
|
||||
void this.rpc.request.shellItemAction({ action: 'open', path });
|
||||
break;
|
||||
case 'ctx-copy-path':
|
||||
void this.rpc.request.shellItemAction({ action: 'copyPath', path });
|
||||
break;
|
||||
case 'ctx-edit':
|
||||
void this.actions.edit(panel);
|
||||
break;
|
||||
case 'ctx-copy':
|
||||
void this.transfers.copyToOtherPanel(side);
|
||||
break;
|
||||
case 'ctx-rename':
|
||||
this.actions.rename(panel);
|
||||
break;
|
||||
case 'ctx-chmod':
|
||||
this.actions.chmod(panel);
|
||||
break;
|
||||
case 'ctx-delete':
|
||||
this.actions.delete(panel);
|
||||
break;
|
||||
case 'ctx-trash':
|
||||
if (entry) void this.actions.trash(panel, entry);
|
||||
break;
|
||||
case 'ctx-new-folder':
|
||||
this.actions.mkdir(panel);
|
||||
break;
|
||||
case 'ctx-new-file':
|
||||
this.actions.createFile(panel);
|
||||
break;
|
||||
case 'ctx-refresh':
|
||||
void panel.refresh();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ConnectionsService } from '@core/connections.service';
|
||||
import { ConfirmDialog } from '../dialogs/confirm-dialog';
|
||||
import { PromptDialog } from '../dialogs/prompt-dialog';
|
||||
import { DialogsService } from '@core/dialogs.service';
|
||||
import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform';
|
||||
import { blockNativeContextMenu, blockReloadKeys, IS_MAC_CHROME } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { ShortcutsService } from '@core/shortcuts.service';
|
||||
@@ -148,12 +148,12 @@ export class ConnectionManager {
|
||||
|
||||
/** Any-window shortcuts (interface scale). Escape has its own binding above. */
|
||||
onWindowKeydown(event: KeyboardEvent): void {
|
||||
const match = this.shortcuts.resolve(event, 'manager');
|
||||
if (!match || this.shortcuts.isTypingTarget(event)) return;
|
||||
event.preventDefault();
|
||||
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
||||
else if (match.id === 'scale-reset') this.settings.setUiScale(1);
|
||||
const id = this.shortcuts.claim(event, 'manager');
|
||||
if (id === 'close-window') this.close();
|
||||
else if (id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||
else if (id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
||||
else if (id === 'scale-reset') this.settings.setUiScale(1);
|
||||
blockReloadKeys(event);
|
||||
}
|
||||
|
||||
blockContextMenu(event: Event): void {
|
||||
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
|
||||
import { ContextMenuService, isSelectable, type MenuItem } from '@core/context-menu.service';
|
||||
import { FocusService } from '@core/focus.service';
|
||||
import { stepIndex } from '@core/menu-nav';
|
||||
import { remPx } from '@core/metrics';
|
||||
import { ShortcutsService } from '@core/shortcuts.service';
|
||||
|
||||
/** Rough per-item metrics in REM, used only to decide whether the menu must
|
||||
* flip. Converted through remPx() at open time so the math tracks the same
|
||||
@@ -20,13 +21,19 @@ import { remPx } from '@core/metrics';
|
||||
* scale while the flip math still believed 224 and fired at the wrong edge. */
|
||||
const ITEM_H_REM = 1.625; // px-3 py-1 row + --path-font line ≈ 26px @16
|
||||
const SEPARATOR_H_REM = 0.4375; // my-1 + 1px border ≈ 7px @16
|
||||
const MENU_W_REM = 14; // w-56
|
||||
const HEADER_H_REM = 1.25; // pt-1.5 pb-0.5 + the 0.625rem caps line
|
||||
const MENU_W_REM = 15; // w-60
|
||||
const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
|
||||
|
||||
/**
|
||||
* The panel right-click menu. Replaces electrobun's native ContextMenu, which
|
||||
* is a no-op on Linux — see ContextMenuService for the why. Emits the same
|
||||
* `ctx-*` / `col:*` action strings the native menu did.
|
||||
* The HTML context menu (rows, tabs, column headers, the panel source picker).
|
||||
* Replaces electrobun's native ContextMenu, which is a no-op on Linux — see
|
||||
* ContextMenuService for the why.
|
||||
*
|
||||
* Keyboard: ↑↓ Home End move one highlight shared with the mouse, Enter/Space
|
||||
* pick, Esc closes, and an item's own shortcut picks it while the menu is open
|
||||
* (F2 renames from an open row menu). Every key it handles stops here, so the
|
||||
* window's shortcut handler never runs it a second time.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-context-menu',
|
||||
@@ -43,15 +50,21 @@ const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
|
||||
(keydown)="onKeydown($event)"
|
||||
[style.left.px]="position().x"
|
||||
[style.top.px]="position().y"
|
||||
class="fixed z-50 w-56 border border-border/60 bg-chrome py-1 text-foreground shadow-xl outline-none"
|
||||
[style.max-height.px]="position().maxHeight"
|
||||
class="fixed z-50 w-60 overflow-y-auto border border-border/60 bg-chrome py-1 text-foreground shadow-xl outline-none"
|
||||
>
|
||||
@for (item of req.items; track $index) {
|
||||
@if (item.type === 'separator') {
|
||||
<div class="my-1 border-t border-border/60"></div>
|
||||
} @else if (item.type === 'header') {
|
||||
<div class="truncate px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
[attr.id]="'ctx-item-' + $index"
|
||||
[title]="item.tooltip ?? ''"
|
||||
[disabled]="!!item.disabled"
|
||||
@@ -61,14 +74,22 @@ const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
|
||||
(click)="pick(item)"
|
||||
class="flex w-full items-center gap-2 px-3 py-1 text-left text-(length:--path-font) disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<span class="w-3.5 shrink-0">
|
||||
<!-- the check column: a checkmark, else the item's icon -->
|
||||
<span class="grid w-4 shrink-0 place-items-center">
|
||||
@if (item.checked) {
|
||||
<ng-icon name="tdesignCheck" size="0.85rem" class="text-primary" />
|
||||
} @else if (item.icon) {
|
||||
<ng-icon
|
||||
[name]="item.icon"
|
||||
size="0.9rem"
|
||||
[class.text-muted-foreground]="$index !== activeIndex() || !!item.disabled"
|
||||
/>
|
||||
}
|
||||
</span>
|
||||
<span class="flex-1 truncate">{{ item.label }}</span>
|
||||
@if (item.accelerator) {
|
||||
<span class="shrink-0 text-muted-foreground">{{ item.accelerator }}</span>
|
||||
@if (item.shortcut && shortcuts.label(item.shortcut); as hint) {
|
||||
<!-- soft: the row's own colour at 60%, so it turns soft green with the highlight -->
|
||||
<span class="ml-4 shrink-0 opacity-60">{{ hint }}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@@ -86,30 +107,37 @@ const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
|
||||
})
|
||||
export class ContextMenu {
|
||||
readonly menu = inject(ContextMenuService);
|
||||
readonly shortcuts = inject(ShortcutsService);
|
||||
private readonly focus = inject(FocusService);
|
||||
private readonly menuEl = viewChild<ElementRef<HTMLDivElement>>('menuEl');
|
||||
|
||||
readonly activeIndex = signal(-1);
|
||||
|
||||
/** Click point, flipped/clamped so the menu never overflows the viewport.
|
||||
* All UI metrics convert through the live root font-size; window.inner* is
|
||||
* viewport CSS px, so both sides of every comparison share units. */
|
||||
* A menu taller than the viewport (a long source picker) scrolls inside a
|
||||
* clamped box. All UI metrics convert through the live root font-size;
|
||||
* window.inner* is viewport CSS px, so both sides of every comparison share
|
||||
* units. */
|
||||
readonly position = computed(() => {
|
||||
const req = this.menu.request();
|
||||
if (!req) return { x: 0, y: 0 };
|
||||
const itemH = remPx(ITEM_H_REM);
|
||||
const separatorH = remPx(SEPARATOR_H_REM);
|
||||
if (!req) return { x: 0, y: 0, maxHeight: 0 };
|
||||
const heights = {
|
||||
separator: remPx(SEPARATOR_H_REM),
|
||||
header: remPx(HEADER_H_REM),
|
||||
item: remPx(ITEM_H_REM),
|
||||
};
|
||||
const content =
|
||||
req.items.reduce((sum, i) => sum + heights[i.type ?? 'item'], 0) + remPx(0.5); // py-1 ×2
|
||||
const maxHeight = window.innerHeight - 2 * EDGE_GAP;
|
||||
const height = Math.min(content, maxHeight);
|
||||
const menuW = remPx(MENU_W_REM);
|
||||
const height =
|
||||
req.items.reduce((sum, i) => sum + (i.type === 'separator' ? separatorH : itemH), 0) +
|
||||
remPx(0.5); // the menu's py-1 (0.25rem x2)
|
||||
const x =
|
||||
req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : req.x;
|
||||
const y =
|
||||
req.y + height > window.innerHeight - EDGE_GAP
|
||||
? Math.max(EDGE_GAP, req.y - height)
|
||||
: req.y;
|
||||
return { x, y };
|
||||
return { x, y, maxHeight };
|
||||
});
|
||||
|
||||
constructor() {
|
||||
@@ -117,22 +145,25 @@ export class ContextMenu {
|
||||
// the viewChild signal, NOT on request(): under zoneless CD an effect can
|
||||
// run before the @if has rendered the element.
|
||||
effect(() => this.menuEl()?.nativeElement.focus());
|
||||
// a fresh menu starts with nothing highlighted — or, opened from the
|
||||
// keyboard, with its first item
|
||||
effect(() => this.activeIndex.set(this.menu.request()?.initialIndex ?? -1));
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (!this.menu.request()) return;
|
||||
this.menu.close();
|
||||
this.activeIndex.set(-1); // a fresh menu starts with nothing highlighted
|
||||
this.focus.bump();
|
||||
}
|
||||
|
||||
pick(item: MenuItem): void {
|
||||
// the keyboard path never consults the disabled attribute — guard here
|
||||
if (item.disabled) return;
|
||||
if (!item.action) return;
|
||||
if (item.disabled || !item.action) return;
|
||||
const action = item.action;
|
||||
const onPick = this.menu.request()?.onPick;
|
||||
this.close();
|
||||
this.menu.onPick(action);
|
||||
if (onPick) onPick(action);
|
||||
else this.menu.emit(action);
|
||||
}
|
||||
|
||||
/** Right-clicking the backdrop closes rather than stacking a second menu. */
|
||||
@@ -143,33 +174,48 @@ export class ContextMenu {
|
||||
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
const items = this.menu.request()?.items ?? [];
|
||||
const selectable = items.map(isSelectable);
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.close();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
this.step(items, 1);
|
||||
this.highlight(stepIndex(selectable, this.activeIndex(), 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
this.step(items, -1);
|
||||
this.highlight(stepIndex(selectable, this.activeIndex(), -1));
|
||||
break;
|
||||
case 'Home':
|
||||
this.highlight(stepIndex(selectable, -1, 1));
|
||||
break;
|
||||
case 'End':
|
||||
this.highlight(stepIndex(selectable, -1, -1));
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ': {
|
||||
event.preventDefault();
|
||||
const item = items[this.activeIndex()];
|
||||
if (item) this.pick(item);
|
||||
break;
|
||||
}
|
||||
case 'Tab':
|
||||
// focus stays on the menu: the items are not Tab stops
|
||||
break;
|
||||
default: {
|
||||
// an item's own shortcut picks it, as in a native menu
|
||||
const hit = items.find(
|
||||
(i) => isSelectable(i) && i.shortcut && this.shortcuts.matches(i.shortcut, event),
|
||||
);
|
||||
if (!hit) return; // not ours: let it bubble
|
||||
this.pick(hit);
|
||||
}
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
/** Move the highlight by one selectable item (see stepIndex). */
|
||||
private step(items: MenuItem[], delta: 1 | -1): void {
|
||||
const selectable = items.map((i) => i.type !== 'separator' && !i.disabled);
|
||||
this.activeIndex.set(stepIndex(selectable, this.activeIndex(), delta));
|
||||
/** Move the highlight and keep it in view (the menu may scroll). */
|
||||
private highlight(index: number): void {
|
||||
this.activeIndex.set(index);
|
||||
document.getElementById(`ctx-item-${index}`)?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Component, computed, inject, input, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
afterNextRender,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { DialogsService, type ChmodRequest } from '@core/dialogs.service';
|
||||
import { cycleFocus } from '@core/focus-cycle';
|
||||
import { FocusService } from '@core/focus.service';
|
||||
|
||||
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
|
||||
@@ -11,9 +21,12 @@ const PERMS = ['r', 'w', 'x'] as const;
|
||||
template: `
|
||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[attr.aria-label]="i18n.t('dialogs.chmodTitle')"
|
||||
class="w-80 border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
(keydown.escape)="cancel()"
|
||||
(keydown)="onKeydown($event)"
|
||||
>
|
||||
<h2 class="text-sm font-semibold">{{ i18n.t('dialogs.chmodTitle') }}</h2>
|
||||
<p class="mt-1 truncate text-xs text-muted-foreground">{{ targetLabel() }}</p>
|
||||
@@ -49,6 +62,7 @@ const PERMS = ['r', 'w', 'x'] as const;
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<label class="text-xs text-muted-foreground">{{ i18n.t('dialogs.octal') }}</label>
|
||||
<input
|
||||
#octalBox
|
||||
[value]="octal()"
|
||||
(input)="setOctal($any($event.target).value)"
|
||||
maxlength="4"
|
||||
@@ -84,6 +98,32 @@ export class ChmodDialog {
|
||||
|
||||
private readonly dialogs = inject(DialogsService);
|
||||
private readonly focus = inject(FocusService);
|
||||
private readonly octalBox = viewChild.required<ElementRef<HTMLInputElement>>('octalBox');
|
||||
|
||||
constructor() {
|
||||
// typing the octal is the fast path; the checkboxes are a Tab away
|
||||
afterNextRender(() => {
|
||||
const el = this.octalBox().nativeElement;
|
||||
el.focus();
|
||||
el.select();
|
||||
});
|
||||
}
|
||||
|
||||
/** Esc cancels (and stops here); Tab stays inside; Enter applies — except on
|
||||
* a button, which Enter clicks natively, and mid IME composition. */
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.cancel();
|
||||
} else if (event.key === 'Enter') {
|
||||
if (event.isComposing || (event.target as HTMLElement).closest('button')) return;
|
||||
event.preventDefault();
|
||||
this.submit();
|
||||
} else {
|
||||
cycleFocus(event.currentTarget as Element, event);
|
||||
}
|
||||
}
|
||||
|
||||
// mode is initialized lazily from the request on first read
|
||||
private readonly modeOverride = signal<number | null>(null);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { DialogsService, type ConfirmRequest } from '@core/dialogs.service';
|
||||
import { cycleFocus } from '@core/focus-cycle';
|
||||
import { FocusService } from '@core/focus.service';
|
||||
|
||||
@Component({
|
||||
@@ -8,9 +9,12 @@ import { FocusService } from '@core/focus.service';
|
||||
template: `
|
||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[attr.aria-label]="request().title"
|
||||
class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
(keydown.escape)="cancel()"
|
||||
(keydown)="onKeydown($event)"
|
||||
>
|
||||
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
|
||||
<p class="mt-2 text-xs text-muted-foreground">{{ request().body }}</p>
|
||||
@@ -49,6 +53,17 @@ export class ConfirmDialog {
|
||||
afterNextRender(() => this.confirmBtn().nativeElement.focus());
|
||||
}
|
||||
|
||||
/** Esc cancels (and stops here — see PromptDialog); Tab stays inside. */
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.cancel();
|
||||
} else {
|
||||
cycleFocus(event.currentTarget as Element, event);
|
||||
}
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
const req = this.request();
|
||||
this.close();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
afterNextRender,
|
||||
afterRenderEffect,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service';
|
||||
import { cycleFocus } from '@core/focus-cycle';
|
||||
import { FocusService } from '@core/focus.service';
|
||||
|
||||
@Component({
|
||||
@@ -18,9 +19,11 @@ import { FocusService } from '@core/focus.service';
|
||||
template: `
|
||||
<div
|
||||
class="fixed inset-0 z-40 grid place-items-center bg-black/40"
|
||||
(keydown.escape)="pick('cancel')"
|
||||
(keydown)="onKeydown($event)"
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
class="w-[26rem] border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
@@ -127,7 +130,23 @@ export class OverwriteDialog {
|
||||
private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn');
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => this.primaryBtn().nativeElement.focus());
|
||||
// per QUESTION, not per component: a run of collisions reuses this one
|
||||
// instance (the signal goes null → next inside one change detection)
|
||||
afterRenderEffect(() => {
|
||||
this.request();
|
||||
this.primaryBtn().nativeElement.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/** Esc cancels the whole run (and stops here); Tab stays inside. */
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.pick('cancel');
|
||||
} else {
|
||||
cycleFocus(event.currentTarget as Element, event);
|
||||
}
|
||||
}
|
||||
|
||||
readonly isDir = computed(() => this.request().srcEntry.type === 'dir');
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { DialogsService, type PromptRequest } from '@core/dialogs.service';
|
||||
import { cycleFocus } from '@core/focus-cycle';
|
||||
import { FocusService } from '@core/focus.service';
|
||||
|
||||
@Component({
|
||||
@@ -16,16 +17,19 @@ import { FocusService } from '@core/focus.service';
|
||||
template: `
|
||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[attr.aria-label]="request().title"
|
||||
class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
(keydown)="onKeydown($event)"
|
||||
>
|
||||
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
|
||||
<input
|
||||
#box
|
||||
[value]="value()"
|
||||
(input)="value.set($any($event.target).value)"
|
||||
(keydown.enter)="submit()"
|
||||
(keydown.escape)="cancel()"
|
||||
(keydown.enter)="onEnter($event)"
|
||||
[placeholder]="request().placeholder ?? ''"
|
||||
spellcheck="false"
|
||||
class="mt-3 h-8 w-full border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||
@@ -67,6 +71,25 @@ export class PromptDialog {
|
||||
});
|
||||
}
|
||||
|
||||
/** Esc anywhere in the dialog cancels; Tab stays inside it. Esc stops here:
|
||||
* a window-level Escape (the connection manager closes on it) must not see
|
||||
* the same key. */
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.cancel();
|
||||
} else {
|
||||
cycleFocus(event.currentTarget as Element, event);
|
||||
}
|
||||
}
|
||||
|
||||
/** Enter submits — but not the Enter that commits an IME candidate. */
|
||||
onEnter(event: Event): void {
|
||||
if ((event as KeyboardEvent).isComposing) return;
|
||||
this.submit();
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
const value = this.value().trim();
|
||||
if (!value) return;
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="toggleWrap()"
|
||||
[title]="i18n.t('editor.wrapHint', { alt: altLabel })"
|
||||
[title]="shortcuts.tip(i18n.t('editor.wrapHint'), 'toggle-wrap')"
|
||||
class="px-2 py-1 text-[0.6875rem] electrobun-webkit-app-region-no-drag hover:bg-accent"
|
||||
[class.bg-accent]="wrap()"
|
||||
[class.text-foreground]="wrap()"
|
||||
@@ -38,7 +38,7 @@
|
||||
</button>
|
||||
<button
|
||||
(click)="openFind()"
|
||||
[title]="i18n.t('editor.findHint', { mod: modLabel })"
|
||||
[title]="shortcuts.tip(i18n.t('editor.findHint'), 'find')"
|
||||
class="grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="tdesignSearch" size="0.85rem" />
|
||||
@@ -66,14 +66,14 @@
|
||||
</span>
|
||||
<button
|
||||
(click)="findStep(-1)"
|
||||
[title]="i18n.t('editor.prevMatch')"
|
||||
[title]="shortcuts.tipFixed(i18n.t('editor.prevMatch'), 'Shift+Enter')"
|
||||
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="tdesignChevronUp" size="0.8rem" />
|
||||
</button>
|
||||
<button
|
||||
(click)="findStep(1)"
|
||||
[title]="i18n.t('editor.nextMatch')"
|
||||
[title]="shortcuts.tipFixed(i18n.t('editor.nextMatch'), 'Enter')"
|
||||
class="grid size-5 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="tdesignChevronDown" size="0.8rem" />
|
||||
@@ -141,7 +141,7 @@
|
||||
</div>
|
||||
|
||||
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2">
|
||||
<span class="text-[0.6875rem] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
|
||||
<span class="text-[0.6875rem] text-muted-foreground">{{ footerHint() }}</span>
|
||||
<span class="text-[0.6875rem] text-muted-foreground">·</span>
|
||||
<span class="text-[0.6875rem] tabular-nums text-muted-foreground">
|
||||
{{ i18n.t('editor.lines', { count: lineCount() }) }}
|
||||
@@ -171,6 +171,10 @@
|
||||
@if (closePrompt()) {
|
||||
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()">
|
||||
<div
|
||||
#prompt
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[attr.aria-label]="i18n.t('editor.saveChangesTitle', { name: ctx.name })"
|
||||
class="w-96 border border-border bg-card p-4 text-card-foreground shadow-xl"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
>
|
||||
@@ -193,6 +197,7 @@
|
||||
{{ i18n.t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
#promptSave
|
||||
(click)="saveAndClose()"
|
||||
[disabled]="saving()"
|
||||
class="bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
|
||||
@@ -2,7 +2,9 @@ import {
|
||||
Component,
|
||||
DOCUMENT,
|
||||
ElementRef,
|
||||
Injector,
|
||||
afterNextRender,
|
||||
afterRenderEffect,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
@@ -11,7 +13,8 @@ import {
|
||||
} from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
|
||||
import { cycleFocus } from '@core/focus-cycle';
|
||||
import { blockNativeContextMenu, blockReloadKeys, EDITOR_CONTEXT, IS_MAC_CHROME } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { ShortcutsService } from '@core/shortcuts.service';
|
||||
@@ -43,8 +46,6 @@ export class EditorWindow {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly ctx = EDITOR_CONTEXT;
|
||||
readonly isMacChrome = IS_MAC_CHROME;
|
||||
readonly modLabel = MOD_LABEL;
|
||||
readonly altLabel = ALT_LABEL;
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal<string | null>(null);
|
||||
@@ -92,6 +93,9 @@ export class EditorWindow {
|
||||
);
|
||||
|
||||
private readonly box = viewChild<ElementRef<HTMLTextAreaElement>>('box');
|
||||
private readonly prompt = viewChild<ElementRef<HTMLElement>>('prompt');
|
||||
private readonly promptSave = viewChild<ElementRef<HTMLButtonElement>>('promptSave');
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
|
||||
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
|
||||
private cachedCharWidth = 0;
|
||||
@@ -102,7 +106,22 @@ export class EditorWindow {
|
||||
// injected for its side effects: fetches settings and applies theme/accent/
|
||||
// text-size data attributes to this window's document root
|
||||
private readonly settings = inject(SettingsService);
|
||||
private readonly shortcuts = inject(ShortcutsService);
|
||||
readonly shortcuts = inject(ShortcutsService);
|
||||
|
||||
/** "⌘S save · ⌘F find · ⌘W close" — the keys from the live keymap, so a remap
|
||||
* shows here too; an unbound command drops out. */
|
||||
readonly footerHint = computed(() =>
|
||||
(
|
||||
[
|
||||
['save', 'editor.verbs.save'],
|
||||
['find', 'editor.verbs.find'],
|
||||
['close-window', 'editor.verbs.close'],
|
||||
] as const
|
||||
)
|
||||
.filter(([id]) => this.shortcuts.label(id))
|
||||
.map(([id, verb]) => `${this.shortcuts.label(id)} ${this.i18n.t(verb)}`)
|
||||
.join(' · '),
|
||||
);
|
||||
|
||||
private draftTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastSentDirty = false;
|
||||
@@ -122,6 +141,11 @@ export class EditorWindow {
|
||||
});
|
||||
|
||||
afterNextRender(() => void this.load(ctx.sessionId, ctx.path));
|
||||
// the unsaved-changes prompt takes the keyboard, so keys can't edit the
|
||||
// hidden text behind it: focus its Save button whenever it shows
|
||||
afterRenderEffect(() => {
|
||||
if (this.closePrompt()) this.promptSave()?.nativeElement.focus();
|
||||
});
|
||||
}
|
||||
|
||||
private async load(sessionId: string, path: string): Promise<void> {
|
||||
@@ -134,7 +158,8 @@ export class EditorWindow {
|
||||
this.savedContent.set(res.content);
|
||||
this.text.set(res.content);
|
||||
this.loading.set(false);
|
||||
queueMicrotask(() => this.box()?.nativeElement.focus());
|
||||
// after the render that creates the textarea — a microtask runs before it
|
||||
afterNextRender(() => this.box()?.nativeElement.focus(), { injector: this.injector });
|
||||
}
|
||||
|
||||
blockContextMenu(event: Event): void {
|
||||
@@ -168,11 +193,14 @@ export class EditorWindow {
|
||||
}
|
||||
this.findOpen.set(true);
|
||||
this.activeMatch.set(0);
|
||||
queueMicrotask(() => {
|
||||
const input = this.findBox()?.nativeElement;
|
||||
input?.focus();
|
||||
input?.select();
|
||||
});
|
||||
afterNextRender(
|
||||
() => {
|
||||
const input = this.findBox()?.nativeElement;
|
||||
input?.focus();
|
||||
input?.select();
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
}
|
||||
|
||||
closeFind(): void {
|
||||
@@ -197,6 +225,29 @@ export class EditorWindow {
|
||||
this.goTo((this.activeMatch() + delta + count) % count);
|
||||
}
|
||||
|
||||
/** ⌘G / ⇧⌘G (F3 / ⇧F3): the next / previous match from the caret, find bar
|
||||
* open or not — the query survives closing it. With no query yet, open the bar. */
|
||||
findNext(delta: 1 | -1): void {
|
||||
if (!this.findQuery()) {
|
||||
this.openFind();
|
||||
return;
|
||||
}
|
||||
const list = this.matches();
|
||||
if (!list.length) return;
|
||||
const el = this.box()?.nativeElement;
|
||||
let index = -1;
|
||||
if (delta > 0) {
|
||||
const caret = el?.selectionEnd ?? 0;
|
||||
index = list.findIndex((start) => start >= caret);
|
||||
if (index < 0) index = 0;
|
||||
} else {
|
||||
const caret = el?.selectionStart ?? 0;
|
||||
for (let i = list.length - 1; i >= 0 && index < 0; i--) if (list[i] < caret) index = i;
|
||||
if (index < 0) index = list.length - 1;
|
||||
}
|
||||
this.goTo(index);
|
||||
}
|
||||
|
||||
onFindKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
@@ -319,55 +370,78 @@ export class EditorWindow {
|
||||
// ---- keyboard ----
|
||||
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
// Chord shortcuts resolve through the shared keymap (ShortcutsService);
|
||||
// Escape and Tab stay below because their meaning is THIS window's state
|
||||
// (close prompt up? find bar open? caret in the textarea?), not a binding.
|
||||
const match = this.shortcuts.resolve(event, 'editor');
|
||||
if (match) {
|
||||
// scale keys must not fire while typing — but the editor's own chords
|
||||
// (save/find/wrap) are WANTED mid-typing, so the guard is per-shortcut
|
||||
if (match.id.startsWith('scale-') && this.shortcuts.isTypingTarget(event)) return;
|
||||
event.preventDefault();
|
||||
switch (match.id) {
|
||||
case 'save':
|
||||
void this.save();
|
||||
break;
|
||||
case 'find':
|
||||
this.openFind();
|
||||
break;
|
||||
case 'close-window':
|
||||
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W
|
||||
// and bun routes it back as editorCloseRequest
|
||||
this.requestClose();
|
||||
break;
|
||||
case 'toggle-wrap':
|
||||
this.toggleWrap();
|
||||
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;
|
||||
this.handleKey(event);
|
||||
blockReloadKeys(event); // F5 / Ctrl+R must not reload over unsaved text
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's keys come from the shared keymap (`editor` scope); they fire
|
||||
* mid-typing because they are all chords or F-keys. Escape and Tab stay here:
|
||||
* their meaning is this window's state (prompt up? find bar open?).
|
||||
*/
|
||||
private handleKey(event: KeyboardEvent): void {
|
||||
if (event.defaultPrevented) return;
|
||||
const id = this.shortcuts.claim(event, 'editor');
|
||||
if (this.closePrompt()) {
|
||||
// the unsaved-changes prompt owns the keyboard: ⌘S is its Save, Esc its
|
||||
// Cancel, Tab walks its buttons; other commands wait
|
||||
if (id === 'save') void this.saveAndClose();
|
||||
else if (id === 'scale-up' || id === 'scale-down' || id === 'scale-reset') this.scale(id);
|
||||
else if (!id && event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.cancelClose();
|
||||
} else if (!id) {
|
||||
const prompt = this.prompt()?.nativeElement;
|
||||
if (prompt) cycleFocus(prompt, event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape' && this.closePrompt()) {
|
||||
event.preventDefault();
|
||||
this.cancelClose();
|
||||
} else if (event.key === 'Escape' && this.findOpen()) {
|
||||
switch (id) {
|
||||
case 'save':
|
||||
void this.save();
|
||||
return;
|
||||
case 'find':
|
||||
this.openFind();
|
||||
return;
|
||||
case 'find-next':
|
||||
this.findNext(1);
|
||||
return;
|
||||
case 'find-prev':
|
||||
this.findNext(-1);
|
||||
return;
|
||||
case 'close-window':
|
||||
this.requestClose();
|
||||
return;
|
||||
case 'toggle-wrap':
|
||||
this.toggleWrap();
|
||||
return;
|
||||
case 'scale-up':
|
||||
case 'scale-down':
|
||||
case 'scale-reset':
|
||||
this.scale(id);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape' && this.findOpen()) {
|
||||
event.preventDefault();
|
||||
this.closeFind();
|
||||
} else if (event.key === 'Tab' && event.target === this.box()?.nativeElement) {
|
||||
} else if (
|
||||
event.key === 'Tab' &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
event.target === this.box()?.nativeElement
|
||||
) {
|
||||
// keep Tab as an input character inside the editor
|
||||
event.preventDefault();
|
||||
this.insertTab();
|
||||
}
|
||||
}
|
||||
|
||||
private scale(id: 'scale-up' | 'scale-down' | 'scale-reset'): void {
|
||||
if (id === 'scale-reset') this.settings.setUiScale(1);
|
||||
else this.settings.setUiScale(this.settings.uiScale() + (id === 'scale-up' ? 0.1 : -0.1));
|
||||
}
|
||||
|
||||
private insertTab(): void {
|
||||
const el = this.box()!.nativeElement;
|
||||
// execCommand keeps the native undo stack intact where supported
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
[style.box-shadow]="activeMarker()"
|
||||
>
|
||||
<button
|
||||
(click)="toggleMenu()"
|
||||
#sourceBtn
|
||||
(click)="openSourcePicker(false)"
|
||||
class="flex min-w-0 items-center gap-1.5 px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent"
|
||||
[title]="i18n.t('panel.choosePanel')"
|
||||
[title]="shortcuts.tip(i18n.t('panel.choosePanel'), 'source-picker')"
|
||||
>
|
||||
@if (panel().connecting()) {
|
||||
<ng-icon name="tdesignLoading" class="shrink-0 animate-spin text-primary" size="var(--action-icon)" />
|
||||
@@ -29,8 +30,8 @@
|
||||
<!-- panel actions: act on THIS panel. Frequent/destructive verbs grow text
|
||||
labels when the panel is wide enough (container query). -->
|
||||
<button
|
||||
(click)="copyToOther()"
|
||||
[title]="side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft')"
|
||||
(click)="run('send')"
|
||||
[title]="shortcuts.tip(side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft'), 'send')"
|
||||
class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-primary"
|
||||
>
|
||||
<ng-icon [name]="copyIcon()" size="var(--action-icon)" />
|
||||
@@ -38,7 +39,7 @@
|
||||
</button>
|
||||
<button
|
||||
(click)="run('edit')"
|
||||
[title]="i18n.t('panel.editFile')"
|
||||
[title]="shortcuts.tip(i18n.t('panel.editFile'), 'edit')"
|
||||
class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="tdesignFile1" size="var(--action-icon)" />
|
||||
@@ -46,22 +47,22 @@
|
||||
</button>
|
||||
<button
|
||||
(click)="run('rename')"
|
||||
[title]="i18n.t('panel.rename')"
|
||||
[title]="shortcuts.tip(i18n.t('panel.rename'), 'rename')"
|
||||
class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="tdesignEdit" size="var(--action-icon)" />
|
||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="run('mkdir')"
|
||||
[title]="i18n.t('panel.newFolder')"
|
||||
(click)="run('new-folder')"
|
||||
[title]="shortcuts.tip(i18n.t('panel.newFolder'), 'new-folder')"
|
||||
class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="tdesignFolderAdd" size="var(--action-icon)" />
|
||||
</button>
|
||||
<button
|
||||
(click)="run('createFile')"
|
||||
[title]="i18n.t('panel.newFile')"
|
||||
(click)="run('new-file')"
|
||||
[title]="shortcuts.tip(i18n.t('panel.newFile'), 'new-file')"
|
||||
class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="tdesignFileAdd" size="var(--action-icon)" />
|
||||
@@ -69,7 +70,7 @@
|
||||
@if (chmodAvailable()) {
|
||||
<button
|
||||
(click)="run('chmod')"
|
||||
[title]="i18n.t('panel.permissions')"
|
||||
[title]="shortcuts.tip(i18n.t('panel.permissions'), 'chmod')"
|
||||
class="grid size-(--action-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="tdesignLockOn" size="var(--action-icon)" />
|
||||
@@ -77,87 +78,13 @@
|
||||
}
|
||||
<button
|
||||
(click)="run('delete')"
|
||||
[title]="i18n.t('panel.deleteHint')"
|
||||
[title]="shortcuts.tip(i18n.t('common.delete'), 'delete')"
|
||||
class="flex h-(--action-h) shrink-0 items-center px-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||
>
|
||||
<ng-icon name="tdesignDelete" size="var(--action-icon)" />
|
||||
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span>
|
||||
</button>
|
||||
|
||||
@if (menuOpen()) {
|
||||
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
|
||||
<div
|
||||
class="absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto border border-border bg-card py-1 text-card-foreground shadow-xl"
|
||||
>
|
||||
<button
|
||||
(click)="pickLocal()"
|
||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
|
||||
>
|
||||
<ng-icon name="tdesignHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||
<span class="flex-1">{{ i18n.t('common.local') }}</span>
|
||||
@if (!isRemote()) {
|
||||
<ng-icon name="tdesignCheck" size="0.85rem" class="shrink-0 text-primary" />
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Windows drive roots (empty list elsewhere) -->
|
||||
@for (drive of drives(); track drive) {
|
||||
<button
|
||||
(click)="pickDrive(drive)"
|
||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent"
|
||||
>
|
||||
<ng-icon name="tdesignHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||
<span class="flex-1 font-mono">{{ drive }}</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (connections.grouped().loose.length || connections.grouped().stacks.length) {
|
||||
<div class="my-1 border-t border-border"></div>
|
||||
}
|
||||
@for (connection of connections.grouped().loose; track connection.id) {
|
||||
<button
|
||||
(click)="pickConnection(connection)"
|
||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
|
||||
>
|
||||
<ng-icon name="tdesignServer" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||
<span class="flex-1 truncate">{{ connection.name }}</span>
|
||||
</button>
|
||||
}
|
||||
@for (group of connections.grouped().stacks; track group.stack.id) {
|
||||
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{{ group.stack.name }}
|
||||
</div>
|
||||
@for (connection of group.connections; track connection.id) {
|
||||
<button
|
||||
(click)="pickConnection(connection)"
|
||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent"
|
||||
>
|
||||
<ng-icon name="tdesignServer" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||
<span class="flex-1 truncate">{{ connection.name }}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
@if (connections.sshHosts().length) {
|
||||
<div class="my-1 border-t border-border"></div>
|
||||
<div class="px-3 pt-1.5 pb-0.5 text-[0.625rem] font-medium tracking-wide text-muted-foreground uppercase">
|
||||
~/.ssh/config
|
||||
</div>
|
||||
@for (host of connections.sshHosts(); track host.name) {
|
||||
<button
|
||||
(click)="pickSshHost(host)"
|
||||
[disabled]="host.unsupported"
|
||||
[title]="sshHostTitle(host)"
|
||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent disabled:opacity-60 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ng-icon name="tdesignServer" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||
<span class="flex-1 truncate">{{ host.name }}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- path bar -->
|
||||
@@ -165,25 +92,28 @@
|
||||
<button
|
||||
(click)="panel().up()"
|
||||
[disabled]="blocked()"
|
||||
[title]="i18n.t('panel.parentDir')"
|
||||
[title]="shortcuts.tip(i18n.t('panel.parentDir'), 'parent')"
|
||||
class="grid size-(--path-ctl-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
|
||||
>
|
||||
<ng-icon name="tdesignArrowUp" size="1rem" />
|
||||
</button>
|
||||
<!-- no blanket stopPropagation: the window handler lets only ⌘/Ctrl chords
|
||||
through from a text field, and its WebView2 reload guard must see F5 -->
|
||||
<input
|
||||
#pathBox
|
||||
[value]="pathDraft()"
|
||||
(input)="pathDraft.set($any($event.target).value)"
|
||||
(keydown.enter)="commitPath()"
|
||||
(keydown.escape)="resetPath()"
|
||||
(keydown)="$event.stopPropagation()"
|
||||
[disabled]="blocked()"
|
||||
[title]="shortcuts.tip(i18n.t('keys.focus-path'), 'focus-path')"
|
||||
spellcheck="false"
|
||||
class="h-(--path-ctl-h) min-w-0 flex-1 border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
(click)="panel().refresh()"
|
||||
(click)="run('refresh')"
|
||||
[disabled]="blocked()"
|
||||
[title]="i18n.t('panel.refresh')"
|
||||
[title]="shortcuts.tip(i18n.t('panel.refresh'), 'refresh')"
|
||||
class="grid size-(--path-ctl-h) shrink-0 place-items-center text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
|
||||
>
|
||||
@if (panel().loading()) {
|
||||
@@ -200,7 +130,11 @@
|
||||
[style.grid-template-columns]="gridTemplate()"
|
||||
class="grid border-b border-border bg-toolbar-3 text-[0.6875rem] font-medium text-muted-foreground"
|
||||
>
|
||||
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')">
|
||||
<button
|
||||
class="px-2 py-1 text-left hover:text-foreground"
|
||||
[title]="sortTip('name')"
|
||||
(click)="panel().toggleSort('name')"
|
||||
>
|
||||
{{ i18n.t('columns.name') }} {{ sortIndicator('name') }}
|
||||
</button>
|
||||
@for (col of visibleColumns(); track col.key) {
|
||||
@@ -208,6 +142,7 @@
|
||||
class="px-2 py-1 hover:text-foreground"
|
||||
[class.text-right]="col.key === 'size'"
|
||||
[class.text-left]="col.key !== 'size'"
|
||||
[title]="sortTip(col.key)"
|
||||
(click)="panel().toggleSort(col.key)"
|
||||
>
|
||||
{{ i18n.t('columns.' + col.key) }} {{ sortIndicator(col.key) }}
|
||||
|
||||
@@ -9,20 +9,21 @@ import {
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { isRepeatable } from '@shared/keymap';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
|
||||
import type { ColumnKey, FileEntry, SshConfigHost } from '@shared/types';
|
||||
import { ConnectionsService } from '@core/connections.service';
|
||||
import { ContextMenuService } from '@core/context-menu.service';
|
||||
import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
|
||||
import { DialogsService } from '@core/dialogs.service';
|
||||
import { FileActionsService } from '@core/file-actions.service';
|
||||
import { FileActionsService, type FileCommand, type FileTarget } from '@core/file-actions.service';
|
||||
import { fileGlyph, fileIconClass, fileKindKey } from '@core/file-icons';
|
||||
import { formatMode } from '@core/format';
|
||||
import { remPx } from '@core/metrics';
|
||||
import { PanelsService, type Side, type SortKey } from '@core/panels.service';
|
||||
import { IS_WINDOWS } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { ShortcutsService } from '@core/shortcuts.service';
|
||||
import { TransfersService } from '@core/transfers.service';
|
||||
import { ShortcutsService, type ShortcutId } from '@core/shortcuts.service';
|
||||
|
||||
/** Optional columns in fixed display order, with their track widths. */
|
||||
const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
|
||||
@@ -34,6 +35,33 @@ const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
|
||||
{ key: 'group', label: 'Group', width: '6rem' },
|
||||
];
|
||||
|
||||
/** Rows a PageUp / PageDown jumps. */
|
||||
const PAGE = 20;
|
||||
|
||||
/** Cursor movements: with Shift they extend the selection instead. */
|
||||
const MOVES = new Set<ShortcutId>([
|
||||
'cursor-up',
|
||||
'cursor-down',
|
||||
'page-up',
|
||||
'page-down',
|
||||
'cursor-first',
|
||||
'cursor-last',
|
||||
]);
|
||||
|
||||
/** The same keydown with Shift lifted — what a Shift+movement spells underneath. */
|
||||
function withoutShift(event: KeyboardEvent) {
|
||||
return {
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
metaKey: event.metaKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
altKey: event.altKey,
|
||||
shiftKey: false,
|
||||
target: event.target,
|
||||
getModifierState: (key: string) => event.getModifierState(key),
|
||||
};
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-file-panel',
|
||||
imports: [NgIcon],
|
||||
@@ -46,12 +74,11 @@ export class FilePanel {
|
||||
|
||||
readonly panels = inject(PanelsService);
|
||||
readonly actions = inject(FileActionsService);
|
||||
readonly transfers = inject(TransfersService);
|
||||
readonly connections = inject(ConnectionsService);
|
||||
readonly dialogs = inject(DialogsService);
|
||||
readonly shortcuts = inject(ShortcutsService);
|
||||
private readonly menu = inject(ContextMenuService);
|
||||
private readonly settings = inject(SettingsService);
|
||||
private readonly shortcuts = inject(ShortcutsService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
readonly panel = computed(() => this.panels.get(this.side()));
|
||||
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
||||
@@ -67,13 +94,14 @@ export class FilePanel {
|
||||
readonly copyIcon = computed(() => (this.side() === 'left' ? 'tdesignArrowRight' : 'tdesignArrowLeft'));
|
||||
|
||||
readonly pathDraft = signal('');
|
||||
readonly menuOpen = signal(false);
|
||||
/** Windows drive roots for the source picker; always empty elsewhere. */
|
||||
readonly drives = signal<string[]>([]);
|
||||
/** chmod on Windows local files only flips the read-only bit — hide it. */
|
||||
readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote()));
|
||||
|
||||
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
|
||||
private readonly sourceBtn = viewChild.required<ElementRef<HTMLButtonElement>>('sourceBtn');
|
||||
private readonly pathBox = viewChild.required<ElementRef<HTMLInputElement>>('pathBox');
|
||||
private lastClickedIndex = 0;
|
||||
|
||||
// locale-reactive wrappers: reading i18n.locale() inside makes the file-list
|
||||
@@ -95,17 +123,6 @@ export class FilePanel {
|
||||
['minmax(0,1fr)', ...this.visibleColumns().map((c) => c.width)].join(' '),
|
||||
);
|
||||
|
||||
headerContextMenu(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
this.menu.open(event, this.menu.columnItems(this.settings.columns()));
|
||||
}
|
||||
|
||||
pickDrive(drive: string): void {
|
||||
this.menuOpen.set(false);
|
||||
this.activate();
|
||||
void this.panel().load(drive);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
if (IS_WINDOWS && this.rpc.available) {
|
||||
void this.rpc.request.listDrives({}).then((res) => {
|
||||
@@ -114,11 +131,23 @@ export class FilePanel {
|
||||
}
|
||||
// keep the editable path bar in sync with the actual cwd
|
||||
effect(() => this.pathDraft.set(this.panel().cwd()));
|
||||
// focus follows the active side (Tab switches panels, dialogs refocus on close)
|
||||
// focus follows the active side (Tab switches panels, dialogs and menus
|
||||
// refocus on close) — but never while a dialog or menu is up: a run of
|
||||
// overwrite questions reuses one dialog, and stealing focus back to the
|
||||
// list behind it would hand Enter and Delete to the panel
|
||||
effect(() => {
|
||||
this.panels.focusTick();
|
||||
if (this.isActive()) this.listEl().nativeElement.focus();
|
||||
if (this.isActive() && !this.dialogs.anyOpen && !this.menu.request()) {
|
||||
this.listEl().nativeElement.focus();
|
||||
}
|
||||
});
|
||||
// keys that land on <body> (a click dropped focus) still reach this list
|
||||
effect(() => this.panels.registerListKeys(this.side(), (event) => this.onKeydown(event)));
|
||||
}
|
||||
|
||||
headerContextMenu(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
this.menu.open(event, this.menu.columnItems(this.settings.columns()));
|
||||
}
|
||||
|
||||
activate(): void {
|
||||
@@ -129,38 +158,95 @@ export class FilePanel {
|
||||
this.panels.cancelConnect(this.side());
|
||||
}
|
||||
|
||||
copyToOther(): void {
|
||||
/** A toolbar button: the same command the keyboard and the menus run. */
|
||||
run(id: FileCommand): void {
|
||||
if (this.blocked()) return;
|
||||
this.activate();
|
||||
void this.transfers.copyToOtherPanel(this.side());
|
||||
this.actions.run(id, this.cursorTarget());
|
||||
}
|
||||
|
||||
run(action: 'edit' | 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void {
|
||||
if (this.blocked()) return;
|
||||
private cursorTarget(): FileTarget {
|
||||
const panel = this.panel();
|
||||
return { side: this.side(), panel, entry: panel.sorted()[panel.cursor()] ?? null };
|
||||
}
|
||||
|
||||
// ---- source picker: what this panel shows ----
|
||||
|
||||
/** The session button's menu (also ⌘K): Local, drives, saved connections by
|
||||
* stack, ~/.ssh/config hosts — through the shared context menu, so it gets
|
||||
* the same keyboard handling and look. */
|
||||
async openSourcePicker(keyboard: boolean): Promise<void> {
|
||||
this.activate();
|
||||
void this.actions[action](this.panel());
|
||||
// re-read ~/.ssh/config on each open so external edits show up — BEFORE
|
||||
// the items are built, the menu holds a snapshot
|
||||
await this.connections.reloadSshHosts();
|
||||
const rect = this.sourceBtn().nativeElement.getBoundingClientRect();
|
||||
this.menu.open({ clientX: rect.left, clientY: rect.bottom + remPx(0.25) }, this.sourceItems(), {
|
||||
keyboard,
|
||||
onPick: (action) => this.pickSource(action),
|
||||
});
|
||||
}
|
||||
|
||||
pickLocal(): void {
|
||||
this.menuOpen.set(false);
|
||||
void this.panels.disconnectPanel(this.side());
|
||||
private sourceItems(): MenuItem[] {
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
label: this.i18n.t('common.local'),
|
||||
action: 'local',
|
||||
icon: 'tdesignHardDrive',
|
||||
checked: !this.isRemote(),
|
||||
},
|
||||
];
|
||||
// Windows drive roots (empty list elsewhere)
|
||||
for (const drive of this.drives()) {
|
||||
items.push({ label: drive, action: `drive:${drive}`, icon: 'tdesignHardDrive' });
|
||||
}
|
||||
const { loose, stacks } = this.connections.grouped();
|
||||
if (loose.length || stacks.length) items.push({ type: 'separator' });
|
||||
const server = (connection: { id: string; name: string }): MenuItem => ({
|
||||
label: connection.name,
|
||||
action: `conn:${connection.id}`,
|
||||
icon: 'tdesignServer',
|
||||
});
|
||||
items.push(...loose.map(server));
|
||||
for (const group of stacks) {
|
||||
items.push({ type: 'header', label: group.stack.name }, ...group.connections.map(server));
|
||||
}
|
||||
const hosts = this.connections.sshHosts();
|
||||
if (hosts.length) {
|
||||
items.push({ type: 'separator' }, { type: 'header', label: this.i18n.t('panel.sshConfig') });
|
||||
for (const host of hosts) {
|
||||
items.push({
|
||||
label: host.name,
|
||||
action: `ssh:${host.name}`,
|
||||
icon: 'tdesignServer',
|
||||
disabled: !!host.unsupported,
|
||||
tooltip: this.sshHostTitle(host),
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
pickConnection(connection: SavedConnection): void {
|
||||
this.menuOpen.set(false);
|
||||
private pickSource(action: string): void {
|
||||
const [kind, ...rest] = action.split(':');
|
||||
const value = rest.join(':'); // a drive root ("C:\") has a colon of its own
|
||||
this.activate();
|
||||
void this.panels.connectPanel(this.side(), connection, connection.name, connection.localDir);
|
||||
if (kind === 'local') {
|
||||
void this.panels.disconnectPanel(this.side());
|
||||
} else if (kind === 'drive') {
|
||||
void this.panel().load(value);
|
||||
} else if (kind === 'conn') {
|
||||
const connection = this.connections.connections().find((c) => c.id === value);
|
||||
if (connection) {
|
||||
void this.panels.connectPanel(this.side(), connection, connection.name, connection.localDir);
|
||||
}
|
||||
} else if (kind === 'ssh') {
|
||||
const host = this.connections.sshHosts().find((h) => h.name === value);
|
||||
if (host) this.connectSshHost(host);
|
||||
}
|
||||
}
|
||||
|
||||
toggleMenu(): void {
|
||||
this.menuOpen.set(!this.menuOpen());
|
||||
// re-read ~/.ssh/config on each open so external edits show up
|
||||
if (this.menuOpen()) void this.connections.reloadSshHosts();
|
||||
}
|
||||
|
||||
pickSshHost(host: SshConfigHost): void {
|
||||
this.menuOpen.set(false);
|
||||
this.activate();
|
||||
private connectSshHost(host: SshConfigHost): void {
|
||||
void this.panels.connectPanel(
|
||||
this.side(),
|
||||
{
|
||||
@@ -209,6 +295,12 @@ export class FilePanel {
|
||||
return this.panel().sortAsc() ? '↑' : '↓';
|
||||
}
|
||||
|
||||
/** The sort header's tooltip: its keymap command and keys. */
|
||||
sortTip(key: SortKey): string {
|
||||
const id = `sort-${key}` as const;
|
||||
return this.shortcuts.tip(this.i18n.t(`keys.${id}`), id);
|
||||
}
|
||||
|
||||
commitPath(): void {
|
||||
if (this.blocked()) return;
|
||||
const value = this.pathDraft().trim();
|
||||
@@ -221,6 +313,12 @@ export class FilePanel {
|
||||
this.listEl().nativeElement.focus();
|
||||
}
|
||||
|
||||
private focusPath(): void {
|
||||
const el = this.pathBox().nativeElement;
|
||||
el.focus();
|
||||
el.select();
|
||||
}
|
||||
|
||||
rowClick(event: MouseEvent, index: number): void {
|
||||
if (this.blocked()) return;
|
||||
this.activate();
|
||||
@@ -257,105 +355,145 @@ export class FilePanel {
|
||||
this.actions.openContextMenu(event, { side: this.side(), panel: this.panel(), entry: null });
|
||||
}
|
||||
|
||||
openAt(index: number): void {
|
||||
if (this.blocked()) return;
|
||||
const entry = this.panel().sorted()[index];
|
||||
if (entry) this.panel().open(entry);
|
||||
/** ⇧F10 / Menu / ⌃↩: the cursor row's menu, under the row. The selection is
|
||||
* kept — the menu's commands act on it, like the F-keys do. */
|
||||
private openKeyboardMenu(): void {
|
||||
const panel = this.panel();
|
||||
const index = panel.cursor();
|
||||
const entry = panel.sorted()[index] ?? null;
|
||||
const row = this.listEl().nativeElement.querySelector<HTMLElement>(`[data-index="${index}"]`);
|
||||
row?.scrollIntoView({ block: 'nearest' });
|
||||
const rect = (row ?? this.listEl().nativeElement).getBoundingClientRect();
|
||||
this.actions.openContextMenu(
|
||||
{ clientX: rect.left + remPx(1.75), clientY: row ? rect.bottom : rect.top + remPx(0.5) },
|
||||
{ side: this.side(), panel, entry },
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
openAt(index: number): void {
|
||||
if (this.blocked()) return;
|
||||
const panel = this.panel();
|
||||
const key = event.key;
|
||||
const entry = panel.sorted()[index];
|
||||
if (entry) this.actions.run('open', { side: this.side(), panel, entry });
|
||||
}
|
||||
|
||||
if (key === 'Tab') {
|
||||
event.preventDefault();
|
||||
this.panels.setActiveSide(this.panels.otherSide(this.side()));
|
||||
/**
|
||||
* The list's keys, in order:
|
||||
* 1. while a query is being typed, printable keys (Space too) extend it,
|
||||
* Backspace trims and Esc clears it — fixed, ahead of every binding;
|
||||
* 2. the `panel` keymap (Shift + a movement extends the selection);
|
||||
* 3. a `main` binding (⌘T, Ctrl+Tab…) is left to bubble to App;
|
||||
* 4. any other printable key starts a type-to-filter query;
|
||||
* 5. Esc clears the selection.
|
||||
*/
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
const panel = this.panel();
|
||||
if (this.blocked()) {
|
||||
// a busy panel takes no input — except Esc, which cancels a connect; its
|
||||
// own keys are swallowed so Tab can't wander out of the list mid-load
|
||||
if (event.key === 'Escape' && panel.connecting()) {
|
||||
event.preventDefault();
|
||||
this.cancelConnect();
|
||||
} else if (this.shortcuts.resolve(event, 'panel')) {
|
||||
event.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.shortcuts.isMod(event) && key.toLowerCase() === 'a') {
|
||||
event.preventDefault();
|
||||
panel.selectAll();
|
||||
return;
|
||||
const plain = !event.metaKey && !event.ctrlKey && !event.altKey;
|
||||
const printable = plain && event.key.length === 1 && !event.isComposing;
|
||||
|
||||
if (panel.filter() !== '' && plain) {
|
||||
if (printable) return this.setFilter(event, panel.filter() + event.key);
|
||||
if (event.key === 'Backspace' && !event.shiftKey) return this.setFilter(event, panel.filter().slice(0, -1));
|
||||
if (event.key === 'Escape') return this.setFilter(event, '');
|
||||
}
|
||||
|
||||
// type-to-filter: printable characters build the query (Space only once a
|
||||
// query exists — an empty-filter Space keeps its toggle-select meaning)
|
||||
if (
|
||||
key.length === 1 &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
(key !== ' ' || panel.filter() !== '')
|
||||
) {
|
||||
const command = this.panelCommand(event);
|
||||
if (command) {
|
||||
event.preventDefault();
|
||||
panel.setFilter(panel.filter() + key);
|
||||
if (event.repeat && !isRepeatable(command.id)) return;
|
||||
this.runKey(command.id, command.extend);
|
||||
this.scrollCursorIntoView();
|
||||
return;
|
||||
}
|
||||
if (key === 'Backspace' && panel.filter() !== '') {
|
||||
event.preventDefault();
|
||||
panel.setFilter(panel.filter().slice(0, -1));
|
||||
return;
|
||||
}
|
||||
if (key === 'Escape' && panel.filter() !== '') {
|
||||
event.preventDefault();
|
||||
panel.setFilter('');
|
||||
return;
|
||||
}
|
||||
if (this.shortcuts.resolve(event, 'main')) return;
|
||||
|
||||
switch (key) {
|
||||
case 'ArrowDown':
|
||||
panel.moveCursor(1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
if (printable && event.key !== ' ') return this.setFilter(event, event.key);
|
||||
if (event.key === 'Escape' && plain && panel.selected().size) {
|
||||
event.preventDefault();
|
||||
panel.clearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
/** The panel command a keydown spells. Shift + a movement key is that
|
||||
* movement extending the selection — a fixed rule, so it follows remaps. */
|
||||
private panelCommand(event: KeyboardEvent): { id: ShortcutId; extend: boolean } | null {
|
||||
const id = this.shortcuts.resolve(event, 'panel');
|
||||
if (id) return { id, extend: false };
|
||||
if (!event.shiftKey) return null;
|
||||
const base = this.shortcuts.resolve(withoutShift(event), 'panel');
|
||||
return base && MOVES.has(base) ? { id: base, extend: true } : null;
|
||||
}
|
||||
|
||||
private runKey(id: ShortcutId, extend: boolean): void {
|
||||
const panel = this.panel();
|
||||
const from = panel.cursor();
|
||||
switch (id) {
|
||||
case 'cursor-up':
|
||||
panel.moveCursor(-1);
|
||||
break;
|
||||
case 'PageDown':
|
||||
panel.moveCursor(20);
|
||||
case 'cursor-down':
|
||||
panel.moveCursor(1);
|
||||
break;
|
||||
case 'PageUp':
|
||||
panel.moveCursor(-20);
|
||||
case 'page-up':
|
||||
panel.moveCursor(-PAGE);
|
||||
break;
|
||||
case 'Home':
|
||||
case 'page-down':
|
||||
panel.moveCursor(PAGE);
|
||||
break;
|
||||
case 'cursor-first':
|
||||
panel.cursor.set(0);
|
||||
break;
|
||||
case 'End':
|
||||
case 'cursor-last':
|
||||
panel.cursor.set(Math.max(0, panel.sorted().length - 1));
|
||||
break;
|
||||
case ' ':
|
||||
{
|
||||
const entry = panel.sorted()[panel.cursor()];
|
||||
if (entry) panel.toggleSelect(entry.name);
|
||||
panel.moveCursor(1);
|
||||
}
|
||||
break;
|
||||
case 'Enter':
|
||||
this.openAt(panel.cursor());
|
||||
return void event.preventDefault();
|
||||
case 'Backspace':
|
||||
case 'toggle-select': {
|
||||
const entry = panel.sorted()[from];
|
||||
if (entry) panel.toggleSelect(entry.name);
|
||||
panel.moveCursor(1);
|
||||
return;
|
||||
}
|
||||
case 'select-all':
|
||||
panel.selectAll();
|
||||
return;
|
||||
case 'parent':
|
||||
panel.up();
|
||||
break;
|
||||
case 'F2':
|
||||
this.actions.rename(panel);
|
||||
break;
|
||||
case 'F4':
|
||||
void this.actions.edit(panel);
|
||||
break;
|
||||
case 'F5':
|
||||
void this.transfers.copyToOtherPanel(this.side());
|
||||
break;
|
||||
case 'F7':
|
||||
this.actions.mkdir(panel);
|
||||
break;
|
||||
case 'F8':
|
||||
case 'Delete':
|
||||
this.actions.delete(panel);
|
||||
break;
|
||||
return;
|
||||
case 'switch-panel':
|
||||
this.panels.setActiveSide(this.panels.otherSide(this.side()));
|
||||
return;
|
||||
case 'row-menu':
|
||||
this.openKeyboardMenu();
|
||||
return;
|
||||
case 'focus-path':
|
||||
this.focusPath();
|
||||
return;
|
||||
case 'source-picker':
|
||||
void this.openSourcePicker(true);
|
||||
return;
|
||||
default:
|
||||
return; // let unhandled keys through
|
||||
// every other panel-scope command acts on files (keymap.ts)
|
||||
this.actions.run(id as FileCommand, this.cursorTarget());
|
||||
return;
|
||||
}
|
||||
// Shift + movement: select every row the cursor passed over
|
||||
if (extend) panel.selectRange(from, panel.cursor());
|
||||
}
|
||||
|
||||
private setFilter(event: KeyboardEvent, query: string): void {
|
||||
event.preventDefault();
|
||||
this.panel().setFilter(query);
|
||||
this.scrollCursorIntoView();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BRANDING } from '@shared/branding';
|
||||
import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n';
|
||||
import { COLUMN_KEYS } from '@core/context-menu.service';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform';
|
||||
import { blockNativeContextMenu, blockReloadKeys, IS_MAC_CHROME } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { ShortcutsService } from '@core/shortcuts.service';
|
||||
@@ -74,14 +74,14 @@ export class SettingsWindow {
|
||||
void this.rpc.request.showAbout({});
|
||||
}
|
||||
|
||||
/** Any-window shortcuts (interface scale); ⌘W arrives as a menu action bun-side. */
|
||||
/** This window's keys: close-window, and the any-window interface scale. */
|
||||
onWindowKeydown(event: KeyboardEvent): void {
|
||||
const match = this.shortcuts.resolve(event, 'settings');
|
||||
if (!match || this.shortcuts.isTypingTarget(event)) return;
|
||||
event.preventDefault();
|
||||
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
||||
else if (match.id === 'scale-reset') this.settings.setUiScale(1);
|
||||
const id = this.shortcuts.claim(event, 'settings');
|
||||
if (id === 'close-window') void this.rpc.request.settingsClose({});
|
||||
else if (id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
||||
else if (id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
||||
else if (id === 'scale-reset') this.settings.setUiScale(1);
|
||||
blockReloadKeys(event);
|
||||
}
|
||||
|
||||
blockContextMenu(event: Event): void {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { TransferSnapshot } from '@shared/types';
|
||||
import { formatSpeed } from '@core/format';
|
||||
import { PanelsService } from '@core/panels.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { ShortcutsService } from '@core/shortcuts.service';
|
||||
import { TransfersService } from '@core/transfers.service';
|
||||
|
||||
@Component({
|
||||
@@ -18,7 +19,7 @@ import { TransfersService } from '@core/transfers.service';
|
||||
<button
|
||||
(click)="transfers.expanded.set(!transfers.expanded())"
|
||||
class="flex min-w-0 items-center gap-1.5 hover:text-foreground"
|
||||
[title]="i18n.t('transfer.showDetails')"
|
||||
[title]="shortcuts.tip(i18n.t('transfer.showDetails'), 'toggle-transfers')"
|
||||
>
|
||||
@if (transfers.currentJob(); as job) {
|
||||
<ng-icon name="tdesignLoading" size="0.8rem" class="shrink-0 animate-spin text-primary" />
|
||||
@@ -62,6 +63,7 @@ import { TransfersService } from '@core/transfers.service';
|
||||
export class StatusBar {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly transfers = inject(TransfersService);
|
||||
readonly shortcuts = inject(ShortcutsService);
|
||||
readonly panels = inject(PanelsService);
|
||||
readonly settings = inject(SettingsService);
|
||||
readonly activePanel = computed(() => this.panels.get(this.panels.activeSide()));
|
||||
|
||||
@@ -3,8 +3,9 @@ import { NgIcon } from '@ng-icons/core';
|
||||
import { ContextMenuService } from '@core/context-menu.service';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { PanelsService, type Workspace } from '@core/panels.service';
|
||||
import { IS_MAC_CHROME, MOD_LABEL } from '@core/platform';
|
||||
import { IS_MAC_CHROME } from '@core/platform';
|
||||
import { RpcService } from '@core/rpc.service';
|
||||
import { ShortcutsService } from '@core/shortcuts.service';
|
||||
import { UpdaterService } from '@core/updater.service';
|
||||
|
||||
@Component({
|
||||
@@ -63,7 +64,7 @@ import { UpdaterService } from '@core/updater.service';
|
||||
(click)="close($event, ws.id)"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
[title]="i18n.t('tabBar.closeTab', { mod: modLabel })"
|
||||
[title]="i18n.t('tabBar.closeTab')"
|
||||
class="mr-1 grid size-4 shrink-0 place-items-center text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="tdesignClose" size="0.7rem" />
|
||||
@@ -79,7 +80,7 @@ import { UpdaterService } from '@core/updater.service';
|
||||
</div>
|
||||
<button
|
||||
(click)="panels.newWorkspace()"
|
||||
[title]="i18n.t('tabBar.newTab', { mod: modLabel })"
|
||||
[title]="shortcuts.tip(i18n.t('tabBar.newTab'), 'new-tab')"
|
||||
class="ml-0.5 grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="tdesignAdd" size=".8rem" />
|
||||
@@ -121,7 +122,7 @@ import { UpdaterService } from '@core/updater.service';
|
||||
<!-- connection manager (own native window) -->
|
||||
<button
|
||||
(click)="openManager()"
|
||||
[title]="i18n.t('tabBar.manageConnections')"
|
||||
[title]="shortcuts.tip(i18n.t('tabBar.manageConnections'), 'connection-manager')"
|
||||
class="grid h-6 px-2 py-1 place-items-center text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<!-- <ng-icon name="tdesignSetting" size="0.9rem" /> -->
|
||||
@@ -131,7 +132,7 @@ import { UpdaterService } from '@core/updater.service';
|
||||
<!-- settings (own native window) -->
|
||||
<button
|
||||
(click)="openSettings()"
|
||||
[title]="i18n.t('tabBar.options')"
|
||||
[title]="shortcuts.tip(i18n.t('tabBar.options'), 'open-settings')"
|
||||
class="grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="tdesignSetting" size="0.9rem" />
|
||||
@@ -145,8 +146,8 @@ export class TabBar {
|
||||
readonly updater = inject(UpdaterService);
|
||||
private readonly contextMenu = inject(ContextMenuService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
readonly shortcuts = inject(ShortcutsService);
|
||||
readonly isMacChrome = IS_MAC_CHROME;
|
||||
readonly modLabel = MOD_LABEL;
|
||||
|
||||
// drag & drop reordering
|
||||
readonly draggedId = signal<number | null>(null);
|
||||
@@ -213,9 +214,7 @@ export class TabBar {
|
||||
|
||||
/** Update pill: first click downloads, second click (when ready) applies + relaunches. */
|
||||
onUpdateClick(): void {
|
||||
const state = this.updater.state();
|
||||
if (state === 'available') void this.updater.download();
|
||||
else if (state === 'ready') this.updater.apply();
|
||||
this.updater.advance();
|
||||
}
|
||||
|
||||
/** Insertion line at the drop position (inset shadow: no layout shift). */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { ToastService } from '@core/toast.service';
|
||||
|
||||
/** Toast stack overlay; shared by the main window and editor windows. */
|
||||
@@ -7,7 +8,7 @@ import { ToastService } from '@core/toast.service';
|
||||
selector: 'app-toasts',
|
||||
imports: [NgIcon],
|
||||
template: `
|
||||
<div class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
|
||||
<div aria-live="polite" class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
|
||||
@for (t of toast.toasts(); track t.id) {
|
||||
<div
|
||||
(mouseenter)="toast.pause(t.id)"
|
||||
@@ -22,6 +23,7 @@ import { ToastService } from '@core/toast.service';
|
||||
<span class="min-w-0 flex-1 wrap-break-word">{{ t.text }}</span>
|
||||
<button
|
||||
(click)="toast.dismiss(t.id)"
|
||||
[attr.aria-label]="i18n.t('transfer.dismiss')"
|
||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="tdesignClose" size="0.9rem" />
|
||||
@@ -41,6 +43,7 @@ import { ToastService } from '@core/toast.service';
|
||||
})
|
||||
export class Toasts {
|
||||
readonly toast = inject(ToastService);
|
||||
readonly i18n = inject(I18nService);
|
||||
|
||||
toastIcon(kind: 'error' | 'info' | 'success'): string {
|
||||
if (kind === 'error') return 'tdesignCloseCircle';
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, ElementRef, Injector, afterNextRender, inject } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import type { TransferSnapshot } from '@shared/types';
|
||||
import { cycleFocus } from '@core/focus-cycle';
|
||||
import { formatEta } from '@core/format';
|
||||
import { I18nService } from '@core/i18n.service';
|
||||
import { PanelsService } from '@core/panels.service';
|
||||
import { SettingsService } from '@core/settings.service';
|
||||
import { TransfersService } from '@core/transfers.service';
|
||||
|
||||
@@ -10,11 +12,35 @@ import { TransfersService } from '@core/transfers.service';
|
||||
selector: 'app-transfer-bar',
|
||||
imports: [NgIcon],
|
||||
templateUrl: './transfer-bar.html',
|
||||
host: { '(keydown)': 'onKeydown($event)' },
|
||||
})
|
||||
export class TransferBar {
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly transfers = inject(TransfersService);
|
||||
readonly settings = inject(SettingsService);
|
||||
private readonly panels = inject(PanelsService);
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly injector = inject(Injector);
|
||||
|
||||
/** ⌘J opened the list: focus its first control once it has rendered. */
|
||||
focusFirst(): void {
|
||||
afterNextRender(
|
||||
() => this.host.nativeElement.querySelector<HTMLElement>('button:not(:disabled)')?.focus(),
|
||||
{ injector: this.injector },
|
||||
);
|
||||
}
|
||||
|
||||
/** Tab walks Clear finished and the jobs' Cancel / Dismiss; Esc goes back to
|
||||
* the file list. */
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.panels.refocus();
|
||||
} else {
|
||||
cycleFocus(this.host.nativeElement, event);
|
||||
}
|
||||
}
|
||||
readonly formatSize = (bytes: number): string => this.i18n.size(bytes);
|
||||
|
||||
/** "14.2 MB/s · 0:42 left" while a job is moving bytes. */
|
||||
|
||||
@@ -285,6 +285,14 @@ body {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Where the keyboard is: a Tab-walked button, tab or sidebar entry (see
|
||||
focus-cycle.ts). :focus-visible only — a click never paints it. Inputs keep
|
||||
their own focus:ring, the file list its cursor row (outline-none wins). */
|
||||
:where(button, [role='tab'], [tabindex]:not([tabindex='-1'])):focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* No native arrow: a caret drawn from two currentColor triangles follows the
|
||||
theme (a data-URI svg can't). The opened option list stays native — macOS
|
||||
draws that popup itself. */
|
||||
|
||||
Reference in New Issue
Block a user