import { Injectable, inject } from '@angular/core'; import type { FileEntry } from '@shared/types'; import { ContextMenuService } from './context-menu.service'; import { DialogsService } from './dialogs.service'; import { joinPath } from './format'; import { type PanelState, type Side } from './panels.service'; import { IS_WINDOWS } from './platform'; import { RpcService } from './rpc.service'; import { SettingsService } from './settings.service'; import { I18nService } from './i18n.service'; import { ToastService } from './toast.service'; /** The row (or empty space) the user last right-clicked; ctx-* actions apply here. */ export interface MenuTarget { side: Side; panel: PanelState; entry: FileEntry | null; } /** * 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. */ @Injectable({ providedIn: 'root' }) export class FileActionsService { private readonly i18n = inject(I18nService); private readonly rpc = inject(RpcService); private readonly toast = inject(ToastService); private readonly dialogs = inject(DialogsService); private readonly menu = inject(ContextMenuService); private readonly settings = inject(SettingsService); menuTarget: MenuTarget | null = null; /** 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; this.menu.open( event, this.menu.rowItems({ path: entry?.path ?? panel.cwd(), name: entry?.name ?? '', type: entry?.type ?? 'dir', isLocal: panel.sessionId() === 'local', hasEntry: !!entry, }), ); } async trash(panel: PanelState, entry: FileEntry): Promise { 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 })); await panel.refresh(); } /** Selection if non-empty, else the cursor entry. Never the ".." parent row. */ targets(panel: PanelState): FileEntry[] { const selected = panel.selectedEntries(); if (selected.length) return selected; const atCursor = panel.sorted()[panel.cursor()]; return atCursor && atCursor.name !== '..' ? [atCursor] : []; } mkdir(panel: PanelState): void { this.dialogs.prompt.set({ title: this.i18n.t('fileActions.newFolderTitle'), placeholder: this.i18n.t('fileActions.newFolderPlaceholder'), confirmLabel: this.i18n.t('fileActions.createConfirm'), onConfirm: async (name) => { const res = await this.rpc.request.mkdir({ sessionId: panel.sessionId(), path: joinPath(panel.cwd(), name), }); if (!res.ok) this.toast.error(res.error); await panel.refresh(); }, }); } createFile(panel: PanelState): void { this.dialogs.prompt.set({ title: this.i18n.t('fileActions.newFileTitle'), placeholder: this.i18n.t('fileActions.newFilePlaceholder'), confirmLabel: this.i18n.t('fileActions.createConfirm'), onConfirm: async (name) => { const res = await this.rpc.request.createFile({ sessionId: panel.sessionId(), path: joinPath(panel.cwd(), name), }); if (!res.ok) this.toast.error(res.error); await panel.refresh(); }, }); } /** Open the cursor file in the built-in editor or the configured external app. */ async edit(panel: PanelState): Promise { const entry = panel.sorted()[panel.cursor()]; if (!entry || entry.type !== 'file' || entry.name === '..') return; if (this.settings.editor() === 'custom') { const res = await this.rpc.request.editExternal({ sessionId: panel.sessionId(), path: entry.path, }); if (!res.ok) { this.toast.error(res.error); } else if (res.watching) { this.toast.info(this.i18n.t('toasts.editingExternal', { name: entry.name })); } return; } // built-in editor: its own native window (reads the file itself) const res = await this.rpc.request.openEditor({ sessionId: panel.sessionId(), path: entry.path, name: entry.name, sessionTitle: panel.title(), }); if (!res.ok) this.toast.error(res.error); } rename(panel: PanelState): void { const entry = panel.sorted()[panel.cursor()]; if (!entry || entry.name === '..') return; this.dialogs.prompt.set({ title: this.i18n.t('fileActions.renameTitle', { name: entry.name }), initial: entry.name, confirmLabel: this.i18n.t('fileActions.renameConfirm'), onConfirm: async (name) => { if (name === entry.name) return; const res = await this.rpc.request.rename({ sessionId: panel.sessionId(), oldPath: entry.path, newPath: joinPath(panel.cwd(), name), }); if (!res.ok) this.toast.error(res.error); await panel.refresh(); }, }); } delete(panel: PanelState): void { const targets = this.targets(panel); if (!targets.length) return; const what = targets.length === 1 ? `"${targets[0].name}"` : this.i18n.t('fileActions.itemsCount', { count: targets.length }); this.dialogs.confirm.set({ title: this.i18n.t('fileActions.deleteTitle'), body: this.i18n.t('fileActions.deleteBody', { what }), confirmLabel: this.i18n.t('fileActions.deleteConfirm'), destructive: true, onConfirm: async () => { const res = await this.rpc.request.deleteEntries({ sessionId: panel.sessionId(), entries: targets.map(({ path, type }) => ({ path, type })), }); if (!res.ok) this.toast.error(res.error); await panel.refresh(); }, }); } chmod(panel: PanelState): void { // Windows local chmod only flips the read-only bit — don't pretend if (IS_WINDOWS && panel.sessionId() === 'local') return; const targets = this.targets(panel); if (!targets.length) return; this.dialogs.chmod.set({ targets: targets.map((t) => t.name), initialMode: targets[0].mode, onConfirm: async (mode) => { const res = await this.rpc.request.chmod({ sessionId: panel.sessionId(), paths: targets.map((t) => t.path), mode, }); if (!res.ok) this.toast.error(res.error); await panel.refresh(); }, }); } }