feat(settings): modernize download location settings

- Replace split 'Default Download Path' and 'All Categories Base' with a canonical baseDownloadFolder and categorySubfolders model
- Retain absolute category overrides only where required
- Update Add window to accurately reflect intended destination:
  - Single URL: specific category path
  - Multiple URLs: base folder with explanatory text
  - Manual Browse override: selected folder
- Centralize path resolution logic shared by Rust backend and UI
This commit is contained in:
NimBold
2026-06-20 19:22:20 +03:30
parent 79b579790d
commit 038f31b988
17 changed files with 1103 additions and 380 deletions
+20
View File
@@ -12,6 +12,15 @@ vi.mock('@tauri-apps/plugin-log', () => ({
error: vi.fn(),
}));
vi.mock('@tauri-apps/api/path', () => ({
homeDir: vi.fn().mockResolvedValue('/Users/test'),
join: vi.fn(async (...parts: string[]) =>
parts
.map((part, index) => index === 0 ? part.replace(/[\\/]+$/, '') : part.replace(/^[\\/]+|[\\/]+$/g, ''))
.join('/')
),
}));
vi.mock('./useSettingsStore', () => ({
useSettingsStore: {
getState: vi.fn(() => ({
@@ -22,6 +31,17 @@ vi.mock('./useSettingsStore', () => ({
customUserAgent: '',
maxAutomaticRetries: 3,
mediaCookieSource: 'none',
baseDownloadFolder: '~/Downloads',
categorySubfolders: {
Musics: 'Musics',
Movies: 'Movies',
Compressed: 'Compressed',
Documents: 'Documents',
Pictures: 'Pictures',
Applications: 'Applications',
Other: 'Other',
},
categoryDirectoryOverrides: {},
})),
}
}));
+13 -16
View File
@@ -11,6 +11,10 @@ import type { MediaMetadata } from '../bindings/MediaMetadata';
import { useSettingsStore } from './useSettingsStore';
import { isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
import {
resolveCategoryDestination,
resolveDownloadFilePath
} from '../utils/downloadLocations';
export type { DownloadCategory } from '../utils/downloads';
@@ -124,21 +128,18 @@ const syncSystemIntegrations = () => {
const resolveDownloadPath = async (destination: string, fileName: string) => {
let resolvedDestination = destination;
if (destination.startsWith('~/')) {
resolvedDestination = `${await homeDir()}/${destination.slice(2)}`;
resolvedDestination = await resolveDownloadFilePath(await homeDir(), destination.slice(2));
} else if (destination === '~') {
resolvedDestination = await homeDir();
}
const separator = resolvedDestination.endsWith('/') ? '' : '/';
return `${resolvedDestination}${separator}${fileName}`;
return resolveDownloadFilePath(resolvedDestination, fileName);
};
const effectiveDestinationForItem = (
const effectiveDestinationForItem = async (
item: Pick<DownloadItem, 'destination' | 'category'>,
settings: ReturnType<typeof useSettingsStore.getState>
) => item.destination ||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
): Promise<string> =>
item.destination || resolveCategoryDestination(settings, item.category);
export type { DownloadStatus };
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
@@ -312,7 +313,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
},
addDownload: async (item, action) => {
const settings = useSettingsStore.getState();
const destPath = effectiveDestinationForItem(item, settings);
const destPath = await effectiveDestinationForItem(item, settings);
const ownedItem: DownloadItem = {
...item,
destination: destPath,
@@ -453,9 +454,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
const settings = useSettingsStore.getState();
const destPath = targetItem.destination ||
(settings.downloadDirectories && settings.downloadDirectories[targetItem.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
await resolveCategoryDestination(settings, targetItem.category);
if (!destPath.trim()) {
throw new Error('Cannot redownload: destination folder is missing.');
@@ -639,10 +638,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
console.warn("Could not fetch keychain password for login:", e);
}
}
const destPath = item.destination ||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
const destPath = item.destination ||
await resolveCategoryDestination(settings, item.category);
itemsToEnqueue.push({
id: item.id,
url: item.url,
+50 -51
View File
@@ -13,6 +13,10 @@ import type { SchedulerSettings } from '../bindings/SchedulerSettings';
import type { SettingsTab } from '../bindings/SettingsTab';
import type { SiteLogin } from '../bindings/SiteLogin';
import type { Theme } from '../bindings/Theme';
import {
DEFAULT_CATEGORY_SUBFOLDERS,
normalizeDownloadLocationSettings
} from '../utils/downloadLocations';
let settingsSave = Promise.resolve();
@@ -66,7 +70,9 @@ export type {
export interface SettingsState {
theme: Theme;
defaultDownloadPath: string;
baseDownloadFolder: string;
categorySubfolders: Record<string, string>;
categoryDirectoryOverrides: Record<string, string>;
maxConcurrentDownloads: number;
globalSpeedLimit: string;
isSidebarVisible: boolean;
@@ -94,13 +100,12 @@ export interface SettingsState {
askWhereToSaveEachFile: boolean;
preventsSleepWhileDownloading: boolean;
mediaCookieSource: MediaCookieSource;
downloadDirectories: Record<string, string>;
siteLogins: SiteLogin[];
extensionPairingToken: string;
autoCheckUpdates: boolean;
setTheme: (theme: Theme) => void;
setDefaultDownloadPath: (path: string) => void;
setBaseDownloadFolder: (path: string) => void;
setMaxConcurrentDownloads: (count: number) => void;
setGlobalSpeedLimit: (limit: string) => void;
setActiveView: (view: ActiveView) => void;
@@ -127,8 +132,9 @@ export interface SettingsState {
setAskWhereToSaveEachFile: (ask: boolean) => void;
setPreventsSleepWhileDownloading: (prevent: boolean) => void;
setMediaCookieSource: (source: MediaCookieSource) => void;
setCategoryDirectory: (category: string, path: string) => void;
resetCategoryDirectories: () => void;
setCategorySubfolder: (category: string, subfolder: string) => void;
setCategoryDirectoryOverride: (category: string, path?: string) => void;
resetCategoryLocations: () => void;
addSiteLogin: (login: SiteLogin) => void;
removeSiteLogin: (id: string) => void;
regeneratePairingToken: () => void;
@@ -136,40 +142,6 @@ export interface SettingsState {
hydratePairingToken: () => Promise<boolean>;
}
const defaultDirectories = {
Musics: '~/Downloads/Musics',
Movies: '~/Downloads/Movies',
Compressed: '~/Downloads/Compressed',
Documents: '~/Downloads/Documents',
Pictures: '~/Downloads/Pictures',
Applications: '~/Downloads/Applications',
Other: '~/Downloads/Other'
};
const normalizeDownloadDirectories = (directories: unknown): Record<string, string> => {
if (!directories || typeof directories !== 'object') {
return { ...defaultDirectories };
}
const values = directories as Record<string, unknown>;
const directory = (current: string, legacy?: string) => {
const value = values[current] ?? (legacy ? values[legacy] : undefined);
return typeof value === 'string' && value.length > 0
? value
: defaultDirectories[current as keyof typeof defaultDirectories];
};
return {
Musics: directory('Musics', 'Audio'),
Movies: directory('Movies', 'Video'),
Compressed: directory('Compressed', 'Archives'),
Documents: directory('Documents'),
Pictures: directory('Pictures', 'Images'),
Applications: directory('Applications', 'Apps'),
Other: directory('Other')
};
};
const generateSecureToken = () => {
try {
const cryptoObj = typeof window !== 'undefined'
@@ -200,7 +172,9 @@ export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: 'system',
defaultDownloadPath: '~/Downloads',
baseDownloadFolder: '~/Downloads',
categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS },
categoryDirectoryOverrides: {},
maxConcurrentDownloads: 3,
globalSpeedLimit: '',
activeView: 'downloads',
@@ -236,13 +210,15 @@ export const useSettingsStore = create<SettingsState>()(
askWhereToSaveEachFile: false,
preventsSleepWhileDownloading: true,
mediaCookieSource: 'none',
downloadDirectories: { ...defaultDirectories },
siteLogins: [],
extensionPairingToken: '',
autoCheckUpdates: true,
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
setDefaultDownloadPath: (path) => { info('Settings updated: defaultDownloadPath'); set({ defaultDownloadPath: path }); },
setBaseDownloadFolder: (path) => {
info('Settings updated: baseDownloadFolder');
set({ baseDownloadFolder: path });
},
setMaxConcurrentDownloads: (max) => {
info('Settings updated: maxConcurrentDownloads');
set({ maxConcurrentDownloads: max });
@@ -282,13 +258,28 @@ export const useSettingsStore = create<SettingsState>()(
if (!preventsSleepWhileDownloading) invoke('set_prevent_sleep', { prevent: false }).catch(console.error);
},
setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); },
setCategoryDirectory: (category, path) => {
info(`Settings updated: category directory ${category}`);
setCategorySubfolder: (category, subfolder) => {
info(`Settings updated: category subfolder ${category}`);
set((state) => ({
downloadDirectories: { ...state.downloadDirectories, [category]: path }
categorySubfolders: { ...state.categorySubfolders, [category]: subfolder }
}));
},
resetCategoryDirectories: () => { info('Settings updated: resetCategoryDirectories'); set({ downloadDirectories: { ...defaultDirectories } }); },
setCategoryDirectoryOverride: (category, path) => {
info(`Settings updated: category directory override ${category}`);
set((state) => {
const next = { ...state.categoryDirectoryOverrides };
if (path?.trim()) next[category] = path.trim();
else delete next[category];
return { categoryDirectoryOverrides: next };
});
},
resetCategoryLocations: () => {
info('Settings updated: resetCategoryLocations');
set({
categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS },
categoryDirectoryOverrides: {}
});
},
addSiteLogin: (login) => set((state) => ({
siteLogins: [...state.siteLogins, login]
})),
@@ -312,21 +303,29 @@ export const useSettingsStore = create<SettingsState>()(
{
name: 'firelink-settings',
storage: createJSONStorage(() => tauriStorage),
version: 1,
version: 2,
migrate: (persistedState) => {
if (!persistedState || typeof persistedState !== 'object') {
return persistedState as SettingsState;
}
const persisted = persistedState as Partial<SettingsState>;
const locations = normalizeDownloadLocationSettings(
persisted as Partial<SettingsState> & {
defaultDownloadPath?: unknown;
downloadDirectories?: unknown;
}
);
return {
...persisted,
downloadDirectories: normalizeDownloadDirectories(persisted.downloadDirectories),
...locations,
siteLogins: Array.isArray(persisted.siteLogins) ? persisted.siteLogins : []
} as SettingsState;
},
partialize: (state): PersistedSettings => ({
theme: state.theme,
defaultDownloadPath: state.defaultDownloadPath,
baseDownloadFolder: state.baseDownloadFolder,
categorySubfolders: state.categorySubfolders,
categoryDirectoryOverrides: state.categoryDirectoryOverrides,
maxConcurrentDownloads: state.maxConcurrentDownloads,
globalSpeedLimit: state.globalSpeedLimit,
isSidebarVisible: state.isSidebarVisible,
@@ -351,7 +350,6 @@ export const useSettingsStore = create<SettingsState>()(
askWhereToSaveEachFile: state.askWhereToSaveEachFile,
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
mediaCookieSource: state.mediaCookieSource,
downloadDirectories: state.downloadDirectories,
siteLogins: state.siteLogins,
autoCheckUpdates: state.autoCheckUpdates
}),
@@ -359,12 +357,13 @@ export const useSettingsStore = create<SettingsState>()(
const persisted = persistedState && typeof persistedState === 'object'
? persistedState as Partial<SettingsState>
: {};
const locations = normalizeDownloadLocationSettings(persisted);
return ({
...currentState,
...persisted,
...locations,
appFontSize: persisted.appFontSize || currentState.appFontSize,
listRowDensity: persisted.listRowDensity || currentState.listRowDensity,
downloadDirectories: normalizeDownloadDirectories(persisted.downloadDirectories),
siteLogins: Array.isArray(persisted.siteLogins)
? persisted.siteLogins
: currentState.siteLogins