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:
+5
-1
@@ -2,6 +2,7 @@ import { BrowserWindow, Updater } from "electrobun/bun";
|
|||||||
import { BRANDING } from "../shared/branding";
|
import { BRANDING } from "../shared/branding";
|
||||||
import { SessionRegistry } from "./fs/registry";
|
import { SessionRegistry } from "./fs/registry";
|
||||||
import { createAppRPC } from "./rpc-handlers";
|
import { createAppRPC } from "./rpc-handlers";
|
||||||
|
import { TransferEngine } from "./transfers/engine";
|
||||||
|
|
||||||
const PROD_URL = "views://app/index.html";
|
const PROD_URL = "views://app/index.html";
|
||||||
|
|
||||||
@@ -24,7 +25,8 @@ async function getMainViewUrl(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const registry = new SessionRegistry();
|
const registry = new SessionRegistry();
|
||||||
const rpc = createAppRPC(registry);
|
const engine = new TransferEngine(registry);
|
||||||
|
const rpc = createAppRPC(registry, engine);
|
||||||
const url = await getMainViewUrl();
|
const url = await getMainViewUrl();
|
||||||
|
|
||||||
new BrowserWindow({
|
new BrowserWindow({
|
||||||
@@ -39,4 +41,6 @@ new BrowserWindow({
|
|||||||
rpc,
|
rpc,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
engine.onUpdate = (snapshot) => rpc.send.transferUpdate(snapshot);
|
||||||
|
|
||||||
console.log(`${BRANDING.appName} started (${url})`);
|
console.log(`${BRANDING.appName} started (${url})`);
|
||||||
|
|||||||
+17
-5
@@ -2,6 +2,7 @@ import { BrowserView } from "electrobun/bun";
|
|||||||
import type { AppRPC } from "../shared/rpc";
|
import type { AppRPC } from "../shared/rpc";
|
||||||
import type { Result } from "../shared/types";
|
import type { Result } from "../shared/types";
|
||||||
import type { SessionRegistry } from "./fs/registry";
|
import type { SessionRegistry } from "./fs/registry";
|
||||||
|
import type { TransferEngine } from "./transfers/engine";
|
||||||
import { removeRecursive } from "./transfers/walk";
|
import { removeRecursive } from "./transfers/walk";
|
||||||
|
|
||||||
// SFTP ops can be slow (big listings, recursive deletes) — the 1s electrobun
|
// SFTP ops can be slow (big listings, recursive deletes) — the 1s electrobun
|
||||||
@@ -18,7 +19,7 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
|
|||||||
|
|
||||||
const NOT_YET = { ok: false as const, error: "Not implemented yet" };
|
const NOT_YET = { ok: false as const, error: "Not implemented yet" };
|
||||||
|
|
||||||
export function createAppRPC(registry: SessionRegistry) {
|
export function createAppRPC(registry: SessionRegistry, engine: TransferEngine) {
|
||||||
return BrowserView.defineRPC<AppRPC>({
|
return BrowserView.defineRPC<AppRPC>({
|
||||||
maxRequestTime: MAX_REQUEST_TIME_MS,
|
maxRequestTime: MAX_REQUEST_TIME_MS,
|
||||||
handlers: {
|
handlers: {
|
||||||
@@ -70,10 +71,21 @@ export function createAppRPC(registry: SessionRegistry) {
|
|||||||
// P4: SFTP sessions
|
// P4: SFTP sessions
|
||||||
connect: async () => NOT_YET,
|
connect: async () => NOT_YET,
|
||||||
disconnect: async () => NOT_YET,
|
disconnect: async () => NOT_YET,
|
||||||
// P3: transfer engine
|
|
||||||
enqueueTransfers: async () => NOT_YET,
|
enqueueTransfers: (params) =>
|
||||||
cancelTransfer: async () => NOT_YET,
|
wrap(async () => ({ jobIds: engine.enqueue(params) })),
|
||||||
dismissTransfer: async () => NOT_YET,
|
|
||||||
|
cancelTransfer: ({ jobId }) =>
|
||||||
|
wrap(async () => {
|
||||||
|
engine.cancel(jobId);
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
|
dismissTransfer: ({ jobId }) =>
|
||||||
|
wrap(async () => {
|
||||||
|
engine.dismiss(jobId);
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
// P4: connection store
|
// P4: connection store
|
||||||
getConnections: async () => NOT_YET,
|
getConnections: async () => NOT_YET,
|
||||||
upsertConnection: async () => NOT_YET,
|
upsertConnection: async () => NOT_YET,
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import type { Readable } from "node:stream";
|
||||||
|
import type { EntryType, TransferSnapshot, TransferState } from "../../shared/types";
|
||||||
|
import type { FileSystemProvider } from "../fs/provider";
|
||||||
|
import type { SessionRegistry } from "../fs/registry";
|
||||||
|
import { walkTree } from "./walk";
|
||||||
|
|
||||||
|
// One at a time keeps remote servers happy; bump for parallel jobs.
|
||||||
|
const TRANSFER_CONCURRENCY = 1;
|
||||||
|
const PROGRESS_INTERVAL_MS = 100;
|
||||||
|
|
||||||
|
interface Job {
|
||||||
|
snapshot: TransferSnapshot;
|
||||||
|
type: EntryType;
|
||||||
|
cancelled: boolean;
|
||||||
|
currentStream: Readable | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TERMINAL: TransferState[] = ["completed", "failed", "cancelled"];
|
||||||
|
|
||||||
|
async function ensureDir(fs: FileSystemProvider, path: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const st = await fs.stat(path);
|
||||||
|
if (st.type === "dir") return;
|
||||||
|
throw new Error(`"${path}" exists and is not a directory`);
|
||||||
|
} catch {
|
||||||
|
await fs.mkdir(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIFO queue of copy jobs. Because both providers speak node streams, one
|
||||||
|
* pipeline() uniformly covers local->local, upload, download and
|
||||||
|
* remote->remote (piped through this process).
|
||||||
|
*/
|
||||||
|
export class TransferEngine {
|
||||||
|
onUpdate: (snapshot: TransferSnapshot) => void = () => {};
|
||||||
|
|
||||||
|
private jobs = new Map<string, Job>();
|
||||||
|
private queue: Job[] = [];
|
||||||
|
private running = 0;
|
||||||
|
private counter = 0;
|
||||||
|
private lastEmit = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(private registry: SessionRegistry) {}
|
||||||
|
|
||||||
|
enqueue(params: {
|
||||||
|
srcSessionId: string;
|
||||||
|
items: { path: string; type: EntryType }[];
|
||||||
|
dstSessionId: string;
|
||||||
|
dstDir: string;
|
||||||
|
}): string[] {
|
||||||
|
const src = this.registry.get(params.srcSessionId);
|
||||||
|
const jobIds: string[] = [];
|
||||||
|
for (const item of params.items) {
|
||||||
|
const id = `job-${++this.counter}`;
|
||||||
|
const job: Job = {
|
||||||
|
type: item.type,
|
||||||
|
cancelled: false,
|
||||||
|
currentStream: null,
|
||||||
|
snapshot: {
|
||||||
|
id,
|
||||||
|
name: src.basename(item.path),
|
||||||
|
srcSessionId: params.srcSessionId,
|
||||||
|
srcPath: item.path,
|
||||||
|
dstSessionId: params.dstSessionId,
|
||||||
|
dstDir: params.dstDir,
|
||||||
|
state: "queued",
|
||||||
|
totalFiles: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
doneFiles: 0,
|
||||||
|
doneBytes: 0,
|
||||||
|
currentFile: "",
|
||||||
|
currentFileBytes: 0,
|
||||||
|
currentFileTotal: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.jobs.set(id, job);
|
||||||
|
this.queue.push(job);
|
||||||
|
jobIds.push(id);
|
||||||
|
this.emit(job, true);
|
||||||
|
}
|
||||||
|
this.pump();
|
||||||
|
return jobIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(jobId: string): void {
|
||||||
|
const job = this.jobs.get(jobId);
|
||||||
|
if (!job || TERMINAL.includes(job.snapshot.state)) return;
|
||||||
|
job.cancelled = true;
|
||||||
|
if (job.snapshot.state === "queued") {
|
||||||
|
this.queue = this.queue.filter((j) => j !== job);
|
||||||
|
this.finish(job, "cancelled");
|
||||||
|
} else {
|
||||||
|
job.currentStream?.destroy(new Error("transfer cancelled"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss(jobId: string): void {
|
||||||
|
const job = this.jobs.get(jobId);
|
||||||
|
if (job && TERMINAL.includes(job.snapshot.state)) this.jobs.delete(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fail every non-terminal job that touches a dead session. */
|
||||||
|
failSession(sessionId: string, reason: string): void {
|
||||||
|
for (const job of this.jobs.values()) {
|
||||||
|
const { srcSessionId, dstSessionId, state } = job.snapshot;
|
||||||
|
if (TERMINAL.includes(state)) continue;
|
||||||
|
if (srcSessionId !== sessionId && dstSessionId !== sessionId) continue;
|
||||||
|
job.cancelled = true;
|
||||||
|
job.snapshot.error = reason;
|
||||||
|
if (state === "queued") {
|
||||||
|
this.queue = this.queue.filter((j) => j !== job);
|
||||||
|
this.finish(job, "failed");
|
||||||
|
} else {
|
||||||
|
job.currentStream?.destroy(new Error(reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pump(): void {
|
||||||
|
while (this.running < TRANSFER_CONCURRENCY && this.queue.length) {
|
||||||
|
const job = this.queue.shift()!;
|
||||||
|
this.running++;
|
||||||
|
this.run(job)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
this.running--;
|
||||||
|
this.pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async run(job: Job): Promise<void> {
|
||||||
|
const snap = job.snapshot;
|
||||||
|
try {
|
||||||
|
const src = this.registry.get(snap.srcSessionId);
|
||||||
|
const dst = this.registry.get(snap.dstSessionId);
|
||||||
|
|
||||||
|
snap.state = "scanning";
|
||||||
|
this.emit(job, true);
|
||||||
|
|
||||||
|
// collect the work list
|
||||||
|
let files: { srcPath: string; dstPath: string; size: number; name: string }[];
|
||||||
|
if (job.type === "dir") {
|
||||||
|
const dstRoot = dst.join(snap.dstDir, snap.name);
|
||||||
|
this.guardAgainstSelfCopy(snap, dstRoot);
|
||||||
|
const tree = await walkTree(src, snap.srcPath);
|
||||||
|
for (const segments of tree.dirs) {
|
||||||
|
if (job.cancelled) throw new Error("transfer cancelled");
|
||||||
|
await ensureDir(dst, dst.join(dstRoot, ...segments));
|
||||||
|
}
|
||||||
|
files = tree.files.map((f) => ({
|
||||||
|
srcPath: f.entry.path,
|
||||||
|
dstPath: dst.join(dstRoot, ...f.dirSegments, f.entry.name),
|
||||||
|
size: f.entry.size,
|
||||||
|
name: f.entry.name,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const dstPath = dst.join(snap.dstDir, snap.name);
|
||||||
|
this.guardAgainstSelfCopy(snap, dstPath);
|
||||||
|
const st = await src.stat(snap.srcPath);
|
||||||
|
files = [{ srcPath: snap.srcPath, dstPath, size: st.size, name: snap.name }];
|
||||||
|
}
|
||||||
|
|
||||||
|
snap.totalFiles = files.length;
|
||||||
|
snap.totalBytes = files.reduce((sum, f) => sum + f.size, 0);
|
||||||
|
snap.state = "running";
|
||||||
|
this.emit(job, true);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (job.cancelled) throw new Error("transfer cancelled");
|
||||||
|
snap.currentFile = file.name;
|
||||||
|
snap.currentFileBytes = 0;
|
||||||
|
snap.currentFileTotal = file.size;
|
||||||
|
this.emit(job, true);
|
||||||
|
|
||||||
|
const readStream = src.createReadStream(file.srcPath);
|
||||||
|
job.currentStream = readStream;
|
||||||
|
readStream.on("data", (chunk: Buffer | string) => {
|
||||||
|
const n = typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length;
|
||||||
|
snap.currentFileBytes += n;
|
||||||
|
snap.doneBytes += n;
|
||||||
|
this.emit(job);
|
||||||
|
});
|
||||||
|
await pipeline(readStream, dst.createWriteStream(file.dstPath));
|
||||||
|
job.currentStream = null;
|
||||||
|
snap.doneFiles++;
|
||||||
|
this.emit(job, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.finish(job, "completed");
|
||||||
|
} catch (error) {
|
||||||
|
job.currentStream = null;
|
||||||
|
if (!job.cancelled && !snap.error) {
|
||||||
|
snap.error = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
this.finish(job, job.cancelled && !snap.error ? "cancelled" : "failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copying onto itself truncates the source; into a descendant never ends. */
|
||||||
|
private guardAgainstSelfCopy(snap: TransferSnapshot, dstPath: string): void {
|
||||||
|
if (snap.srcSessionId !== snap.dstSessionId) return;
|
||||||
|
if (dstPath === snap.srcPath) {
|
||||||
|
throw new Error("Source and destination are the same");
|
||||||
|
}
|
||||||
|
if (dstPath.startsWith(snap.srcPath.endsWith("/") ? snap.srcPath : `${snap.srcPath}/`)) {
|
||||||
|
throw new Error("Cannot copy a folder into itself");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private finish(job: Job, state: TransferState): void {
|
||||||
|
if (TERMINAL.includes(job.snapshot.state)) return;
|
||||||
|
job.snapshot.state = state;
|
||||||
|
this.emit(job, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(job: Job, force = false): void {
|
||||||
|
const now = Date.now();
|
||||||
|
if (!force && now - (this.lastEmit.get(job.snapshot.id) ?? 0) < PROGRESS_INTERVAL_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastEmit.set(job.snapshot.id, now);
|
||||||
|
this.onUpdate({ ...job.snapshot });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,8 +20,17 @@
|
|||||||
<app-file-panel side="right" />
|
<app-file-panel side="right" />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<app-transfer-bar />
|
||||||
|
|
||||||
<!-- function bar: acts on the active panel -->
|
<!-- function bar: acts on the active panel -->
|
||||||
<footer class="flex items-center gap-1 border-t border-border px-2 py-1">
|
<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
|
<button
|
||||||
(click)="actions.rename()"
|
(click)="actions.rename()"
|
||||||
title="Rename (F2)"
|
title="Rename (F2)"
|
||||||
|
|||||||
+4
-1
@@ -9,10 +9,12 @@ import { FileActionsService } from './file-actions.service';
|
|||||||
import { FilePanel } from './file-panel/file-panel';
|
import { FilePanel } from './file-panel/file-panel';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from './rpc.service';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
|
import { TransferBar } from './transfer-bar/transfer-bar';
|
||||||
|
import { TransfersService } from './transfers.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [NgIcon, FilePanel, PromptDialog, ConfirmDialog, ChmodDialog],
|
imports: [NgIcon, FilePanel, PromptDialog, ConfirmDialog, ChmodDialog, TransferBar],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
@@ -21,6 +23,7 @@ export class App {
|
|||||||
readonly toast = inject(ToastService);
|
readonly toast = inject(ToastService);
|
||||||
readonly dialogs = inject(DialogsService);
|
readonly dialogs = inject(DialogsService);
|
||||||
readonly actions = inject(FileActionsService);
|
readonly actions = inject(FileActionsService);
|
||||||
|
readonly transfers = inject(TransfersService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
document.title = BRANDING.appName;
|
document.title = BRANDING.appName;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { FileEntry } from '@shared/types';
|
|||||||
import { FileActionsService } from '../file-actions.service';
|
import { FileActionsService } from '../file-actions.service';
|
||||||
import { formatDate, formatSize } from '../format';
|
import { formatDate, formatSize } from '../format';
|
||||||
import { PanelsService, type Side, type SortKey } from '../panels.service';
|
import { PanelsService, type Side, type SortKey } from '../panels.service';
|
||||||
|
import { TransfersService } from '../transfers.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-file-panel',
|
selector: 'app-file-panel',
|
||||||
@@ -25,6 +26,7 @@ export class FilePanel {
|
|||||||
|
|
||||||
readonly panels = inject(PanelsService);
|
readonly panels = inject(PanelsService);
|
||||||
readonly actions = inject(FileActionsService);
|
readonly actions = inject(FileActionsService);
|
||||||
|
readonly transfers = inject(TransfersService);
|
||||||
readonly panel = computed(() => this.panels.get(this.side()));
|
readonly panel = computed(() => this.panels.get(this.side()));
|
||||||
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
||||||
|
|
||||||
@@ -144,6 +146,9 @@ export class FilePanel {
|
|||||||
case 'F2':
|
case 'F2':
|
||||||
this.actions.rename();
|
this.actions.rename();
|
||||||
break;
|
break;
|
||||||
|
case 'F5':
|
||||||
|
void this.transfers.copyToOtherPanel();
|
||||||
|
break;
|
||||||
case 'F7':
|
case 'F7':
|
||||||
this.actions.mkdir();
|
this.actions.mkdir();
|
||||||
break;
|
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