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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { BrowserWindow, Updater } from "electrobun/bun";
|
import { BrowserWindow, Updater } from "electrobun/bun";
|
||||||
import { BRANDING } from "../shared/branding";
|
import { BRANDING } from "../shared/branding";
|
||||||
|
import { effectiveConcurrency } from "../shared/types";
|
||||||
import { SessionRegistry } from "./fs/registry";
|
import { SessionRegistry } from "./fs/registry";
|
||||||
import { createAppRPC } from "./rpc-handlers";
|
import { createAppRPC } from "./rpc-handlers";
|
||||||
import { ConnectionStore } from "./store/connections";
|
import { ConnectionStore } from "./store/connections";
|
||||||
@@ -58,5 +59,6 @@ new BrowserWindow({
|
|||||||
|
|
||||||
engine.onUpdate = (snapshot) => rpc.send.transferUpdate(snapshot);
|
engine.onUpdate = (snapshot) => rpc.send.transferUpdate(snapshot);
|
||||||
events.sessionClosed = (info) => rpc.send.sessionClosed(info);
|
events.sessionClosed = (info) => rpc.send.sessionClosed(info);
|
||||||
|
void settings.load().then((s) => engine.setConcurrency(effectiveConcurrency(s)));
|
||||||
|
|
||||||
console.log(`${BRANDING.appName} started (${url})`);
|
console.log(`${BRANDING.appName} started (${url})`);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BrowserView } from "electrobun/bun";
|
import { BrowserView } from "electrobun/bun";
|
||||||
import type { AppRPC } from "../shared/rpc";
|
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 type { SessionRegistry } from "./fs/registry";
|
||||||
import { SftpProvider } from "./fs/sftp";
|
import { SftpProvider } from "./fs/sftp";
|
||||||
import type { ConnectionStore } from "./store/connections";
|
import type { ConnectionStore } from "./store/connections";
|
||||||
@@ -144,6 +144,7 @@ export function createAppRPC({ registry, engine, store, settings, events }: AppD
|
|||||||
saveSettings: ({ settings: next }) =>
|
saveSettings: ({ settings: next }) =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
await settings.save(next);
|
await settings.save(next);
|
||||||
|
engine.setConcurrency(effectiveConcurrency(next));
|
||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import type { FileSystemProvider } from "../fs/provider";
|
|||||||
import type { SessionRegistry } from "../fs/registry";
|
import type { SessionRegistry } from "../fs/registry";
|
||||||
import { walkTree } from "./walk";
|
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;
|
const PROGRESS_INTERVAL_MS = 100;
|
||||||
|
|
||||||
interface Job {
|
interface Job {
|
||||||
@@ -41,9 +39,16 @@ export class TransferEngine {
|
|||||||
private running = 0;
|
private running = 0;
|
||||||
private counter = 0;
|
private counter = 0;
|
||||||
private lastEmit = new Map<string, number>();
|
private lastEmit = new Map<string, number>();
|
||||||
|
// serial by default; the transfers setting raises it (jobs are independent)
|
||||||
|
private concurrency = 1;
|
||||||
|
|
||||||
constructor(private registry: SessionRegistry) {}
|
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: {
|
enqueue(params: {
|
||||||
srcSessionId: string;
|
srcSessionId: string;
|
||||||
items: { path: string; type: EntryType }[];
|
items: { path: string; type: EntryType }[];
|
||||||
@@ -119,7 +124,7 @@ export class TransferEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private pump(): void {
|
private pump(): void {
|
||||||
while (this.running < TRANSFER_CONCURRENCY && this.queue.length) {
|
while (this.running < this.concurrency && this.queue.length) {
|
||||||
const job = this.queue.shift()!;
|
const job = this.queue.shift()!;
|
||||||
this.running++;
|
this.running++;
|
||||||
this.run(job)
|
this.run(job)
|
||||||
|
|||||||
@@ -46,14 +46,25 @@ export interface AppSettings {
|
|||||||
speedUnit: "MBps" | "Mbps";
|
speedUnit: "MBps" | "Mbps";
|
||||||
/** Font size of the path bar and file list. */
|
/** Font size of the path bar and file list. */
|
||||||
textSize: "compact" | "default" | "large";
|
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 = {
|
export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
theme: "system",
|
theme: "system",
|
||||||
speedUnit: "MBps",
|
speedUnit: "MBps",
|
||||||
textSize: "default",
|
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 =
|
export type TransferState =
|
||||||
| "queued"
|
| "queued"
|
||||||
| "scanning"
|
| "scanning"
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export class SettingsService {
|
|||||||
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
|
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
|
||||||
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
|
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
|
||||||
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 transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency);
|
||||||
|
|
||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
|
|
||||||
@@ -17,6 +19,8 @@ export class SettingsService {
|
|||||||
this.theme.set(res.settings.theme);
|
this.theme.set(res.settings.theme);
|
||||||
this.speedUnit.set(res.settings.speedUnit);
|
this.speedUnit.set(res.settings.speedUnit);
|
||||||
this.textSize.set(res.settings.textSize);
|
this.textSize.set(res.settings.textSize);
|
||||||
|
this.transferMode.set(res.settings.transferMode);
|
||||||
|
this.transferConcurrency.set(res.settings.transferConcurrency);
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
this.applyTextSize();
|
this.applyTextSize();
|
||||||
});
|
});
|
||||||
@@ -39,6 +43,16 @@ export class SettingsService {
|
|||||||
void this.persist();
|
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 {
|
private applyTheme(): void {
|
||||||
const theme = this.theme();
|
const theme = this.theme();
|
||||||
if (theme === 'system') {
|
if (theme === 'system') {
|
||||||
@@ -62,6 +76,8 @@ export class SettingsService {
|
|||||||
theme: this.theme(),
|
theme: this.theme(),
|
||||||
speedUnit: this.speedUnit(),
|
speedUnit: this.speedUnit(),
|
||||||
textSize: this.textSize(),
|
textSize: this.textSize(),
|
||||||
|
transferMode: this.transferMode(),
|
||||||
|
transferConcurrency: this.transferConcurrency(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const SPEED_UNITS = [
|
|||||||
{ value: 'Mbps', label: 'Mbps' },
|
{ value: 'Mbps', label: 'Mbps' },
|
||||||
] as const;
|
] as const;
|
||||||
const TEXT_SIZES = ['compact', 'default', 'large'] 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({
|
@Component({
|
||||||
selector: 'app-tab-bar',
|
selector: 'app-tab-bar',
|
||||||
@@ -136,6 +138,42 @@ const TEXT_SIZES = ['compact', 'default', 'large'] 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">Transfers</span>
|
||||||
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
|
@for (mode of transferModes; track mode) {
|
||||||
|
<button
|
||||||
|
(click)="settings.setTransferMode(mode)"
|
||||||
|
class="rounded px-2 py-0.5 text-[11px] capitalize"
|
||||||
|
[class.bg-accent]="settings.transferMode() === mode"
|
||||||
|
[class.text-foreground]="settings.transferMode() === mode"
|
||||||
|
[class.text-muted-foreground]="settings.transferMode() !== mode"
|
||||||
|
>
|
||||||
|
{{ mode }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (settings.transferMode() === 'multi') {
|
||||||
|
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
|
||||||
|
<span class="text-xs text-muted-foreground">At once</span>
|
||||||
|
<div class="flex rounded-md border border-border p-0.5">
|
||||||
|
@for (count of concurrencyChoices; track count) {
|
||||||
|
<button
|
||||||
|
(click)="settings.setTransferConcurrency(count)"
|
||||||
|
class="rounded px-2 py-0.5 text-[11px] tabular-nums"
|
||||||
|
[class.bg-accent]="settings.transferConcurrency() === count"
|
||||||
|
[class.text-foreground]="settings.transferConcurrency() === count"
|
||||||
|
[class.text-muted-foreground]="settings.transferConcurrency() !== count"
|
||||||
|
>
|
||||||
|
{{ count }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -149,6 +187,8 @@ export class TabBar {
|
|||||||
readonly themes = THEMES;
|
readonly themes = THEMES;
|
||||||
readonly speedUnits = SPEED_UNITS;
|
readonly speedUnits = SPEED_UNITS;
|
||||||
readonly textSizes = TEXT_SIZES;
|
readonly textSizes = TEXT_SIZES;
|
||||||
|
readonly transferModes = TRANSFER_MODES;
|
||||||
|
readonly concurrencyChoices = CONCURRENCY_CHOICES;
|
||||||
|
|
||||||
close(event: MouseEvent, id: number): void {
|
close(event: MouseEvent, id: number): void {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|||||||
Reference in New Issue
Block a user