import { Component, inject, signal, viewChild } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import { I18nService } from '@core/i18n.service'; import { BRANDING } from '@shared/branding'; import { NATIVE_MENU_IDS } from '@shared/keymap'; import type { ColumnKey } from '@shared/types'; import { ConnectionsService } from '@core/connections.service'; import { ContextMenuService } from '@core/context-menu.service'; import { ContextMenu } from './context-menu'; import { ChmodDialog } from './dialogs/chmod-dialog'; import { ConfirmDialog } from './dialogs/confirm-dialog'; import { OverwriteDialog } from './dialogs/overwrite-dialog'; import { PromptDialog } from './dialogs/prompt-dialog'; import { DialogsService } from '@core/dialogs.service'; import { FileActionsService, type FileCommand } from '@core/file-actions.service'; import { FilePanel } from './file-panel/file-panel'; import { PanelsService } from '@core/panels.service'; import { blockNativeContextMenu, blockReloadKeys } from '@core/platform'; import { RpcService } from '@core/rpc.service'; import { SettingsService } from '@core/settings.service'; import { ShortcutsService, type ShortcutId } from '@core/shortcuts.service'; import { StatusBar } from './status-bar/status-bar'; import { TabBar } from './tab-bar/tab-bar'; import { ToastService } from '@core/toast.service'; import { Toasts } from './toasts/toasts'; import { TransferBar } from './transfer-bar/transfer-bar'; import { TransfersService } from '@core/transfers.service'; import { UpdaterService } from '@core/updater.service'; @Component({ selector: 'app-root', imports: [ NgIcon, FilePanel, TabBar, StatusBar, PromptDialog, ConfirmDialog, ChmodDialog, OverwriteDialog, Toasts, TransferBar, ContextMenu, ], templateUrl: './app.html', host: { '(window:keydown)': 'onWindowKeydown($event)', '(window:contextmenu)': 'blockContextMenu($event)', '(mousedown)': 'keepListFocus($event)', }, }) export class App { readonly i18n = inject(I18nService); readonly rpc = inject(RpcService); readonly toast = inject(ToastService); readonly dialogs = inject(DialogsService); readonly panels = inject(PanelsService); readonly actions = inject(FileActionsService); readonly connections = inject(ConnectionsService); private readonly transfers = inject(TransfersService); private readonly settings = inject(SettingsService); private readonly shortcuts = inject(ShortcutsService); private readonly updater = inject(UpdaterService); private readonly menu = inject(ContextMenuService); private readonly transferBar = viewChild(TransferBar); /** The connection-manager window is open — this window is modal-blocked. */ readonly managerOpen = signal(false); constructor() { document.title = BRANDING.appName; if (this.rpc.available) { this.rpc.onMenuAction((action) => void this.handleMenuAction(action)); this.rpc.onManagerState((open) => this.managerOpen.set(open)); void this.promptForEditLeftovers(); } } focusManager(): void { void this.rpc.request.openConnectionManager({}); } blockContextMenu(event: Event): void { blockNativeContextMenu(event); } /** Temp files from external edits in a previous run (crash / quit mid-edit). */ private async promptForEditLeftovers(): Promise { const res = await this.rpc.request.getEditLeftovers({}); if (!res.ok || !res.names.length) return; const shown = res.names.slice(0, 5).join(', '); const more = res.names.length > 5 ? this.i18n.t('toasts.leftoverMore', { count: res.names.length - 5 }) : ''; this.dialogs.confirm.set({ title: this.i18n.t('toasts.leftoverTitle'), body: this.i18n.t('toasts.leftoverBody', { count: res.names.length, names: shown + more }), confirmLabel: this.i18n.t('toasts.leftoverClear'), destructive: true, onConfirm: () => void this.rpc.request.clearEditLeftovers({}), }); } /** A connection by id; reloads once if unseen (a manager-window save can * race the connectionsChanged push). */ private async findConnection(id: string) { const lookup = () => this.connections.connections().find((c) => c.id === id); if (!lookup()) await this.connections.reload(); return lookup(); } /** Native application-menu / manager-window actions forwarded from bun. */ private async handleMenuAction(action: string): Promise { if (action.startsWith('connect:')) { const connection = await this.findConnection(action.slice('connect:'.length)); if (connection) { // remote goes right, local stays left — the commander convention void this.panels.connectPanel('right', connection, connection.name, connection.localDir); } return; } if (action.startsWith('col:')) { this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey); return; } if (action.startsWith('tab:')) { this.handleTabAction(action.slice('tab:'.length)); return; } if (action.startsWith('open-stack:')) { const id = action.slice('open-stack:'.length); let stackConnections = this.connections.connections().filter((c) => c.stackId === id); if (!stackConnections.length) { await this.connections.reload(); stackConnections = this.connections.connections().filter((c) => c.stackId === id); } if (stackConnections.length) this.panels.openStack(stackConnections); return; } switch (action) { case 'new-tab': case 'close-tab': this.runWindowCommand(action); break; // the macOS File / View items act on the active panel, exactly like their keys case 'new-folder': case 'new-file': this.runFileCommand(action); break; case 'edit-file': this.runFileCommand('edit'); break; case 'refresh-active': this.runFileCommand('refresh'); break; case 'check-for-updates': void this.updater.check(); break; } } private runFileCommand(id: FileCommand): void { const panel = this.panels.active(); const entry = panel.sorted()[panel.cursor()] ?? null; this.actions.run(id, { side: this.panels.activeSide(), panel, entry }); } /** * Window keys — the `main` scope of the shared keymap (ShortcutsService); the * focused file list handles its own `panel` keys first. Whatever nobody * claims ends at the WebView2 reload guard. */ onWindowKeydown(event: KeyboardEvent): void { this.handleWindowKey(event); blockReloadKeys(event); // a no-op once something claimed the key } private handleWindowKey(event: KeyboardEvent): void { // a component took it (the list, a menu, a dialog), or the connection // manager holds this window modal if (event.defaultPrevented || this.managerOpen()) return; if (this.dialogs.anyOpen || this.menu.request()) { this.swallowBehindOverlay(event); return; } if (event.target === document.body) { // a click dropped focus to : the active list still gets its keys this.panels.forwardListKey(event); this.panels.refocus(); if (event.defaultPrevented) return; } const id = this.shortcuts.claim(event, 'main'); if (id) this.runWindowCommand(id); } /** * A dialog or menu owns the keyboard. Window commands are swallowed — an * unclaimed ⌘T would fall through to the macOS menu bar and open a tab * behind the dialog — and so are the panel commands the menu bar carries * (⌘N, ⌘E…). The interface-scale keys still work. */ private swallowBehindOverlay(event: KeyboardEvent): void { const id = this.shortcuts.resolve(event, 'main'); if (id === 'scale-up' || id === 'scale-down' || id === 'scale-reset') { const claimed = this.shortcuts.claim(event, 'main'); if (claimed) this.runWindowCommand(claimed); return; } const panelId = id ? null : this.shortcuts.resolve(event, 'panel'); if (id || (panelId && NATIVE_MENU_IDS.includes(panelId))) event.preventDefault(); } private runWindowCommand(id: ShortcutId): void { const active = this.panels.activeWorkspaceId(); switch (id) { case 'new-tab': this.panels.newWorkspace(); break; case 'close-tab': this.panels.closeWorkspace(active); break; case 'close-other-tabs': this.panels.closeOtherWorkspaces(active); break; case 'close-tabs-right': this.panels.closeWorkspacesToRight(active); break; case 'next-tab': this.panels.cycleWorkspace(1); break; case 'prev-tab': this.panels.cycleWorkspace(-1); break; case 'go-to-last-tab': this.panels.goToWorkspace(-1); break; case 'move-tab-left': this.moveActiveTab(-1); break; case 'move-tab-right': this.moveActiveTab(1); break; case 'disconnect-tab': void this.panels.disconnectWorkspace(active); break; case 'open-settings': void this.rpc.request.openSettings({}); break; case 'connection-manager': void this.rpc.request.openConnectionManager({}); break; case 'toggle-transfers': this.toggleTransfers(); break; case 'install-update': this.updater.advance(); break; case 'scale-up': this.settings.setUiScale(this.settings.uiScale() + 0.1); break; case 'scale-down': this.settings.setUiScale(this.settings.uiScale() - 0.1); break; case 'scale-reset': this.settings.setUiScale(1); break; default: { // go-to-tab-1…8: that tab (go-to-last-tab is ⌘9, like Firefox) const n = this.shortcuts.binding(id)?.n; if (n) this.panels.goToWorkspace(n - 1); } } } /** Move-tab keys: the keyboard's tab drag. */ private moveActiveTab(delta: 1 | -1): void { const list = this.panels.workspaces(); const index = list.findIndex((w) => w.id === this.panels.activeWorkspaceId()); if (index >= 0) this.panels.moveWorkspace(list[index].id, index + delta); } /** ⌘J: open the transfer list with focus in it (Esc goes back to the files), * or close it. It only renders with jobs, so with none there is nothing to show. */ private toggleTransfers(): void { if (!this.transfers.hasJobs()) return; const open = !this.transfers.expanded(); this.transfers.expanded.set(open); if (open) this.transferBar()?.focusFirst(); else this.panels.refocus(); } /** * A click on a chrome button keeps keyboard focus where it was — normally the * file list. WebKit drops focus to on a button click (every list key * then died until the list was clicked), Windows parks it on the button, * where Space would click it again. The click itself still fires. Text * fields, menus, dialogs and the draggable tabs keep their default. */ keepListFocus(event: MouseEvent): void { const target = event.target as Element; if ( target.closest('button') && !target.closest('input, select, textarea, [role=menu], [role=dialog], [draggable=true]') ) { event.preventDefault(); } } /** * Workspace-tab menu actions. The target id travels INSIDE the action string * because the right-clicked tab is not necessarily the active one — nothing * here may fall back to activeWorkspaceId. */ private handleTabAction(action: string): void { if (action === 'new') { this.panels.newWorkspace(); return; } const parsed = /^([a-z-]+):(\d+)$/.exec(action); if (!parsed) return; const id = Number(parsed[2]); switch (parsed[1]) { case 'close': this.panels.closeWorkspace(id); break; case 'close-others': this.panels.closeOtherWorkspaces(id); break; case 'close-right': this.panels.closeWorkspacesToRight(id); break; case 'disconnect': void this.panels.disconnectWorkspace(id); break; } } }