From c95087c0797d8ee3db64498f57fb81383b62178c Mon Sep 17 00:00:00 2001 From: Jokin Date: Sat, 11 Jul 2026 17:32:21 +0200 Subject: [PATCH] Serial/parallel transfers setting Transfers: Single | Multi in the options popover, with an 'At once' picker (2/3/4/6/8) when Multi. Engine concurrency becomes an instance field applied live: at boot from settings.json and on every saveSettings (raising the limit starts queued jobs immediately). Verified headless: 3 slow jobs overlap at concurrency 3, stay strictly serial at 1, clamp works. Co-Authored-By: Claude Fable 5 --- src/bun/index.ts | 2 ++ src/bun/rpc-handlers.ts | 3 ++- src/bun/transfers/engine.ts | 11 +++++++--- src/shared/types.ts | 11 ++++++++++ ui/src/app/settings.service.ts | 16 ++++++++++++++ ui/src/app/tab-bar/tab-bar.ts | 40 ++++++++++++++++++++++++++++++++++ 6 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/bun/index.ts b/src/bun/index.ts index 8e6839e..859d1a3 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -1,5 +1,6 @@ import { BrowserWindow, Updater } from "electrobun/bun"; import { BRANDING } from "../shared/branding"; +import { effectiveConcurrency } from "../shared/types"; import { SessionRegistry } from "./fs/registry"; import { createAppRPC } from "./rpc-handlers"; import { ConnectionStore } from "./store/connections"; @@ -58,5 +59,6 @@ new BrowserWindow({ engine.onUpdate = (snapshot) => rpc.send.transferUpdate(snapshot); events.sessionClosed = (info) => rpc.send.sessionClosed(info); +void settings.load().then((s) => engine.setConcurrency(effectiveConcurrency(s))); console.log(`${BRANDING.appName} started (${url})`); diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts index 7c4ed2d..db21cdb 100644 --- a/src/bun/rpc-handlers.ts +++ b/src/bun/rpc-handlers.ts @@ -1,6 +1,6 @@ import { BrowserView } from "electrobun/bun"; import type { AppRPC } from "../shared/rpc"; -import type { Result } from "../shared/types"; +import { effectiveConcurrency, type Result } from "../shared/types"; import type { SessionRegistry } from "./fs/registry"; import { SftpProvider } from "./fs/sftp"; import type { ConnectionStore } from "./store/connections"; @@ -144,6 +144,7 @@ export function createAppRPC({ registry, engine, store, settings, events }: AppD saveSettings: ({ settings: next }) => wrap(async () => { await settings.save(next); + engine.setConcurrency(effectiveConcurrency(next)); return {}; }), }, diff --git a/src/bun/transfers/engine.ts b/src/bun/transfers/engine.ts index fe1de61..135e126 100644 --- a/src/bun/transfers/engine.ts +++ b/src/bun/transfers/engine.ts @@ -5,8 +5,6 @@ import type { FileSystemProvider } from "../fs/provider"; import type { SessionRegistry } from "../fs/registry"; import { walkTree } from "./walk"; -// One at a time keeps remote servers happy; bump for parallel jobs. -const TRANSFER_CONCURRENCY = 1; const PROGRESS_INTERVAL_MS = 100; interface Job { @@ -41,9 +39,16 @@ export class TransferEngine { private running = 0; private counter = 0; private lastEmit = new Map(); + // serial by default; the transfers setting raises it (jobs are independent) + private concurrency = 1; constructor(private registry: SessionRegistry) {} + setConcurrency(n: number): void { + this.concurrency = Math.max(1, Math.min(8, Math.round(n) || 1)); + this.pump(); // a raised limit starts queued jobs immediately + } + enqueue(params: { srcSessionId: string; items: { path: string; type: EntryType }[]; @@ -119,7 +124,7 @@ export class TransferEngine { } private pump(): void { - while (this.running < TRANSFER_CONCURRENCY && this.queue.length) { + while (this.running < this.concurrency && this.queue.length) { const job = this.queue.shift()!; this.running++; this.run(job) diff --git a/src/shared/types.ts b/src/shared/types.ts index 2110cdc..ed21254 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -46,14 +46,25 @@ export interface AppSettings { speedUnit: "MBps" | "Mbps"; /** Font size of the path bar and file list. */ textSize: "compact" | "default" | "large"; + /** Run transfer jobs one at a time, or several in parallel. */ + transferMode: "single" | "multi"; + /** How many jobs run at once when transferMode is "multi". */ + transferConcurrency: number; } export const DEFAULT_SETTINGS: AppSettings = { theme: "system", speedUnit: "MBps", textSize: "default", + transferMode: "single", + transferConcurrency: 3, }; +export function effectiveConcurrency(settings: AppSettings): number { + if (settings.transferMode === "single") return 1; + return Math.max(1, Math.min(8, Math.round(settings.transferConcurrency) || 1)); +} + export type TransferState = | "queued" | "scanning" diff --git a/ui/src/app/settings.service.ts b/ui/src/app/settings.service.ts index f1f8859..c7ad703 100644 --- a/ui/src/app/settings.service.ts +++ b/ui/src/app/settings.service.ts @@ -7,6 +7,8 @@ export class SettingsService { readonly theme = signal(DEFAULT_SETTINGS.theme); readonly speedUnit = signal(DEFAULT_SETTINGS.speedUnit); readonly textSize = signal(DEFAULT_SETTINGS.textSize); + readonly transferMode = signal(DEFAULT_SETTINGS.transferMode); + readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency); private readonly rpc = inject(RpcService); @@ -17,6 +19,8 @@ export class SettingsService { this.theme.set(res.settings.theme); this.speedUnit.set(res.settings.speedUnit); this.textSize.set(res.settings.textSize); + this.transferMode.set(res.settings.transferMode); + this.transferConcurrency.set(res.settings.transferConcurrency); this.applyTheme(); this.applyTextSize(); }); @@ -39,6 +43,16 @@ export class SettingsService { void this.persist(); } + setTransferMode(mode: AppSettings['transferMode']): void { + this.transferMode.set(mode); + void this.persist(); + } + + setTransferConcurrency(count: number): void { + this.transferConcurrency.set(count); + void this.persist(); + } + private applyTheme(): void { const theme = this.theme(); if (theme === 'system') { @@ -62,6 +76,8 @@ export class SettingsService { theme: this.theme(), speedUnit: this.speedUnit(), textSize: this.textSize(), + transferMode: this.transferMode(), + transferConcurrency: this.transferConcurrency(), }; } diff --git a/ui/src/app/tab-bar/tab-bar.ts b/ui/src/app/tab-bar/tab-bar.ts index 39fe739..406e951 100644 --- a/ui/src/app/tab-bar/tab-bar.ts +++ b/ui/src/app/tab-bar/tab-bar.ts @@ -10,6 +10,8 @@ const SPEED_UNITS = [ { value: 'Mbps', label: 'Mbps' }, ] as const; const TEXT_SIZES = ['compact', 'default', 'large'] as const; +const TRANSFER_MODES = ['single', 'multi'] as const; +const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; @Component({ selector: 'app-tab-bar', @@ -136,6 +138,42 @@ const TEXT_SIZES = ['compact', 'default', 'large'] as const; } + +
+ Transfers +
+ @for (mode of transferModes; track mode) { + + } +
+
+ + @if (settings.transferMode() === 'multi') { +
+ At once +
+ @for (count of concurrencyChoices; track count) { + + } +
+
+ } } @@ -149,6 +187,8 @@ export class TabBar { readonly themes = THEMES; readonly speedUnits = SPEED_UNITS; readonly textSizes = TEXT_SIZES; + readonly transferModes = TRANSFER_MODES; + readonly concurrencyChoices = CONCURRENCY_CHOICES; close(event: MouseEvent, id: number): void { event.stopPropagation();