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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user