From 85ad9d18e809467e80363c3456123171a7192a8a Mon Sep 17 00:00:00 2001 From: NimBold Date: Mon, 6 Jul 2026 23:37:03 +0330 Subject: [PATCH] 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 --- src-tauri/src/download_ownership.rs | 3 ++ src-tauri/src/ipc.rs | 1 + src-tauri/src/settings.rs | 24 +++++++++ src/bindings/PersistedSettings.ts | 2 +- src/components/SettingsView.tsx | 79 +++++++++++++++++++++-------- src/store/useDownloadStore.test.ts | 1 + src/store/useSettingsStore.ts | 8 +++ src/utils/downloadLocations.test.ts | 20 ++++++++ src/utils/downloadLocations.ts | 10 +++- 9 files changed, 123 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/download_ownership.rs b/src-tauri/src/download_ownership.rs index cac598a..ce3e8e2 100644 --- a/src-tauri/src/download_ownership.rs +++ b/src-tauri/src/download_ownership.rs @@ -168,6 +168,9 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result, pub category_directory_overrides: HashMap, pub approved_download_roots: Vec, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index b296007..d66f27d 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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}); diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index a78a410..0328de9 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -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, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, 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, +export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, 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, /** * HMAC shared secret for the browser extension. It is persisted in the * settings database so that startup never needs to touch the OS keychain. diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index b919711..c34fa4d 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -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 = ({ 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`} /> @@ -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); +
+ +
+
Category Subfolders - Relative to the base folder + + {settings.categorySubfoldersEnabled ? 'Relative to the base folder' : 'Disabled'} +
-
+
{DOWNLOAD_CATEGORIES.map((category) => ( -
+
{category} handleBrowseCategory(category)} + disabled={!settings.categorySubfoldersEnabled} />
@@ -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 diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 52d6a3d..71edb46 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -36,6 +36,7 @@ vi.mock('./useSettingsStore', () => ({ maxAutomaticRetries: 3, mediaCookieSource: 'none', baseDownloadFolder: '~/Downloads', + categorySubfoldersEnabled: true, categorySubfolders: { Musics: 'Musics', Movies: 'Movies', diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 1cb0c88..77bcd71 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -73,6 +73,7 @@ export type { export interface SettingsState { theme: Theme; baseDownloadFolder: string; + categorySubfoldersEnabled: boolean; categorySubfolders: Record; categoryDirectoryOverrides: Record; 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()( (set, _get) => ({ theme: 'system', baseDownloadFolder: '~/Downloads', + categorySubfoldersEnabled: true, categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS }, categoryDirectoryOverrides: {}, approvedDownloadRoots: [], @@ -295,6 +298,10 @@ export const useSettingsStore = create()( 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()( partialize: (state): PersistedSettings => ({ theme: state.theme, baseDownloadFolder: state.baseDownloadFolder, + categorySubfoldersEnabled: state.categorySubfoldersEnabled, categorySubfolders: state.categorySubfolders, categoryDirectoryOverrides: state.categoryDirectoryOverrides, approvedDownloadRoots: state.approvedDownloadRoots, diff --git a/src/utils/downloadLocations.test.ts b/src/utils/downloadLocations.test.ts index 43ca640..e98c3c6 100644 --- a/src/utils/downloadLocations.test.ts +++ b/src/utils/downloadLocations.test.ts @@ -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', diff --git a/src/utils/downloadLocations.ts b/src/utils/downloadLocations.ts index c6cc5e5..a1c1cbe 100644 --- a/src/utils/downloadLocations.ts +++ b/src/utils/downloadLocations.ts @@ -34,12 +34,14 @@ export const DEFAULT_CATEGORY_SUBFOLDERS: Record = { export interface DownloadLocationSettings { baseDownloadFolder: string; + categorySubfoldersEnabled: boolean; categorySubfolders: Record; categoryDirectoryOverrides: Record; } 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 => { + 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];