diff --git a/README.md b/README.md index d9ed03c..dce387b 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,14 @@ remote server over SSH/SFTP, so you can move files **local↔remote**, sessions stay connected and transfers keep running in background tabs. - Per-panel action toolbar with a direction-explicit copy button, plus a VSCode-style status bar showing live transfer progress (click to expand the - transfer queue). + transfer queue with per-file speed and ETA). +- **Overwrite confirmation** on name collisions, with a source-vs-destination + comparison (size, modified date, which is newer) and batch replace-all / + skip-all. +- **Settings** (gear, top-right): light / dark / system theme and speed units + (MB/s or Mbps), persisted across restarts. +- On macOS the tab bar is fused with the window titlebar; other platforms keep + a standard titlebar. - Browse, sort (name/size/modified, folders first), keyboard navigation. - Create folder / file, rename, delete (recursive), change permissions (chmod). - Upload, download, and remote→remote copy — files **and** whole folders — @@ -129,6 +136,17 @@ Saved connections live in a plain JSON file in the OS app-data directory (On Macs, function keys may need `fn` — every action also has a toolbar button.) +## Known issues + +- **Rare native crash under load.** A long-running session (multiple remote + connections, extended uptime) can hit a Bun panic — a segfault inside Bun + 1.3.13's FFI thread-safe callbacks executing Electrobun's native→worker + bridge (`FFI_Callback_threadsafe_call` → `web_worker.spin` in the decoded + crash report). This is an upstream Bun × Electrobun bug, not app code; a + restart recovers cleanly and in-flight work isn't corrupted. Expected to + resolve when Electrobun bumps its pinned Bun (currently 1.3.13; 1.3.14+ + available). Bun's own crash reporter link is printed to the console on panic. + ## Not in v1 Drag-and-drop, file preview/edit, transfer resume, symlink following diff --git a/package.json b/package.json index 1e54c5e..e6601b5 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "devDependencies": { "@types/bun": "latest", "@types/ssh2": "^1.15.5", - "typescript": "~5.9.0" + "typescript": "~5.9.3" }, "trustedDependencies": ["@parcel/watcher"] } diff --git a/src/bun/index.ts b/src/bun/index.ts index 4e85879..8e6839e 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -3,6 +3,7 @@ import { BRANDING } from "../shared/branding"; import { SessionRegistry } from "./fs/registry"; import { createAppRPC } from "./rpc-handlers"; import { ConnectionStore } from "./store/connections"; +import { SettingsStore } from "./store/settings"; import { TransferEngine } from "./transfers/engine"; const PROD_URL = "views://app/index.html"; @@ -28,10 +29,13 @@ async function getMainViewUrl(): Promise { const registry = new SessionRegistry(); const engine = new TransferEngine(registry); const store = new ConnectionStore(); +const settings = new SettingsStore(); const events = { sessionClosed: (_info: { sessionId: string; reason: string }) => {} }; -const rpc = createAppRPC({ registry, engine, store, events }); +const rpc = createAppRPC({ registry, engine, store, settings, events }); const url = await getMainViewUrl(); +const isMac = process.platform === "darwin"; + new BrowserWindow({ title: BRANDING.appName, url, @@ -41,6 +45,14 @@ new BrowserWindow({ x: 120, y: 120, }, + // macOS: fuse the tab bar with the titlebar — traffic lights overlay the + // UI's h-10 tab row (offset centers them vertically). Other platforms keep + // the native titlebar and the tab bar stays a separate row. + ...(isMac + ? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } } + : {}), + // lets the UI pick the fused layout before first paint + preload: `window.__BUNSCP_PLATFORM=${JSON.stringify(process.platform)};`, rpc, }); diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts index 0e2c1f3..7c4ed2d 100644 --- a/src/bun/rpc-handlers.ts +++ b/src/bun/rpc-handlers.ts @@ -4,6 +4,7 @@ import type { Result } from "../shared/types"; import type { SessionRegistry } from "./fs/registry"; import { SftpProvider } from "./fs/sftp"; import type { ConnectionStore } from "./store/connections"; +import type { SettingsStore } from "./store/settings"; import type { TransferEngine } from "./transfers/engine"; import { removeRecursive } from "./transfers/walk"; @@ -15,6 +16,7 @@ export interface AppDeps { registry: SessionRegistry; engine: TransferEngine; store: ConnectionStore; + settings: SettingsStore; /** Assigned after the rpc object exists (it needs rpc.send). */ events: { sessionClosed: (info: { sessionId: string; reason: string }) => void }; } @@ -27,7 +29,7 @@ async function wrap(fn: () => Promise | T): Promise({ maxRequestTime: MAX_REQUEST_TIME_MS, handlers: { @@ -135,6 +137,15 @@ export function createAppRPC({ registry, engine, store, events }: AppDeps) { await store.deleteFolder(id); return {}; }), + + // ---- settings ---- + getSettings: () => wrap(async () => ({ settings: await settings.load() })), + + saveSettings: ({ settings: next }) => + wrap(async () => { + await settings.save(next); + return {}; + }), }, }, }); diff --git a/src/bun/store/settings.ts b/src/bun/store/settings.ts new file mode 100644 index 0000000..efbac68 --- /dev/null +++ b/src/bun/store/settings.ts @@ -0,0 +1,25 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { DEFAULT_SETTINGS, type AppSettings } from "../../shared/types"; +import { appDataDir } from "./paths"; + +/** App settings persisted bun-side (WKWebView localStorage on views:// is unreliable). */ +export class SettingsStore { + private readonly file = path.join(appDataDir(), "settings.json"); + + async load(): Promise { + try { + const data = JSON.parse(await fs.readFile(this.file, "utf8")); + return { ...DEFAULT_SETTINGS, ...data }; + } catch { + return { ...DEFAULT_SETTINGS }; + } + } + + async save(settings: AppSettings): Promise { + await fs.mkdir(path.dirname(this.file), { recursive: true }); + const tmp = `${this.file}.tmp`; + await fs.writeFile(tmp, JSON.stringify(settings, null, "\t"), { mode: 0o600 }); + await fs.rename(tmp, this.file); + } +} diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index 8b57460..a366c25 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -1,4 +1,5 @@ import type { + AppSettings, ConnFolder, ConnectionConfig, ConnectionsFile, @@ -67,6 +68,12 @@ export type AppRPC = { deleteConnection: { params: { id: string }; response: Result }; upsertFolder: { params: { folder: ConnFolder }; response: Result<{ id: string }> }; deleteFolder: { params: { id: string }; response: Result }; + // settings + getSettings: { + params: Record; + response: Result<{ settings: AppSettings }>; + }; + saveSettings: { params: { settings: AppSettings }; response: Result }; }; messages: Record; }; diff --git a/src/shared/types.ts b/src/shared/types.ts index 214da69..d1ef074 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -41,6 +41,16 @@ export interface ConnectionsFile { connections: SavedConnection[]; } +export interface AppSettings { + theme: "system" | "light" | "dark"; + speedUnit: "MBps" | "Mbps"; +} + +export const DEFAULT_SETTINGS: AppSettings = { + theme: "system", + speedUnit: "MBps", +}; + export type TransferState = | "queued" | "scanning" diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 304b462..217d323 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -34,6 +34,9 @@ @if (dialogs.chmod(); as req) { } + @if (dialogs.overwrite(); as req) { + + }
diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 24eea7a..46f36a5 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -4,6 +4,7 @@ import { BRANDING } from '@shared/branding'; import { ConnectionManager } from './connection-manager/connection-manager'; import { ChmodDialog } from './dialogs/chmod-dialog'; import { ConfirmDialog } from './dialogs/confirm-dialog'; +import { OverwriteDialog } from './dialogs/overwrite-dialog'; import { PromptDialog } from './dialogs/prompt-dialog'; import { DialogsService } from './dialogs.service'; import { FilePanel } from './file-panel/file-panel'; @@ -24,6 +25,7 @@ import { TransferBar } from './transfer-bar/transfer-bar'; PromptDialog, ConfirmDialog, ChmodDialog, + OverwriteDialog, TransferBar, ConnectionManager, ], diff --git a/ui/src/app/dialogs.service.ts b/ui/src/app/dialogs.service.ts index 96c0b7a..b0b91d6 100644 --- a/ui/src/app/dialogs.service.ts +++ b/ui/src/app/dialogs.service.ts @@ -1,4 +1,15 @@ import { Injectable, signal } from '@angular/core'; +import type { FileEntry } from '@shared/types'; + +export type OverwriteChoice = 'overwrite' | 'skip' | 'overwriteAll' | 'skipAll' | 'cancel'; + +export interface OverwriteRequest { + srcEntry: FileEntry; + dstEntry: FileEntry; + index: number; // 1-based position within the colliding items + total: number; // number of colliding items in this batch + resolve: (choice: OverwriteChoice) => void; +} export interface PromptRequest { title: string; @@ -27,8 +38,16 @@ export class DialogsService { readonly prompt = signal(null); readonly confirm = signal(null); readonly chmod = signal(null); + readonly overwrite = signal(null); readonly manager = signal(false); + /** Ask what to do about one name collision; resolves with the user's choice. */ + askOverwrite(request: Omit): Promise { + return new Promise((resolve) => { + this.overwrite.set({ ...request, resolve }); + }); + } + get anyOpen(): boolean { return !!(this.prompt() || this.confirm() || this.chmod()); } diff --git a/ui/src/app/dialogs/overwrite-dialog.ts b/ui/src/app/dialogs/overwrite-dialog.ts new file mode 100644 index 0000000..c731d46 --- /dev/null +++ b/ui/src/app/dialogs/overwrite-dialog.ts @@ -0,0 +1,151 @@ +import { + Component, + ElementRef, + afterNextRender, + computed, + inject, + input, + viewChild, +} from '@angular/core'; +import { NgIcon } from '@ng-icons/core'; +import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '../dialogs.service'; +import { formatDate, formatSize } from '../format'; +import { PanelsService } from '../panels.service'; + +@Component({ + selector: 'app-overwrite-dialog', + imports: [NgIcon], + template: ` +
+
+
+

+ {{ isDir() ? 'Merge folder' : 'Replace' }} + "{{ request().srcEntry.name }}"? +

+ @if (request().total > 1) { + + {{ request().index }} of {{ request().total }} + + } +
+

+ @if (isDir()) { + The destination already has a folder with this name. Merging may replace files inside + it. + } @else { + The destination already has a file with this name. + } +

+ + +
+ @for (side of comparison(); track side.label) { +
+
+ + {{ side.label }} + + @if (side.newer) { + + newer + + } +
+
+ + + {{ isDir() ? '—' : formatSize(side.entry.size) }} + +
+
+ {{ formatDate(side.entry.mtime) }} +
+
+ } +
+ + +
+ +
+ @if (request().total > 1) { + + +
+ } + + +
+
+
+ `, +}) +export class OverwriteDialog { + readonly request = input.required(); + + readonly formatSize = formatSize; + readonly formatDate = formatDate; + + private readonly dialogs = inject(DialogsService); + private readonly panels = inject(PanelsService); + private readonly primaryBtn = viewChild.required>('primaryBtn'); + + constructor() { + afterNextRender(() => this.primaryBtn().nativeElement.focus()); + } + + readonly isDir = computed(() => this.request().srcEntry.type === 'dir'); + + readonly comparison = computed(() => { + const { srcEntry, dstEntry } = this.request(); + return [ + { label: 'Source', entry: srcEntry, newer: srcEntry.mtime > dstEntry.mtime }, + { label: 'Destination', entry: dstEntry, newer: dstEntry.mtime > srcEntry.mtime }, + ]; + }); + + pick(choice: OverwriteChoice): void { + const req = this.request(); + this.dialogs.overwrite.set(null); + this.panels.refocus(); + req.resolve(choice); + } +} diff --git a/ui/src/app/format.ts b/ui/src/app/format.ts index 2a01a30..56611cf 100644 --- a/ui/src/app/format.ts +++ b/ui/src/app/format.ts @@ -23,6 +23,25 @@ export function formatDate(epochMs: number): string { return DATE_FORMAT.format(new Date(epochMs)); } +/** bytes/sec -> "14.2 MB/s" or "113.8 Mbps" depending on the configured unit. */ +export function formatSpeed(bytesPerSec: number, unit: 'MBps' | 'Mbps'): string { + if (unit === 'Mbps') { + return `${((bytesPerSec * 8) / 1_000_000).toFixed(1)} Mbps`; + } + return `${formatSize(bytesPerSec)}/s`; +} + +/** Seconds -> "0:42" / "3:07" / "1:02:15". */ +export function formatEta(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) return ''; + const s = Math.round(seconds); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + const mmss = `${m}:${String(sec).padStart(2, '0')}`; + return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : mmss; +} + /** POSIX-style join for UI-composed paths (both panels use "/" on macOS + remote). */ export function joinPath(dir: string, name: string): string { return dir.endsWith('/') ? dir + name : `${dir}/${name}`; diff --git a/ui/src/app/platform.ts b/ui/src/app/platform.ts new file mode 100644 index 0000000..20c1b40 --- /dev/null +++ b/ui/src/app/platform.ts @@ -0,0 +1,11 @@ +// Injected by the bun process via BrowserWindow preload before any page script. +declare global { + interface Window { + __BUNSCP_PLATFORM?: string; + } +} + +export const PLATFORM = typeof window !== 'undefined' ? (window.__BUNSCP_PLATFORM ?? '') : ''; + +/** True when the window uses macOS hiddenInset chrome (tab bar fused with titlebar). */ +export const IS_MAC_CHROME = PLATFORM === 'darwin'; diff --git a/ui/src/app/settings.service.ts b/ui/src/app/settings.service.ts new file mode 100644 index 0000000..0608a13 --- /dev/null +++ b/ui/src/app/settings.service.ts @@ -0,0 +1,47 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { DEFAULT_SETTINGS, type AppSettings } from '@shared/types'; +import { RpcService } from './rpc.service'; + +@Injectable({ providedIn: 'root' }) +export class SettingsService { + readonly theme = signal(DEFAULT_SETTINGS.theme); + readonly speedUnit = signal(DEFAULT_SETTINGS.speedUnit); + + private readonly rpc = inject(RpcService); + + constructor() { + if (!this.rpc.available) return; + void this.rpc.request.getSettings({}).then((res) => { + if (!res.ok) return; + this.theme.set(res.settings.theme); + this.speedUnit.set(res.settings.speedUnit); + this.applyTheme(); + }); + } + + setTheme(theme: AppSettings['theme']): void { + this.theme.set(theme); + this.applyTheme(); + void this.persist(); + } + + setSpeedUnit(unit: AppSettings['speedUnit']): void { + this.speedUnit.set(unit); + void this.persist(); + } + + private applyTheme(): void { + const theme = this.theme(); + if (theme === 'system') { + delete document.documentElement.dataset['theme']; + } else { + document.documentElement.dataset['theme'] = theme; + } + } + + private async persist(): Promise { + await this.rpc.request.saveSettings({ + settings: { theme: this.theme(), speedUnit: this.speedUnit() }, + }); + } +} diff --git a/ui/src/app/status-bar/status-bar.ts b/ui/src/app/status-bar/status-bar.ts index 102d384..5e8f8fe 100644 --- a/ui/src/app/status-bar/status-bar.ts +++ b/ui/src/app/status-bar/status-bar.ts @@ -1,7 +1,9 @@ import { Component, computed, inject } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import type { TransferSnapshot } from '@shared/types'; +import { formatSpeed } from '../format'; import { PanelsService } from '../panels.service'; +import { SettingsService } from '../settings.service'; import { TransfersService } from '../transfers.service'; @Component({ @@ -20,7 +22,7 @@ import { TransfersService } from '../transfers.service'; @if (transfers.currentJob(); as job) { - Copying {{ job.name }} · {{ percent(job) }}% + Copying {{ job.name }} · {{ percent(job) }}%{{ speedSuffix(job) }} @if (transfers.queuedCount(); as queued) { · {{ queued }} queued } @@ -57,10 +59,16 @@ import { TransfersService } from '../transfers.service'; export class StatusBar { readonly transfers = inject(TransfersService); readonly panels = inject(PanelsService); + readonly settings = inject(SettingsService); readonly activePanel = computed(() => this.panels.get(this.panels.activeSide())); percent(job: TransferSnapshot): number { if (!job.totalBytes) return 0; return Math.min(100, Math.round((job.doneBytes / job.totalBytes) * 100)); } + + speedSuffix(job: TransferSnapshot): string { + const speed = this.transfers.speeds().get(job.id); + return speed ? ` · ${formatSpeed(speed, this.settings.speedUnit())}` : ''; + } } diff --git a/ui/src/app/tab-bar/tab-bar.ts b/ui/src/app/tab-bar/tab-bar.ts index d643462..a5c0faf 100644 --- a/ui/src/app/tab-bar/tab-bar.ts +++ b/ui/src/app/tab-bar/tab-bar.ts @@ -1,15 +1,31 @@ -import { Component, inject } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import { PanelsService } from '../panels.service'; +import { IS_MAC_CHROME } from '../platform'; +import { SettingsService } from '../settings.service'; + +const THEMES = ['system', 'light', 'dark'] as const; +const SPEED_UNITS = [ + { value: 'MBps', label: 'MB/s' }, + { value: 'Mbps', label: 'Mbps' }, +] as const; @Component({ selector: 'app-tab-bar', imports: [NgIcon], template: ` -
+ +
@for (ws of panels.workspaces(); track ws.id) {
+ +
+ + + + + @if (settingsOpen()) { +
+
+

Options

+ +
+ Theme +
+ @for (theme of themes; track theme) { + + } +
+
+ +
+ Speed unit +
+ @for (unit of speedUnits; track unit.value) { + + } +
+
+
+ }
`, }) export class TabBar { readonly panels = inject(PanelsService); + readonly settings = inject(SettingsService); + readonly settingsOpen = signal(false); + readonly isMacChrome = IS_MAC_CHROME; + readonly themes = THEMES; + readonly speedUnits = SPEED_UNITS; close(event: MouseEvent, id: number): void { event.stopPropagation(); diff --git a/ui/src/app/transfer-bar/transfer-bar.ts b/ui/src/app/transfer-bar/transfer-bar.ts index c966db5..1a09873 100644 --- a/ui/src/app/transfer-bar/transfer-bar.ts +++ b/ui/src/app/transfer-bar/transfer-bar.ts @@ -1,7 +1,8 @@ import { Component, inject } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import type { TransferSnapshot } from '@shared/types'; -import { formatSize } from '../format'; +import { formatEta, formatSize, formatSpeed } from '../format'; +import { SettingsService } from '../settings.service'; import { TransfersService } from '../transfers.service'; @Component({ @@ -11,8 +12,20 @@ import { TransfersService } from '../transfers.service'; }) export class TransferBar { readonly transfers = inject(TransfersService); + readonly settings = inject(SettingsService); readonly formatSize = formatSize; + /** "14.2 MB/s · 0:42 left" while a job is moving bytes. */ + speedLabel(job: TransferSnapshot): string { + if (job.state !== 'running') return ''; + const speed = this.transfers.speeds().get(job.id); + if (!speed) return ''; + const parts = [formatSpeed(speed, this.settings.speedUnit())]; + const remaining = job.totalBytes - job.doneBytes; + if (remaining > 0 && speed > 0) parts.push(`${formatEta(remaining / speed)} left`); + return parts.join(' · '); + } + stateIcon(job: TransferSnapshot): string { switch (job.state) { case 'completed': @@ -54,6 +67,8 @@ export class TransferBar { 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)}`; + const speed = this.speedLabel(job); + const base = `${job.currentFile} · ${formatSize(job.doneBytes)} / ${formatSize(job.totalBytes)}`; + return speed ? `${base} · ${speed}` : base; } } diff --git a/ui/src/app/transfers.service.ts b/ui/src/app/transfers.service.ts index 842a939..2e91bcf 100644 --- a/ui/src/app/transfers.service.ts +++ b/ui/src/app/transfers.service.ts @@ -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>(new Map()); + /** Smoothed bytes/sec per running job (UI-computed from progress deltas). */ + readonly speeds = signal>(new Map()); + private speedTrack = new Map(); 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 { + 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 { 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)); + } } diff --git a/ui/src/styles.css b/ui/src/styles.css index 4952f3a..637300f 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -24,8 +24,31 @@ --radius: 0.5rem; } +/* Dark tokens apply when the user forces dark, or follows a dark system theme. + (data-theme is set by SettingsService; absent = follow the system.) */ +:root[data-theme='dark'] { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --destructive-foreground: oklch(0.985 0 0); + --success: oklch(0.696 0.17 149.2); + --border: oklch(1 0 0 / 12%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); +} + @media (prefers-color-scheme: dark) { - :root { + :root:not([data-theme='light']) { --background: oklch(0.141 0.005 285.823); --foreground: oklch(0.985 0 0); --card: oklch(0.21 0.006 285.885); @@ -47,6 +70,12 @@ } } +@media (prefers-color-scheme: dark) { + :root[data-theme='light'] { + color-scheme: light; + } +} + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground);