fix(settings): preserve speed limiter units

Persist the selected display unit for disabled limits, preserve in-progress fractional input, and keep exact KiB-backed preset values visible.\n\nNo linked issue.
This commit is contained in:
NimBold
2026-07-15 09:19:19 +03:30
parent b81e8391e1
commit c34c489aef
6 changed files with 122 additions and 26 deletions
+6
View File
@@ -2,6 +2,10 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use ts_rs::TS;
fn default_speed_limit_unit() -> String {
"MB/s".to_string()
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
@@ -264,6 +268,8 @@ pub struct PersistedSettings {
pub scheduler_last_start_key: String,
pub scheduler_last_stop_key: String,
pub last_custom_speed_limit_ki_b: u32,
#[serde(default = "default_speed_limit_unit")]
pub last_custom_speed_limit_unit: String,
pub per_server_connections: i32,
pub max_automatic_retries: i32,
pub show_notifications: bool,
+8
View File
@@ -266,6 +266,12 @@ fn validate_settings(settings: &mut PersistedSettings) {
settings.max_concurrent_downloads = settings.max_concurrent_downloads.min(12);
settings.per_server_connections = settings.per_server_connections.clamp(1, 16);
settings.max_automatic_retries = settings.max_automatic_retries.clamp(0, 10);
if !matches!(
settings.last_custom_speed_limit_unit.as_str(),
"KB/s" | "MB/s"
) {
settings.last_custom_speed_limit_unit = default_settings().last_custom_speed_limit_unit;
}
}
fn default_category_subfolders() -> HashMap<String, String> {
@@ -430,6 +436,7 @@ fn default_settings() -> PersistedSettings {
scheduler_last_start_key: String::new(),
scheduler_last_stop_key: String::new(),
last_custom_speed_limit_ki_b: 1024,
last_custom_speed_limit_unit: "MB/s".to_string(),
per_server_connections: 16,
max_automatic_retries: 3,
show_notifications: true,
@@ -532,6 +539,7 @@ mod tests {
assert_eq!(settings.max_concurrent_downloads, 5);
assert_eq!(settings.global_speed_limit, "512K");
assert_eq!(settings.last_custom_speed_limit_unit, "MB/s");
assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]);
assert!(!settings.logs_enabled);
assert!(!settings.scheduler.enabled);
+1 -1
View File
@@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab";
import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme";
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
+33
View File
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest';
import {
convertSpeedValue,
clampSpeedDisplayValue,
displayValueFromPresetBase,
formatSpeedLimitForStorage,
formatPresetValue,
parseLimit,
presetBaseFromDisplayValue,
} from './SpeedLimiterView';
@@ -20,4 +24,33 @@ describe('SpeedLimiterView speed conversions', () => {
expect(convertSpeedValue(1500, 'KB/s', 'MB/s')).toBe(1500 / 1024);
expect(convertSpeedValue(convertSpeedValue(1500, 'KB/s', 'MB/s'), 'MB/s', 'KB/s')).toBe(1500);
});
it('preserves the selected unit when saving a non-MiB-aligned MB/s value', () => {
const storedLimit = formatSpeedLimitForStorage(1.5, 'MB/s');
expect(storedLimit).toBe('1.5M');
expect(parseLimit(storedLimit, 1024)).toEqual({ value: 1.5, unit: 'MB/s' });
});
it('preserves an explicitly selected KB/s unit at MiB boundaries', () => {
const storedLimit = formatSpeedLimitForStorage(1024, 'KB/s');
expect(storedLimit).toBe('1024K');
expect(parseLimit(storedLimit, 1)).toEqual({ value: 1024, unit: 'KB/s' });
});
it('restores the persisted unit while the limiter is disabled', () => {
expect(parseLimit('', 1536, 'MB/s')).toEqual({ value: 1.5, unit: 'MB/s' });
expect(parseLimit('', 1536, 'KB/s')).toEqual({ value: 1536, unit: 'KB/s' });
});
it('allows sub-one MB/s values while enforcing the 1 KiB backend minimum', () => {
expect(clampSpeedDisplayValue(0.5, 'MB/s')).toBe(0.5);
expect(clampSpeedDisplayValue(0, 'MB/s')).toBe(1 / 1024);
expect(clampSpeedDisplayValue(0, 'KB/s')).toBe(1);
});
it('shows the exact stored preset value instead of a rounded label', () => {
expect(formatPresetValue(1.46484375)).toBe('1.46484375');
});
});
+65 -25
View File
@@ -25,17 +25,48 @@ export function convertSpeedValue(value: number, fromUnit: SpeedUnit, toUnit: Sp
return speedValueFromKiB(speedValueToKiB(value, fromUnit), toUnit);
}
export function parseLimit(limit: string, fallback: number): { value: number; unit: SpeedUnit } {
export function parseLimit(
limit: string,
fallback: number,
fallbackUnit: SpeedUnit = 'MB/s'
): { value: number; unit: SpeedUnit } {
const match = limit.trim().match(/^(\d+(?:\.\d+)?)\s*([km]?)b?(?:\/s)?$/i);
const suffix = match?.[2].toLowerCase();
const valueKiB = match
? speedValueToKiB(Number(match[1]) * (match[2].toLowerCase() === 'm' ? KIB_PER_MIB : 1), 'KB/s')
? speedValueToKiB(Number(match[1]) * (suffix === 'm' ? KIB_PER_MIB : 1), 'KB/s')
: speedValueToKiB(fallback, 'KB/s');
if (!match) {
return { value: speedValueFromKiB(valueKiB, fallbackUnit), unit: fallbackUnit };
}
if (suffix === 'm') {
return { value: speedValueFromKiB(valueKiB, 'MB/s'), unit: 'MB/s' };
}
if (suffix === 'k') {
return { value: valueKiB, unit: 'KB/s' };
}
return valueKiB >= 1024 && valueKiB % 1024 === 0
? { value: valueKiB / 1024, unit: 'MB/s' }
: { value: valueKiB, unit: 'KB/s' };
}
export function formatSpeedLimitForStorage(value: number, unit: SpeedUnit): string {
const valueKiB = speedValueToKiB(value, unit);
return unit === 'MB/s'
? `${speedValueFromKiB(valueKiB, 'MB/s')}M`
: `${valueKiB}K`;
}
export function clampSpeedDisplayValue(value: number, unit: SpeedUnit): number {
const numericValue = Number.isFinite(value) ? value : speedValueFromKiB(1, unit);
const minimum = speedValueFromKiB(1, unit);
const maximum = speedValueFromKiB(MAX_LIMIT_KIB, unit);
return Math.max(minimum, Math.min(maximum, numericValue));
}
function sanitizePresetValues(values: number[]): number[] {
const cleaned = values
.map(value => Number(value))
@@ -53,22 +84,25 @@ export function displayValueFromPresetBase(value: number, unit: SpeedUnit): numb
return speedValueFromKiB(speedValueToKiB(value, 'MB/s'), unit);
}
function formatPresetValue(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, '');
export function formatPresetValue(value: number): string {
return String(value);
}
export default function SpeedLimiterView() {
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
const lastCustomSpeedLimitKiB = useSettingsStore(state => state.lastCustomSpeedLimitKiB);
const lastCustomSpeedLimitUnit = useSettingsStore(state => state.lastCustomSpeedLimitUnit);
const speedLimitPresetValues = useSettingsStore(state => state.speedLimitPresetValues);
const setGlobalSpeedLimit = useSettingsStore(state => state.setGlobalSpeedLimit);
const setLastCustomSpeedLimitKiB = useSettingsStore(state => state.setLastCustomSpeedLimitKiB);
const setLastCustomSpeedLimitUnit = useSettingsStore(state => state.setLastCustomSpeedLimitUnit);
const setSpeedLimitPresetValues = useSettingsStore(state => state.setSpeedLimitPresetValues);
const initial = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
const fallbackUnit: SpeedUnit = lastCustomSpeedLimitUnit === 'KB/s' ? 'KB/s' : 'MB/s';
const initial = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB, fallbackUnit);
const [enabled, setEnabled] = useState(Boolean(globalSpeedLimit));
const [value, setValue] = useState(initial.value);
const [value, setValue] = useState(String(initial.value));
const [unit, setUnit] = useState<SpeedUnit>(initial.unit);
const [customPresetValue, setCustomPresetValue] = useState(initial.value);
const [customPresetValue, setCustomPresetValue] = useState(String(initial.value));
const { addToast } = useToast();
const savingRef = useRef(false);
const presetValues = useMemo(
@@ -77,12 +111,12 @@ export default function SpeedLimiterView() {
);
useEffect(() => {
const parsed = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
const parsed = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB, fallbackUnit);
setEnabled(Boolean(globalSpeedLimit));
setValue(parsed.value);
setValue(String(parsed.value));
setUnit(parsed.unit);
setCustomPresetValue(parsed.value);
}, [globalSpeedLimit, lastCustomSpeedLimitKiB]);
setCustomPresetValue(String(parsed.value));
}, [globalSpeedLimit, lastCustomSpeedLimitKiB, fallbackUnit]);
const [isSaving, setIsSaving] = useState(false);
@@ -90,12 +124,13 @@ export default function SpeedLimiterView() {
const save = async () => {
if (savingRef.current) return;
savingRef.current = true;
const numericValue = Math.max(1, Math.min(Number(value) || 1, unit === 'MB/s' ? 10240 : MAX_LIMIT_KIB));
const numericValue = clampSpeedDisplayValue(Number(value), unit);
const valueKiB = speedValueToKiB(numericValue, unit);
setIsSaving(true);
try {
await setGlobalSpeedLimit(enabled ? `${valueKiB}K` : '');
await setGlobalSpeedLimit(enabled ? formatSpeedLimitForStorage(numericValue, unit) : '');
setLastCustomSpeedLimitKiB(valueKiB);
setLastCustomSpeedLimitUnit(unit);
addToast({
message: enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled',
variant: 'success'
@@ -114,21 +149,22 @@ export default function SpeedLimiterView() {
const preset = (presetValue: number) => {
setEnabled(true);
setValue(displayValueFromPresetBase(presetValue, unit));
setValue(String(displayValueFromPresetBase(presetValue, unit)));
};
const applyCustomPreset = () => {
const numericValue = Math.max(1, Math.min(Number(customPresetValue) || 1, unit === 'MB/s' ? MAX_LIMIT_MB : MAX_LIMIT_KIB));
const numericValue = clampSpeedDisplayValue(Number(customPresetValue), unit);
const presetBaseValue = Math.min(MAX_LIMIT_MB, presetBaseFromDisplayValue(numericValue, unit));
const nextPresets = sanitizePresetValues([...presetValues, presetBaseValue]);
const alreadyExists = nextPresets.length === presetValues.length;
const storedPresetDisplayValue = displayValueFromPresetBase(presetBaseValue, unit);
setSpeedLimitPresetValues(nextPresets);
setEnabled(true);
setValue(numericValue);
setValue(String(storedPresetDisplayValue));
addToast({
message: alreadyExists
? `${formatPresetValue(numericValue)} ${unit} is already in quick presets`
: `Added ${formatPresetValue(numericValue)} ${unit} quick preset`,
? `${formatPresetValue(storedPresetDisplayValue)} ${unit} is already in quick presets`
: `Added ${formatPresetValue(storedPresetDisplayValue)} ${unit} quick preset`,
variant: alreadyExists ? 'info' : 'success'
});
};
@@ -145,11 +181,15 @@ export default function SpeedLimiterView() {
const changeUnit = (nextUnit: SpeedUnit) => {
if (nextUnit === unit) return;
setValue(convertSpeedValue(value, unit, nextUnit));
setCustomPresetValue(convertSpeedValue(customPresetValue, unit, nextUnit));
setValue(String(convertSpeedValue(Number(value), unit, nextUnit)));
setCustomPresetValue(String(convertSpeedValue(Number(customPresetValue), unit, nextUnit)));
setUnit(nextUnit);
};
const currentDisplayValue = Number.isFinite(Number(value)) && Number(value) > 0
? value
: String(speedValueFromKiB(1, unit));
return (
<div className="flex-1 flex h-full flex-col overflow-hidden bg-main-bg">
<WindowDragRegion />
@@ -172,7 +212,7 @@ export default function SpeedLimiterView() {
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
enabled ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
}`}>
{enabled ? `${value} ${unit}` : 'Unlimited'}
{enabled ? `${currentDisplayValue} ${unit}` : 'Unlimited'}
</span>
<button onClick={() => void save()} disabled={isSaving} className="app-button app-button-primary ml-auto px-3 text-[11px] disabled:opacity-50">
<Save size={14} /> Save Limit
@@ -191,11 +231,11 @@ export default function SpeedLimiterView() {
<div className="mt-6 flex items-center gap-3">
<input
type="number"
min="1"
min={speedValueFromKiB(1, unit)}
step="any"
value={value}
disabled={!enabled || isSaving}
onChange={event => setValue(Math.max(1, Number(event.target.value) || 1))}
onChange={event => setValue(event.target.value)}
className="app-control w-28 px-3 py-2 text-right font-mono"
/>
<div className="flex rounded-md border border-border-modal bg-bg-input p-1">
@@ -251,11 +291,11 @@ export default function SpeedLimiterView() {
<div className="ml-1 flex h-8 items-center gap-1.5 rounded-md border border-border-modal bg-bg-input px-2">
<input
type="number"
min="1"
min={speedValueFromKiB(1, unit)}
step="any"
value={customPresetValue}
disabled={!enabled || isSaving}
onChange={event => setCustomPresetValue(Math.max(1, Number(event.target.value) || 1))}
onChange={event => setCustomPresetValue(event.target.value)}
className="w-12 bg-transparent text-right font-mono text-[12px] text-text-primary outline-none disabled:opacity-50"
aria-label={`Custom preset in ${unit}`}
/>
+9
View File
@@ -135,6 +135,7 @@ export interface SettingsState {
schedulerLastStartKey: string;
schedulerLastStopKey: string;
lastCustomSpeedLimitKiB: number;
lastCustomSpeedLimitUnit: string;
// Replicated SwiftUI App Settings
perServerConnections: number;
@@ -175,6 +176,7 @@ export interface SettingsState {
setSchedulerLastStartKey: (key: string) => void;
setSchedulerLastStopKey: (key: string) => void;
setLastCustomSpeedLimitKiB: (limit: number) => void;
setLastCustomSpeedLimitUnit: (unit: string) => void;
toggleSidebar: () => void;
setPerServerConnections: (count: number) => void;
@@ -236,6 +238,7 @@ export const useSettingsStore = create<SettingsState>()(
schedulerLastStartKey: '',
schedulerLastStopKey: '',
lastCustomSpeedLimitKiB: 1024,
lastCustomSpeedLimitUnit: 'MB/s',
// Replicated SwiftUI defaults
perServerConnections: 16,
@@ -298,6 +301,7 @@ export const useSettingsStore = create<SettingsState>()(
setSchedulerLastStartKey: (schedulerLastStartKey) => set({ schedulerLastStartKey }),
setSchedulerLastStopKey: (schedulerLastStopKey) => set({ schedulerLastStopKey }),
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({ lastCustomSpeedLimitKiB }),
setLastCustomSpeedLimitUnit: (lastCustomSpeedLimitUnit) => set({ lastCustomSpeedLimitUnit }),
toggleSidebar: () => set((state) => ({ isSidebarVisible: !state.isSidebarVisible })),
setPerServerConnections: (perServerConnections) => set({
@@ -447,6 +451,7 @@ export const useSettingsStore = create<SettingsState>()(
schedulerLastStartKey: state.schedulerLastStartKey,
schedulerLastStopKey: state.schedulerLastStopKey,
lastCustomSpeedLimitKiB: state.lastCustomSpeedLimitKiB,
lastCustomSpeedLimitUnit: state.lastCustomSpeedLimitUnit,
perServerConnections: state.perServerConnections,
maxAutomaticRetries: state.maxAutomaticRetries,
@@ -538,6 +543,10 @@ export const useSettingsStore = create<SettingsState>()(
speedLimitPresetValues: Array.isArray(persisted.speedLimitPresetValues)
? persisted.speedLimitPresetValues
: currentState.speedLimitPresetValues,
lastCustomSpeedLimitUnit: persisted.lastCustomSpeedLimitUnit === 'KB/s'
|| persisted.lastCustomSpeedLimitUnit === 'MB/s'
? persisted.lastCustomSpeedLimitUnit
: currentState.lastCustomSpeedLimitUnit,
logsEnabled: persisted.logsEnabled === true,
approvedDownloadRoots: Array.isArray(persisted.approvedDownloadRoots)
? persisted.approvedDownloadRoots