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:
Jokin
2026-07-11 17:14:50 +02:00
co-authored by Claude Fable 5
parent 262f4de04d
commit 6048d5c814
19 changed files with 571 additions and 14 deletions
+97 -2
View File
@@ -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));
}
}