Finished-transfers setting + transfer-list header with Clear finished

- Options popover: 'Finished transfers' Auto-clear | Keep (default auto-clear,
  the previous behavior); persisted like the rest
- Transfer list header: 'N active · M finished' + Clear finished button
  (dismisses completed/failed/cancelled only, never running/queued)
- Failed rows now name the file that failed alongside the error message

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-11 19:56:24 +02:00
co-authored by Claude Fable 5
parent 45df0e450f
commit eaa2d00115
6 changed files with 62 additions and 2 deletions
+3
View File
@@ -50,6 +50,8 @@ export interface AppSettings {
transferMode: "single" | "multi"; transferMode: "single" | "multi";
/** How many jobs run at once when transferMode is "multi". */ /** How many jobs run at once when transferMode is "multi". */
transferConcurrency: number; transferConcurrency: number;
/** Remove completed transfers from the list automatically. */
autoClearCompleted: boolean;
} }
export const DEFAULT_SETTINGS: AppSettings = { export const DEFAULT_SETTINGS: AppSettings = {
@@ -58,6 +60,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
textSize: "default", textSize: "default",
transferMode: "single", transferMode: "single",
transferConcurrency: 3, transferConcurrency: 3,
autoClearCompleted: true,
}; };
export function effectiveConcurrency(settings: AppSettings): number { export function effectiveConcurrency(settings: AppSettings): number {
+8
View File
@@ -9,6 +9,7 @@ export class SettingsService {
readonly textSize = signal<AppSettings['textSize']>(DEFAULT_SETTINGS.textSize); readonly textSize = signal<AppSettings['textSize']>(DEFAULT_SETTINGS.textSize);
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode); readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency); readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency);
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
@@ -21,6 +22,7 @@ export class SettingsService {
this.textSize.set(res.settings.textSize); this.textSize.set(res.settings.textSize);
this.transferMode.set(res.settings.transferMode); this.transferMode.set(res.settings.transferMode);
this.transferConcurrency.set(res.settings.transferConcurrency); this.transferConcurrency.set(res.settings.transferConcurrency);
this.autoClearCompleted.set(res.settings.autoClearCompleted);
this.applyTheme(); this.applyTheme();
this.applyTextSize(); this.applyTextSize();
}); });
@@ -53,6 +55,11 @@ export class SettingsService {
void this.persist(); void this.persist();
} }
setAutoClearCompleted(auto: boolean): void {
this.autoClearCompleted.set(auto);
void this.persist();
}
private applyTheme(): void { private applyTheme(): void {
const theme = this.theme(); const theme = this.theme();
if (theme === 'system') { if (theme === 'system') {
@@ -78,6 +85,7 @@ export class SettingsService {
textSize: this.textSize(), textSize: this.textSize(),
transferMode: this.transferMode(), transferMode: this.transferMode(),
transferConcurrency: this.transferConcurrency(), transferConcurrency: this.transferConcurrency(),
autoClearCompleted: this.autoClearCompleted(),
}; };
} }
+24
View File
@@ -187,6 +187,30 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</div> </div>
</div> </div>
} }
<div class="mt-2.5 flex items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">Finished transfers</span>
<div class="flex rounded-md border border-border p-0.5">
<button
(click)="settings.setAutoClearCompleted(true)"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="settings.autoClearCompleted()"
[class.text-foreground]="settings.autoClearCompleted()"
[class.text-muted-foreground]="!settings.autoClearCompleted()"
>
Auto-clear
</button>
<button
(click)="settings.setAutoClearCompleted(false)"
class="rounded px-2 py-0.5 text-[11px]"
[class.bg-accent]="!settings.autoClearCompleted()"
[class.text-foreground]="!settings.autoClearCompleted()"
[class.text-muted-foreground]="settings.autoClearCompleted()"
>
Keep
</button>
</div>
</div>
</div> </div>
} }
</div> </div>
+12
View File
@@ -1,4 +1,16 @@
@if (transfers.expanded() && transfers.hasJobs()) { @if (transfers.expanded() && transfers.hasJobs()) {
<div class="flex items-center justify-between border-t border-border px-3 py-1 text-[11px] text-muted-foreground">
<span>
{{ transfers.activeJobs().length }} active · {{ transfers.finishedJobs().length }} finished
</span>
<button
(click)="transfers.clearFinished()"
[disabled]="!transfers.finishedJobs().length"
class="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
<ng-icon name="mynaTrash" size="0.75rem" /> Clear finished
</button>
</div>
<div class="max-h-40 overflow-y-auto border-t border-border"> <div class="max-h-40 overflow-y-auto border-t border-border">
@for (job of transfers.jobs(); track job.id) { @for (job of transfers.jobs(); track job.id) {
<div class="flex items-center gap-2 px-3 py-1.5 text-xs"> <div class="flex items-center gap-2 px-3 py-1.5 text-xs">
+2 -1
View File
@@ -63,7 +63,8 @@ export class TransferBar {
progressLabel(job: TransferSnapshot): string { progressLabel(job: TransferSnapshot): string {
if (job.state === 'queued') return 'Queued'; if (job.state === 'queued') return 'Queued';
if (job.state === 'scanning') return 'Scanning…'; 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 === 'cancelled') return 'Cancelled';
if (job.state === 'completed') if (job.state === 'completed')
return `${job.doneFiles} file${job.doneFiles === 1 ? '' : 's'} · ${formatSize(job.doneBytes)}`; return `${job.doneFiles} file${job.doneFiles === 1 ? '' : 's'} · ${formatSize(job.doneBytes)}`;
+13 -1
View File
@@ -3,6 +3,7 @@ import type { FileEntry, TransferSnapshot } from '@shared/types';
import { DialogsService } from './dialogs.service'; import { DialogsService } from './dialogs.service';
import { PanelsService, type Side } from './panels.service'; import { PanelsService, type Side } from './panels.service';
import { RpcService } from './rpc.service'; import { RpcService } from './rpc.service';
import { SettingsService } from './settings.service';
import { ToastService } from './toast.service'; import { ToastService } from './toast.service';
const AUTO_DISMISS_COMPLETED_MS = 5000; const AUTO_DISMISS_COMPLETED_MS = 5000;
@@ -16,6 +17,7 @@ export class TransfersService {
private readonly panels = inject(PanelsService); private readonly panels = inject(PanelsService);
private readonly toast = inject(ToastService); private readonly toast = inject(ToastService);
private readonly dialogs = inject(DialogsService); private readonly dialogs = inject(DialogsService);
private readonly settings = inject(SettingsService);
private readonly jobMap = signal<ReadonlyMap<string, TransferSnapshot>>(new Map()); private readonly jobMap = signal<ReadonlyMap<string, TransferSnapshot>>(new Map());
/** Smoothed bytes/sec per running job (UI-computed from progress deltas). */ /** 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 queuedCount = computed(() => this.jobs().filter((j) => j.state === 'queued').length);
readonly failedJobs = computed(() => this.jobs().filter((j) => j.state === 'failed')); 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). */ /** Whether the detailed per-job list is shown (toggled from the status bar). */
readonly expanded = signal(false); readonly expanded = signal(false);
@@ -131,6 +134,13 @@ export class TransfersService {
await this.rpc.request.cancelTransfer({ jobId }); 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<void> { async dismiss(jobId: string): Promise<void> {
this.jobMap.update((prev) => { this.jobMap.update((prev) => {
const next = new Map(prev); const next = new Map(prev);
@@ -156,7 +166,9 @@ export class TransfersService {
if (snapshot.state === 'completed') { if (snapshot.state === 'completed') {
this.refreshPanelsOn(snapshot.dstSessionId); 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') { } else if (snapshot.state === 'failed') {
this.expanded.set(true); this.expanded.set(true);
this.toast.error(`Transfer "${snapshot.name}" failed: ${snapshot.error ?? 'unknown error'}`); this.toast.error(`Transfer "${snapshot.name}" failed: ${snapshot.error ?? 'unknown error'}`);