import { Component, inject, signal } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import { I18nService } from '@core/i18n.service'; import { BRANDING } from '@shared/branding'; import type { ColumnKey } from '@shared/types'; import { ConnectionsService } from '@core/connections.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 { FilePanel } from './file-panel/file-panel'; import { PanelsService } from '@core/panels.service'; import { blockNativeContextMenu } from '@core/platform'; import { RpcService } from '@core/rpc.service'; import { SettingsService } from '@core/settings.service'; import { ShortcutsService } from '@core/shortcuts.service'; import { StatusBar } from './status-bar/status-bar'; import { TabBar } from './tab-bar/tab-bar'; import { TitleBar } from './title-bar/title-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, TitleBar, ContextMenu, ], templateUrl: './app.html', host: { '(window:keydown)': 'onWindowKeydown($event)', '(window:contextmenu)': 'blockContextMenu($event)', // Aero greys an inactive window's title bar and controls. The webview gets // DOM focus/blur when the native window's focus changes, so this needs no // RPC — see the `active` binding on .window in app.html. '(window:focus)': 'windowFocused.set(true)', '(window:blur)': 'windowFocused.set(false)', }, }) 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); /** The connection-manager window is open — this window is modal-blocked. */ readonly managerOpen = signal(false); /** Drives `.window.active`: Aero desaturates an unfocused window's chrome. */ readonly windowFocused = signal(document.hasFocus()); /** Shown in the win7 title bar (no native titlebar to carry it). */ readonly appName = BRANDING.appName; /** * win7 title bar's X. Closing the main window quits, and the app-level * before-quit guard (dirty editors + running transfers) still runs — it is * the one close path electrobun lets us cancel. */ closeWindow(): void { void this.rpc.request.windowClose({}); } 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('ctx-')) { this.handleContextAction(action); return; } 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': this.panels.newWorkspace(); break; case 'close-tab': this.panels.closeWorkspace(this.panels.activeWorkspaceId()); break; case 'new-folder': this.actions.mkdir(this.panels.active()); break; case 'new-file': this.actions.createFile(this.panels.active()); break; case 'edit-file': void this.actions.edit(this.panels.active()); break; case 'refresh-active': void this.panels.active().refresh(); break; case 'check-for-updates': void this.updater.check(); break; } } /** 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. */ 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) { case 'new-tab': this.panels.newWorkspace(); break; case 'close-tab': this.panels.closeWorkspace(this.panels.activeWorkspaceId()); break; case 'cycle-tab': this.panels.cycleWorkspace(1); break; case 'cycle-tab-back': 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); 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; } } /** 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 * 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; } } 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; } } }