fix(settings): add category subfolders toggle

Add a Settings > Locations toggle that disables automatic category subfolders while keeping the selected base download folder as the automatic destination.

Persist the setting across frontend and backend startup paths and disable the category subfolder controls when the feature is off.

Fixes #6
This commit is contained in:
NimBold
2026-07-06 23:37:03 +03:30
parent 036ff02dac
commit 85ad9d18e8
9 changed files with 123 additions and 25 deletions
+3
View File
@@ -168,6 +168,9 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
}
let category_destination = settings.as_ref().map(|settings| {
if !settings.category_subfolders_enabled {
return settings.base_download_folder.clone();
}
settings
.category_directory_overrides
.get(&category)
+1
View File
@@ -246,6 +246,7 @@ pub struct SchedulerSettings {
pub struct PersistedSettings {
pub theme: Theme,
pub base_download_folder: String,
pub category_subfolders_enabled: bool,
pub category_subfolders: HashMap<String, String>,
pub category_directory_overrides: HashMap<String, String>,
pub approved_download_roots: Vec<String>,
+24
View File
@@ -236,6 +236,9 @@ fn migrate_location_settings(state: &mut Value) -> Result<(), String> {
}
state.insert("baseDownloadFolder".to_string(), Value::String(base));
state
.entry("categorySubfoldersEnabled".to_string())
.or_insert(Value::Bool(true));
state.insert(
"categorySubfolders".to_string(),
serde_json::to_value(subfolders)
@@ -268,6 +271,7 @@ fn default_settings() -> PersistedSettings {
PersistedSettings {
theme: Theme::System,
base_download_folder: "~/Downloads".to_string(),
category_subfolders_enabled: true,
category_subfolders: default_category_subfolders(),
category_directory_overrides: HashMap::new(),
approved_download_roots: Vec::new(),
@@ -378,6 +382,7 @@ mod tests {
vec!["00000000-0000-0000-0000-000000000001"]
);
assert_eq!(settings.base_download_folder, "~/Downloads");
assert!(settings.category_subfolders_enabled);
}
#[test]
@@ -475,6 +480,25 @@ mod tests {
assert_eq!(settings.category_subfolders["Documents"], "Documents");
}
#[test]
fn decodes_disabled_category_subfolders() {
let stored = json!({
"state": {
"baseDownloadFolder": "/Users/test/Downloads",
"categorySubfoldersEnabled": false,
"categorySubfolders": {
"Movies": "Movies"
}
},
"version": 3
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert!(!settings.category_subfolders_enabled);
assert_eq!(settings.category_subfolders["Movies"], "Movies");
}
#[test]
fn replaces_zero_concurrency_with_the_safe_default() {
let stored = json!({"state": {"maxConcurrentDownloads": 0}, "version": 0});
+1 -1
View File
@@ -8,7 +8,7 @@ import type { SettingsTab } from "./SettingsTab";
import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme";
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, 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>,
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>,
/**
* HMAC shared secret for the browser extension. It is persisted in the
* settings database so that startup never needs to touch the OS keychain.
+57 -22
View File
@@ -168,11 +168,13 @@ const runEngineStatusCheck = (check: EngineCheck, force: boolean) => {
const CategoryFolderInput = ({
category,
settings,
onBrowse
onBrowse,
disabled = false
}: {
category: string;
settings: SettingsState;
onBrowse: () => void;
disabled?: boolean;
}) => {
const base = settings.baseDownloadFolder || '~/Downloads';
const sub = Object.prototype.hasOwnProperty.call(settings.categorySubfolders, category)
@@ -190,6 +192,7 @@ const CategoryFolderInput = ({
<input
type="text"
value={value}
disabled={disabled}
onFocus={() => setLocalValue(displayPath)}
onChange={(e) => setLocalValue(e.target.value)}
onBlur={() => {
@@ -213,12 +216,13 @@ const CategoryFolderInput = ({
}
setLocalValue(null);
}}
className="app-control flex-1 max-w-[280px] text-[12px] px-3 py-1.5 bg-surface-overlay/50 border-border-color/50 focus:border-accent-color focus:bg-surface-overlay"
className="app-control flex-1 max-w-[280px] text-[12px] px-3 py-1.5 bg-surface-overlay/50 border-border-color/50 focus:border-accent-color focus:bg-surface-overlay disabled:opacity-60 disabled:cursor-not-allowed"
aria-label={`${category} subfolder`}
/>
<button
onClick={onBrowse}
className="app-button px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary hover:bg-surface-overlay"
disabled={disabled}
className="app-button px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary hover:bg-surface-overlay disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-text-secondary"
>
Custom folder
</button>
@@ -478,21 +482,23 @@ runEngineChecks(false);
const approvedBase = await settings.approveDownloadRoot(base);
settings.setBaseDownloadFolder(approvedBase);
try {
const safeSubfolders = Object.fromEntries(
DOWNLOAD_CATEGORIES.map(category => [
category,
normalizeCategorySubfolder(
Object.prototype.hasOwnProperty.call(settings.categorySubfolders, category)
? settings.categorySubfolders[category]
: DEFAULT_CATEGORY_SUBFOLDERS[category],
DEFAULT_CATEGORY_SUBFOLDERS[category]
)
])
);
await invoke('create_category_directories', {
baseFolder: approvedBase,
subfolders: safeSubfolders
});
if (settings.categorySubfoldersEnabled) {
const safeSubfolders = Object.fromEntries(
DOWNLOAD_CATEGORIES.map(category => [
category,
normalizeCategorySubfolder(
Object.prototype.hasOwnProperty.call(settings.categorySubfolders, category)
? settings.categorySubfolders[category]
: DEFAULT_CATEGORY_SUBFOLDERS[category],
DEFAULT_CATEGORY_SUBFOLDERS[category]
)
])
);
await invoke('create_category_directories', {
baseFolder: approvedBase,
subfolders: safeSubfolders
});
}
} catch (e) {
console.error("Failed to create directories on disk:", e);
showToast(`Base folder saved, but category folders could not be created: ${String(e)}`, 'warning');
@@ -936,22 +942,50 @@ runEngineChecks(false);
</label>
</div>
<div className="mac-settings-group">
<label className="mac-settings-row cursor-default">
<div>
<span className="text-[13px] text-text-primary">Automatically save files to category subfolders</span>
<p className="mt-0.5 text-[11px] text-text-muted">When off, automatic downloads use the base folder.</p>
</div>
<input
type="checkbox"
checked={settings.categorySubfoldersEnabled}
onChange={(e) => settings.setCategorySubfoldersEnabled(e.target.checked)}
className="mac-switch"
/>
</label>
</div>
<div className="mac-settings-group">
<div className="mac-settings-row bg-item-hover/20">
<span className="text-[13px] font-semibold text-text-primary">Category Subfolders</span>
<span className="text-[11px] text-text-muted">Relative to the base folder</span>
<span className="text-[11px] text-text-muted">
{settings.categorySubfoldersEnabled ? 'Relative to the base folder' : 'Disabled'}
</span>
</div>
<div className="flex flex-col divide-y divide-border-color/30">
<div
className={`flex flex-col divide-y divide-border-color/30 ${
settings.categorySubfoldersEnabled ? '' : 'opacity-50'
}`}
aria-disabled={!settings.categorySubfoldersEnabled}
>
{DOWNLOAD_CATEGORIES.map((category) => (
<div key={category} className="flex flex-col gap-2 px-4 py-3 hover:bg-item-hover/20 transition-colors">
<div
key={category}
className={`flex flex-col gap-2 px-4 py-3 transition-colors ${
settings.categorySubfoldersEnabled ? 'hover:bg-item-hover/20' : ''
}`}
>
<div className="flex justify-between items-center">
<span className="text-[13px] font-medium text-text-primary w-32 shrink-0">{category}</span>
<CategoryFolderInput
category={category}
settings={settings}
onBrowse={() => handleBrowseCategory(category)}
disabled={!settings.categorySubfoldersEnabled}
/>
</div>
</div>
@@ -964,7 +998,8 @@ runEngineChecks(false);
settings.resetCategoryLocations();
showToast("Reset category locations to default", 'success');
}}
className="app-control hover:bg-item-hover text-text-secondary px-4 py-1"
disabled={!settings.categorySubfoldersEnabled}
className="app-control hover:bg-item-hover text-text-secondary px-4 py-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent"
>
Reset Defaults
</button>
+1
View File
@@ -36,6 +36,7 @@ vi.mock('./useSettingsStore', () => ({
maxAutomaticRetries: 3,
mediaCookieSource: 'none',
baseDownloadFolder: '~/Downloads',
categorySubfoldersEnabled: true,
categorySubfolders: {
Musics: 'Musics',
Movies: 'Movies',
+8
View File
@@ -73,6 +73,7 @@ export type {
export interface SettingsState {
theme: Theme;
baseDownloadFolder: string;
categorySubfoldersEnabled: boolean;
categorySubfolders: Record<string, string>;
categoryDirectoryOverrides: Record<string, string>;
approvedDownloadRoots: string[];
@@ -145,6 +146,7 @@ export interface SettingsState {
setAskWhereToSaveEachFile: (ask: boolean) => void;
setPreventsSleepWhileDownloading: (prevent: boolean) => void;
setMediaCookieSource: (source: MediaCookieSource) => void;
setCategorySubfoldersEnabled: (enabled: boolean) => void;
setCategorySubfolder: (category: string, subfolder: string) => void;
setCategoryDirectoryOverride: (category: string, path?: string) => void;
resetCategoryLocations: () => void;
@@ -187,6 +189,7 @@ export const useSettingsStore = create<SettingsState>()(
(set, _get) => ({
theme: 'system',
baseDownloadFolder: '~/Downloads',
categorySubfoldersEnabled: true,
categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS },
categoryDirectoryOverrides: {},
approvedDownloadRoots: [],
@@ -295,6 +298,10 @@ export const useSettingsStore = create<SettingsState>()(
set({ preventsSleepWhileDownloading });
},
setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); },
setCategorySubfoldersEnabled: (categorySubfoldersEnabled) => {
info('Settings updated: categorySubfoldersEnabled');
set({ categorySubfoldersEnabled });
},
setCategorySubfolder: (category, subfolder) => {
info(`Settings updated: category subfolder ${category}`);
set((state) => ({
@@ -385,6 +392,7 @@ export const useSettingsStore = create<SettingsState>()(
partialize: (state): PersistedSettings => ({
theme: state.theme,
baseDownloadFolder: state.baseDownloadFolder,
categorySubfoldersEnabled: state.categorySubfoldersEnabled,
categorySubfolders: state.categorySubfolders,
categoryDirectoryOverrides: state.categoryDirectoryOverrides,
approvedDownloadRoots: state.approvedDownloadRoots,
+20
View File
@@ -65,6 +65,26 @@ describe('download locations', () => {
expect(await resolveCategoryDestination(automatic, 'Movies')).toBe('/Volumes/Media');
});
it('defaults category subfolders on and sends every category to the base folder when disabled', async () => {
const automatic = normalizeDownloadLocationSettings({
baseDownloadFolder: '/Users/test/Downloads'
});
expect(automatic.categorySubfoldersEnabled).toBe(true);
const disabled = normalizeDownloadLocationSettings({
baseDownloadFolder: '/Users/test/Downloads',
categorySubfoldersEnabled: false,
categorySubfolders: { Movies: 'Video Files' },
categoryDirectoryOverrides: { Movies: '/Volumes/Media' }
});
expect(disabled.categorySubfoldersEnabled).toBe(false);
expect(await resolveCategoryDestination(disabled, 'Movies'))
.toBe('/Users/test/Downloads');
expect(await resolveCategoryDestination(disabled, 'Documents'))
.toBe('/Users/test/Downloads');
});
it('keeps an explicit empty category subfolder as the base folder', async () => {
const settings = normalizeDownloadLocationSettings({
baseDownloadFolder: '/Users/test/Downloads',
+8 -2
View File
@@ -34,12 +34,14 @@ export const DEFAULT_CATEGORY_SUBFOLDERS: Record<DownloadCategory, string> = {
export interface DownloadLocationSettings {
baseDownloadFolder: string;
categorySubfoldersEnabled: boolean;
categorySubfolders: Record<string, string>;
categoryDirectoryOverrides: Record<string, string>;
}
interface LegacyDownloadLocationSettings {
baseDownloadFolder?: unknown;
categorySubfoldersEnabled?: unknown;
categorySubfolders?: unknown;
categoryDirectoryOverrides?: unknown;
defaultDownloadPath?: unknown;
@@ -114,6 +116,7 @@ export const normalizeDownloadLocationSettings = (
(typeof value.baseDownloadFolder === 'string' && value.baseDownloadFolder.trim()) ||
(typeof value.defaultDownloadPath === 'string' && value.defaultDownloadPath.trim()) ||
'~/Downloads';
const categorySubfoldersEnabled = value.categorySubfoldersEnabled !== false;
const persistedSubfolders = stringRecord(value.categorySubfolders);
const categorySubfolders = Object.fromEntries(
DOWNLOAD_CATEGORIES.map(category => {
@@ -150,6 +153,7 @@ export const normalizeDownloadLocationSettings = (
return {
baseDownloadFolder,
categorySubfoldersEnabled,
categorySubfolders,
categoryDirectoryOverrides
};
@@ -159,11 +163,13 @@ export const resolveCategoryDestination = async (
settings: DownloadLocationSettings,
category: DownloadCategory
): Promise<string> => {
const base = settings.baseDownloadFolder.trim() || '~/Downloads';
const expandedBase = await expandTilde(base);
if (!settings.categorySubfoldersEnabled) return expandedBase;
const override = settings.categoryDirectoryOverrides[category]?.trim();
if (override) return expandTilde(override);
const base = settings.baseDownloadFolder.trim() || '~/Downloads';
const expandedBase = await expandTilde(base);
const persistedValue = hasOwn(settings.categorySubfolders, category)
? settings.categorySubfolders[category]
: DEFAULT_CATEGORY_SUBFOLDERS[category];