import { Injectable, computed, inject, signal } from '@angular/core'; import type { FileEntry, TransferSnapshot } from '@shared/types'; import { DialogsService } from './dialogs.service'; import { PanelsService, type Side } from './panels.service'; import { RpcService } from './rpc.service'; import { SettingsService } from './settings.service'; import { ToastService } from './toast.service'; const AUTO_DISMISS_COMPLETED_MS = 5000; const ACTIVE_STATES = ['queued', 'scanning', 'running']; // EMA smoothing for the displayed transfer speed const SPEED_SMOOTHING = 0.7; @Injectable({ providedIn: 'root' }) export class TransfersService { private readonly rpc = inject(RpcService); private readonly panels = inject(PanelsService); private readonly toast = inject(ToastService); private readonly dialogs = inject(DialogsService); private readonly settings = inject(SettingsService); private readonly jobMap = signal>(new Map()); /** Smoothed bytes/sec per running job (UI-computed from progress deltas). */ readonly speeds = signal>(new Map()); private speedTrack = new Map(); readonly jobs = computed(() => [...this.jobMap().values()]); readonly hasJobs = computed(() => this.jobMap().size > 0); readonly activeJobs = computed(() => this.jobs().filter((j) => ACTIVE_STATES.includes(j.state))); readonly currentJob = computed( () => this.jobs().find((j) => j.state === 'running' || j.state === 'scanning') ?? null, ); readonly queuedCount = computed(() => this.jobs().filter((j) => j.state === 'queued').length); readonly failedJobs = computed(() => this.jobs().filter((j) => j.state === 'failed')); readonly finishedJobs = computed(() => this.jobs().filter((j) => !ACTIVE_STATES.includes(j.state))); /** Whether the detailed per-job list is shown (toggled from the status bar). */ readonly expanded = signal(false); constructor() { if (this.rpc.available) { this.rpc.onTransferUpdate((snapshot) => this.apply(snapshot)); } } /** Copy the given panel's targets into the other panel's cwd (F5 / toolbar). */ async copyToOtherPanel(fromSide: Side): Promise { const src = this.panels.get(fromSide); const dst = this.panels.other(fromSide); const selected = src.selectedEntries(); const atCursor = src.sorted()[src.cursor()]; const targets = (selected.length ? selected : atCursor ? [atCursor] : []).filter( (e) => e.type !== 'symlink' && e.name !== '..', ); if (!targets.length) { this.toast.info('Nothing to copy (symlinks are skipped)'); return; } const sameFs = src.fsKey() === dst.fsKey(); if (sameFs && src.cwd() === dst.cwd()) { this.toast.info('Both panels show the same folder'); return; } const finalTargets = await this.resolveCollisions(targets, dst.entries(), sameFs); if (finalTargets === null) return; // user cancelled if (!finalTargets.length) { this.toast.info('Nothing to copy — all conflicting items were skipped'); return; } const res = await this.rpc.request.enqueueTransfers({ srcSessionId: src.sessionId(), items: finalTargets.map(({ path, type }) => ({ path, type })), dstSessionId: dst.sessionId(), dstDir: dst.cwd(), }); if (!res.ok) this.toast.error(res.error); } /** * Confirm name collisions with the destination listing one by one. * Returns the approved target list, or null if the user cancelled. */ private async resolveCollisions( targets: FileEntry[], dstEntries: FileEntry[], sameFs: boolean, ): Promise { const dstByName = new Map(dstEntries.map((e) => [e.name, e])); const collisions = targets.filter((t) => { const dst = dstByName.get(t.name); return dst && (dst.type === 'dir') === (t.type === 'dir'); }); let mode: 'ask' | 'all' | 'none' = 'ask'; let asked = 0; const finalTargets: FileEntry[] = []; for (const target of targets) { const dstEntry = dstByName.get(target.name); if (!dstEntry) { finalTargets.push(target); continue; } if (sameFs && dstEntry.path === target.path) { // replacing a file with ITSELF would only destroy it — never offer that this.toast.info(`Skipped "${target.name}" — it is the same file`); continue; } if ((dstEntry.type === 'dir') !== (target.type === 'dir')) { this.toast.info(`Skipped "${target.name}" — a different kind of item is in the way`); continue; } if (mode === 'all') { finalTargets.push(target); continue; } if (mode === 'none') continue; const choice = await this.dialogs.askOverwrite({ srcEntry: target, dstEntry, index: ++asked, total: collisions.length, }); if (choice === 'cancel') return null; if (choice === 'overwriteAll') { mode = 'all'; finalTargets.push(target); } else if (choice === 'skipAll') { mode = 'none'; } else if (choice === 'overwrite') { finalTargets.push(target); } } return finalTargets; } async cancel(jobId: string): Promise { await this.rpc.request.cancelTransfer({ jobId }); } /** Remove every finished (completed/failed/cancelled) entry; running ones stay. */ clearFinished(): void { for (const job of this.finishedJobs()) { void this.dismiss(job.id); } } async dismiss(jobId: string): Promise { this.jobMap.update((prev) => { const next = new Map(prev); next.delete(jobId); return next; }); this.speedTrack.delete(jobId); this.speeds.update((prev) => { const next = new Map(prev); next.delete(jobId); return next; }); await this.rpc.request.dismissTransfer({ jobId }); } private apply(snapshot: TransferSnapshot): void { const previous = this.jobMap().get(snapshot.id); this.jobMap.update((prev) => new Map(prev).set(snapshot.id, snapshot)); this.trackSpeed(snapshot); const justFinished = previous?.state !== snapshot.state; if (!justFinished) return; if (snapshot.state === 'completed') { this.refreshPanelsOn(snapshot.dstSessionId); if (this.settings.autoClearCompleted()) { setTimeout(() => void this.dismiss(snapshot.id), AUTO_DISMISS_COMPLETED_MS); } } else if (snapshot.state === 'failed') { this.expanded.set(true); this.toast.error(`Transfer "${snapshot.name}" failed: ${snapshot.error ?? 'unknown error'}`); this.refreshPanelsOn(snapshot.dstSessionId); } else if (snapshot.state === 'cancelled') { this.refreshPanelsOn(snapshot.dstSessionId); } } private refreshPanelsOn(sessionId: string): void { for (const panel of this.panels.allPanels()) { if (panel.sessionId() === sessionId) void panel.refresh(); } } private trackSpeed(snapshot: TransferSnapshot): void { if (snapshot.state !== 'running') { if (snapshot.state !== 'queued' && snapshot.state !== 'scanning') { this.speedTrack.delete(snapshot.id); } return; } const now = performance.now(); const prev = this.speedTrack.get(snapshot.id); if (!prev) { this.speedTrack.set(snapshot.id, { bytes: snapshot.doneBytes, time: now, speed: 0 }); return; } const dt = (now - prev.time) / 1000; if (dt < 0.2) return; // wait for a meaningful window const instant = Math.max(0, (snapshot.doneBytes - prev.bytes) / dt); const speed = prev.speed ? SPEED_SMOOTHING * prev.speed + (1 - SPEED_SMOOTHING) * instant : instant; this.speedTrack.set(snapshot.id, { bytes: snapshot.doneBytes, time: now, speed }); this.speeds.update((map) => new Map(map).set(snapshot.id, speed)); } }