P3: transfer engine with queue, progress, cancel + transfer bar UI

- TransferEngine: FIFO queue, provider-generic stream pipeline (covers all four
  panel combos), throttled transferUpdate push, cancel via stream destroy,
  self-copy guards, failSession hook for dead SSH sessions
- TransfersService + TransferBar: per-job rows, progress bars, cancel/dismiss,
  auto-dismiss completed, dst panel refresh
- F5 + Copy button wired to active-panel selection
- verified: 30MB nested tree copy (UTF-8 names, empty dirs), self-copy rejection,
  mid-transfer cancel leaving partial file

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-11 13:46:34 +02:00
co-authored by Claude Fable 5
parent b907e0a4ce
commit 88af44acfc
9 changed files with 455 additions and 7 deletions
+91
View File
@@ -0,0 +1,91 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import type { TransferSnapshot } from '@shared/types';
import { PanelsService } from './panels.service';
import { RpcService } from './rpc.service';
import { ToastService } from './toast.service';
const AUTO_DISMISS_COMPLETED_MS = 5000;
@Injectable({ providedIn: 'root' })
export class TransfersService {
private readonly rpc = inject(RpcService);
private readonly panels = inject(PanelsService);
private readonly toast = inject(ToastService);
private readonly jobMap = signal<ReadonlyMap<string, TransferSnapshot>>(new Map());
readonly jobs = computed(() => [...this.jobMap().values()]);
readonly hasJobs = computed(() => this.jobMap().size > 0);
constructor() {
effect(() => {
const snapshot = this.rpc.transferUpdate();
if (snapshot) this.apply(snapshot);
});
}
/** Copy the active panel's targets into the other panel's cwd (the F5 action). */
async copyToOtherPanel(): Promise<void> {
const side = this.panels.activeSide();
const src = this.panels.get(side);
const dst = this.panels.other(side);
const selected = src.selectedEntries();
const atCursor = src.sorted()[src.cursor()];
const targets = (selected.length ? selected : atCursor ? [atCursor] : []).filter(
(e) => e.type !== 'symlink',
);
if (!targets.length) {
this.toast.info('Nothing to copy (symlinks are skipped)');
return;
}
if (src.sessionId() === dst.sessionId() && src.cwd() === dst.cwd()) {
this.toast.info('Both panels show the same folder');
return;
}
const res = await this.rpc.request.enqueueTransfers({
srcSessionId: src.sessionId(),
items: targets.map(({ path, type }) => ({ path, type })),
dstSessionId: dst.sessionId(),
dstDir: dst.cwd(),
});
if (!res.ok) this.toast.error(res.error);
}
async cancel(jobId: string): Promise<void> {
await this.rpc.request.cancelTransfer({ jobId });
}
async dismiss(jobId: string): Promise<void> {
this.jobMap.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));
const justFinished = previous?.state !== snapshot.state;
if (!justFinished) return;
if (snapshot.state === 'completed') {
this.refreshPanelsOn(snapshot.dstSessionId);
setTimeout(() => void this.dismiss(snapshot.id), AUTO_DISMISS_COMPLETED_MS);
} else if (snapshot.state === 'failed') {
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.left, this.panels.right]) {
if (panel.sessionId() === sessionId) void panel.refresh();
}
}
}