diff --git a/src/shared/types.ts b/src/shared/types.ts index ed21254..c43656b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -50,6 +50,8 @@ export interface AppSettings { transferMode: "single" | "multi"; /** How many jobs run at once when transferMode is "multi". */ transferConcurrency: number; + /** Remove completed transfers from the list automatically. */ + autoClearCompleted: boolean; } export const DEFAULT_SETTINGS: AppSettings = { @@ -58,6 +60,7 @@ export const DEFAULT_SETTINGS: AppSettings = { textSize: "default", transferMode: "single", transferConcurrency: 3, + autoClearCompleted: true, }; export function effectiveConcurrency(settings: AppSettings): number { diff --git a/ui/src/app/settings.service.ts b/ui/src/app/settings.service.ts index c7ad703..01cb5e4 100644 --- a/ui/src/app/settings.service.ts +++ b/ui/src/app/settings.service.ts @@ -9,6 +9,7 @@ export class SettingsService { readonly textSize = signal(DEFAULT_SETTINGS.textSize); readonly transferMode = signal(DEFAULT_SETTINGS.transferMode); readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency); + readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted); private readonly rpc = inject(RpcService); @@ -21,6 +22,7 @@ export class SettingsService { this.textSize.set(res.settings.textSize); this.transferMode.set(res.settings.transferMode); this.transferConcurrency.set(res.settings.transferConcurrency); + this.autoClearCompleted.set(res.settings.autoClearCompleted); this.applyTheme(); this.applyTextSize(); }); @@ -53,6 +55,11 @@ export class SettingsService { void this.persist(); } + setAutoClearCompleted(auto: boolean): void { + this.autoClearCompleted.set(auto); + void this.persist(); + } + private applyTheme(): void { const theme = this.theme(); if (theme === 'system') { @@ -78,6 +85,7 @@ export class SettingsService { textSize: this.textSize(), transferMode: this.transferMode(), transferConcurrency: this.transferConcurrency(), + autoClearCompleted: this.autoClearCompleted(), }; } diff --git a/ui/src/app/tab-bar/tab-bar.ts b/ui/src/app/tab-bar/tab-bar.ts index c2a6248..99644b8 100644 --- a/ui/src/app/tab-bar/tab-bar.ts +++ b/ui/src/app/tab-bar/tab-bar.ts @@ -187,6 +187,30 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; } + +
+ Finished transfers +
+ + +
+
} diff --git a/ui/src/app/transfer-bar/transfer-bar.html b/ui/src/app/transfer-bar/transfer-bar.html index f664460..79f5d07 100644 --- a/ui/src/app/transfer-bar/transfer-bar.html +++ b/ui/src/app/transfer-bar/transfer-bar.html @@ -1,4 +1,16 @@ @if (transfers.expanded() && transfers.hasJobs()) { +
+ + {{ transfers.activeJobs().length }} active · {{ transfers.finishedJobs().length }} finished + + +
@for (job of transfers.jobs(); track job.id) {
diff --git a/ui/src/app/transfer-bar/transfer-bar.ts b/ui/src/app/transfer-bar/transfer-bar.ts index 1a09873..82453af 100644 --- a/ui/src/app/transfer-bar/transfer-bar.ts +++ b/ui/src/app/transfer-bar/transfer-bar.ts @@ -63,7 +63,8 @@ export class TransferBar { 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 === 'failed') + return `${job.currentFile ? `${job.currentFile} — ` : ''}${job.error ?? 'Failed'}`; if (job.state === 'cancelled') return 'Cancelled'; if (job.state === 'completed') return `${job.doneFiles} file${job.doneFiles === 1 ? '' : 's'} · ${formatSize(job.doneBytes)}`; diff --git a/ui/src/app/transfers.service.ts b/ui/src/app/transfers.service.ts index 47af2a7..993c93b 100644 --- a/ui/src/app/transfers.service.ts +++ b/ui/src/app/transfers.service.ts @@ -3,6 +3,7 @@ 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; @@ -16,6 +17,7 @@ export class TransfersService { 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). */ @@ -29,6 +31,7 @@ export class TransfersService { ); 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); @@ -131,6 +134,13 @@ export class TransfersService { 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); @@ -156,7 +166,9 @@ export class TransfersService { if (snapshot.state === 'completed') { this.refreshPanelsOn(snapshot.dstSessionId); - setTimeout(() => void this.dismiss(snapshot.id), AUTO_DISMISS_COMPLETED_MS); + 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'}`);