v1.2: overwrite confirm, transfer speed/ETA, settings + themes, fused macOS titlebar
- Overwrite confirmation: name-collision detection before enqueue; dialog shows source vs destination (size, mtime, 'newer' badge) with per-item Skip/Replace and batch Skip all / Replace all; type mismatches auto-skip with a toast; cancel aborts the whole operation - Transfer speed + ETA: UI computes smoothed bytes/sec from progress deltas (callback bus makes deltas meaningful); shown in the expanded queue rows and the status-bar summary; unit follows the speed-unit setting - Settings: bun-side SettingsStore (settings.json, 0600, atomic) + get/save RPC; SettingsService; gear popover on the tab row (theme segmented control, speed unit); persisted across restarts - Light theme: data-theme on <html> drives token overrides; absent = follow OS - macOS fused titlebar: hiddenInset + trafficLightOffset, tab row becomes the draggable titlebar (app-region drag/no-drag classes); other platforms keep a separate titlebar (gated by a platform value injected via window preload) - README: known-issues note for the upstream Bun/Electrobun FFI crash Verified: tsc + ui build clean; SettingsStore roundtrip + 0600 headless; app boots and the UI fetches settings over RPC at startup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,26 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import type { TransferSnapshot } from '@shared/types';
|
||||
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 { ToastService } from './toast.service';
|
||||
|
||||
const AUTO_DISMISS_COMPLETED_MS = 5000;
|
||||
const ACTIVE_STATES = ['queued', 'scanning', 'running'];
|
||||
// EMA smoothing for the displayed transfer speed
|
||||
const SPEED_SMOOTHING = 0.7;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TransfersService {
|
||||
private readonly rpc = inject(RpcService);
|
||||
private readonly panels = inject(PanelsService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly dialogs = inject(DialogsService);
|
||||
|
||||
private readonly jobMap = signal<ReadonlyMap<string, TransferSnapshot>>(new Map());
|
||||
/** Smoothed bytes/sec per running job (UI-computed from progress deltas). */
|
||||
readonly speeds = signal<ReadonlyMap<string, number>>(new Map());
|
||||
private speedTrack = new Map<string, { bytes: number; time: number; speed: number }>();
|
||||
readonly jobs = computed(() => [...this.jobMap().values()]);
|
||||
readonly hasJobs = computed(() => this.jobMap().size > 0);
|
||||
readonly activeJobs = computed(() => this.jobs().filter((j) => ACTIVE_STATES.includes(j.state)));
|
||||
@@ -51,15 +58,75 @@ export class TransfersService {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalTargets = await this.resolveCollisions(targets, dst.entries());
|
||||
if (finalTargets === null) return; // user cancelled
|
||||
if (!finalTargets.length) {
|
||||
this.toast.info('Nothing to copy — all conflicting items were skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await this.rpc.request.enqueueTransfers({
|
||||
srcSessionId: src.sessionId(),
|
||||
items: targets.map(({ path, type }) => ({ path, type })),
|
||||
items: finalTargets.map(({ path, type }) => ({ path, type })),
|
||||
dstSessionId: dst.sessionId(),
|
||||
dstDir: dst.cwd(),
|
||||
});
|
||||
if (!res.ok) this.toast.error(res.error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm name collisions with the destination listing one by one.
|
||||
* Returns the approved target list, or null if the user cancelled.
|
||||
*/
|
||||
private async resolveCollisions(
|
||||
targets: FileEntry[],
|
||||
dstEntries: FileEntry[],
|
||||
): Promise<FileEntry[] | null> {
|
||||
const dstByName = new Map(dstEntries.map((e) => [e.name, e]));
|
||||
const collisions = targets.filter((t) => {
|
||||
const dst = dstByName.get(t.name);
|
||||
return dst && (dst.type === 'dir') === (t.type === 'dir');
|
||||
});
|
||||
|
||||
let mode: 'ask' | 'all' | 'none' = 'ask';
|
||||
let asked = 0;
|
||||
const finalTargets: FileEntry[] = [];
|
||||
|
||||
for (const target of targets) {
|
||||
const dstEntry = dstByName.get(target.name);
|
||||
if (!dstEntry) {
|
||||
finalTargets.push(target);
|
||||
continue;
|
||||
}
|
||||
if ((dstEntry.type === 'dir') !== (target.type === 'dir')) {
|
||||
this.toast.info(`Skipped "${target.name}" — a different kind of item is in the way`);
|
||||
continue;
|
||||
}
|
||||
if (mode === 'all') {
|
||||
finalTargets.push(target);
|
||||
continue;
|
||||
}
|
||||
if (mode === 'none') continue;
|
||||
|
||||
const choice = await this.dialogs.askOverwrite({
|
||||
srcEntry: target,
|
||||
dstEntry,
|
||||
index: ++asked,
|
||||
total: collisions.length,
|
||||
});
|
||||
if (choice === 'cancel') return null;
|
||||
if (choice === 'overwriteAll') {
|
||||
mode = 'all';
|
||||
finalTargets.push(target);
|
||||
} else if (choice === 'skipAll') {
|
||||
mode = 'none';
|
||||
} else if (choice === 'overwrite') {
|
||||
finalTargets.push(target);
|
||||
}
|
||||
}
|
||||
return finalTargets;
|
||||
}
|
||||
|
||||
async cancel(jobId: string): Promise<void> {
|
||||
await this.rpc.request.cancelTransfer({ jobId });
|
||||
}
|
||||
@@ -70,12 +137,19 @@ export class TransfersService {
|
||||
next.delete(jobId);
|
||||
return next;
|
||||
});
|
||||
this.speedTrack.delete(jobId);
|
||||
this.speeds.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));
|
||||
this.trackSpeed(snapshot);
|
||||
|
||||
const justFinished = previous?.state !== snapshot.state;
|
||||
if (!justFinished) return;
|
||||
@@ -97,4 +171,25 @@ export class TransfersService {
|
||||
if (panel.sessionId() === sessionId) void panel.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private trackSpeed(snapshot: TransferSnapshot): void {
|
||||
if (snapshot.state !== 'running') {
|
||||
if (snapshot.state !== 'queued' && snapshot.state !== 'scanning') {
|
||||
this.speedTrack.delete(snapshot.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const now = performance.now();
|
||||
const prev = this.speedTrack.get(snapshot.id);
|
||||
if (!prev) {
|
||||
this.speedTrack.set(snapshot.id, { bytes: snapshot.doneBytes, time: now, speed: 0 });
|
||||
return;
|
||||
}
|
||||
const dt = (now - prev.time) / 1000;
|
||||
if (dt < 0.2) return; // wait for a meaningful window
|
||||
const instant = Math.max(0, (snapshot.doneBytes - prev.bytes) / dt);
|
||||
const speed = prev.speed ? SPEED_SMOOTHING * prev.speed + (1 - SPEED_SMOOTHING) * instant : instant;
|
||||
this.speedTrack.set(snapshot.id, { bytes: snapshot.doneBytes, time: now, speed });
|
||||
this.speeds.update((map) => new Map(map).set(snapshot.id, speed));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user