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:
@@ -20,8 +20,17 @@
|
||||
<app-file-panel side="right" />
|
||||
</main>
|
||||
|
||||
<app-transfer-bar />
|
||||
|
||||
<!-- function bar: acts on the active panel -->
|
||||
<footer class="flex items-center gap-1 border-t border-border px-2 py-1">
|
||||
<button
|
||||
(click)="transfers.copyToOtherPanel()"
|
||||
title="Copy to other panel (F5)"
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaArrowLeftRight" size="0.9rem" /> Copy
|
||||
</button>
|
||||
<button
|
||||
(click)="actions.rename()"
|
||||
title="Rename (F2)"
|
||||
|
||||
+4
-1
@@ -9,10 +9,12 @@ import { FileActionsService } from './file-actions.service';
|
||||
import { FilePanel } from './file-panel/file-panel';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { ToastService } from './toast.service';
|
||||
import { TransferBar } from './transfer-bar/transfer-bar';
|
||||
import { TransfersService } from './transfers.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [NgIcon, FilePanel, PromptDialog, ConfirmDialog, ChmodDialog],
|
||||
imports: [NgIcon, FilePanel, PromptDialog, ConfirmDialog, ChmodDialog, TransferBar],
|
||||
templateUrl: './app.html',
|
||||
})
|
||||
export class App {
|
||||
@@ -21,6 +23,7 @@ export class App {
|
||||
readonly toast = inject(ToastService);
|
||||
readonly dialogs = inject(DialogsService);
|
||||
readonly actions = inject(FileActionsService);
|
||||
readonly transfers = inject(TransfersService);
|
||||
|
||||
constructor() {
|
||||
document.title = BRANDING.appName;
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { FileEntry } from '@shared/types';
|
||||
import { FileActionsService } from '../file-actions.service';
|
||||
import { formatDate, formatSize } from '../format';
|
||||
import { PanelsService, type Side, type SortKey } from '../panels.service';
|
||||
import { TransfersService } from '../transfers.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-file-panel',
|
||||
@@ -25,6 +26,7 @@ export class FilePanel {
|
||||
|
||||
readonly panels = inject(PanelsService);
|
||||
readonly actions = inject(FileActionsService);
|
||||
readonly transfers = inject(TransfersService);
|
||||
readonly panel = computed(() => this.panels.get(this.side()));
|
||||
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
||||
|
||||
@@ -144,6 +146,9 @@ export class FilePanel {
|
||||
case 'F2':
|
||||
this.actions.rename();
|
||||
break;
|
||||
case 'F5':
|
||||
void this.transfers.copyToOtherPanel();
|
||||
break;
|
||||
case 'F7':
|
||||
this.actions.mkdir();
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
@if (transfers.hasJobs()) {
|
||||
<div class="max-h-40 overflow-y-auto border-t border-border">
|
||||
@for (job of transfers.jobs(); track job.id) {
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 text-xs">
|
||||
<ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" />
|
||||
<span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[11px] text-muted-foreground">{{ progressLabel(job) }}</div>
|
||||
@if (isActive(job)) {
|
||||
<div class="mt-0.5 h-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-[width] duration-100"
|
||||
[style.width.%]="jobPercent(job)"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (isActive(job)) {
|
||||
<button
|
||||
(click)="transfers.cancel(job.id)"
|
||||
title="Cancel"
|
||||
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.9rem" />
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
(click)="transfers.dismiss(job.id)"
|
||||
title="Dismiss"
|
||||
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.9rem" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import type { TransferSnapshot } from '@shared/types';
|
||||
import { formatSize } from '../format';
|
||||
import { TransfersService } from '../transfers.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-transfer-bar',
|
||||
imports: [NgIcon],
|
||||
templateUrl: './transfer-bar.html',
|
||||
})
|
||||
export class TransferBar {
|
||||
readonly transfers = inject(TransfersService);
|
||||
readonly formatSize = formatSize;
|
||||
|
||||
stateIcon(job: TransferSnapshot): string {
|
||||
switch (job.state) {
|
||||
case 'completed':
|
||||
return 'mynaCheckCircle';
|
||||
case 'failed':
|
||||
case 'cancelled':
|
||||
return 'mynaXCircle';
|
||||
default:
|
||||
return 'mynaSpinner';
|
||||
}
|
||||
}
|
||||
|
||||
stateClass(job: TransferSnapshot): string {
|
||||
switch (job.state) {
|
||||
case 'completed':
|
||||
return 'shrink-0 text-success';
|
||||
case 'failed':
|
||||
return 'shrink-0 text-destructive';
|
||||
case 'cancelled':
|
||||
return 'shrink-0 text-muted-foreground';
|
||||
default:
|
||||
return 'shrink-0 animate-spin text-primary';
|
||||
}
|
||||
}
|
||||
|
||||
isActive(job: TransferSnapshot): boolean {
|
||||
return job.state === 'queued' || job.state === 'scanning' || job.state === 'running';
|
||||
}
|
||||
|
||||
jobPercent(job: TransferSnapshot): number {
|
||||
if (!job.totalBytes) return job.state === 'completed' ? 100 : 0;
|
||||
return Math.min(100, (job.doneBytes / job.totalBytes) * 100);
|
||||
}
|
||||
|
||||
progressLabel(job: TransferSnapshot): string {
|
||||
if (job.state === 'queued') return 'Queued';
|
||||
if (job.state === 'scanning') return 'Scanning…';
|
||||
if (job.state === 'failed') return job.error ?? 'Failed';
|
||||
if (job.state === 'cancelled') return 'Cancelled';
|
||||
if (job.state === 'completed')
|
||||
return `${job.doneFiles} file${job.doneFiles === 1 ? '' : 's'} · ${formatSize(job.doneBytes)}`;
|
||||
return `${job.currentFile} · ${formatSize(job.doneBytes)} / ${formatSize(job.totalBytes)}`;
|
||||
}
|
||||
}
|
||||
@@ -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