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
+90 -35
View File
@@ -12,6 +12,10 @@ import { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
import { categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads';
import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
import {
resolveCategoryDestination,
resolveDownloadFilePath
} from '../utils/downloadLocations';
interface RawMediaFormat {
format_id?: string;
@@ -271,7 +275,7 @@ export const AddDownloadsModal = () => {
addDownload,
queues
} = useDownloadStore();
const { defaultDownloadPath } = useSettingsStore();
const { baseDownloadFolder } = useSettingsStore();
const [selectedQueueId, setSelectedQueueId] = useState<string>(MAIN_QUEUE_ID);
@@ -289,7 +293,7 @@ export const AddDownloadsModal = () => {
const actionMenuRef = useRef<HTMLDivElement>(null);
// Right Form
const [saveLocation, setSaveLocation] = useState(defaultDownloadPath);
const [saveLocation, setSaveLocation] = useState(baseDownloadFolder);
const [isSaveLocationManual, setIsSaveLocationManual] = useState(false);
const [connections, setConnections] = useState(16);
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
@@ -310,7 +314,7 @@ export const AddDownloadsModal = () => {
useEffect(() => {
if (isAddModalOpen) {
setSaveLocation(defaultDownloadPath);
setSaveLocation(baseDownloadFolder);
setIsSaveLocationManual(false);
setUrls(pendingAddUrls || '');
setParsedItems([]);
@@ -338,7 +342,7 @@ export const AddDownloadsModal = () => {
pendingAddReferer,
pendingAddHeaders,
pendingAddCookies,
defaultDownloadPath,
baseDownloadFolder,
queues
]);
@@ -477,14 +481,20 @@ export const AddDownloadsModal = () => {
if (active && firstReadyIndex !== null && !isSaveLocationManual) {
setSelectedItemIndex(firstReadyIndex);
const firstFile = updatedItems[firstReadyIndex].file;
if (firstFile) {
const category = categoryForFileName(firstFile);
const settingsStore = useSettingsStore.getState();
const categoryDir = (settingsStore.downloadDirectories && settingsStore.downloadDirectories[category]) || settingsStore.defaultDownloadPath || '~/Downloads';
setSaveLocation(categoryDir);
if (lines.length > 1) {
setSaveLocation(useSettingsStore.getState().baseDownloadFolder || '~/Downloads');
} else {
const firstFile = updatedItems[firstReadyIndex].file;
if (firstFile) {
const category = categoryForFileName(firstFile);
const categoryDir = await resolveCategoryDestination(
useSettingsStore.getState(),
category
);
if (active) setSaveLocation(categoryDir);
}
}
}
}, 400);
return () => {
@@ -513,10 +523,7 @@ export const AddDownloadsModal = () => {
const categoryLocationForFile = (fileName: string) => {
const category = categoryForFileName(fileName);
const settingsStore = useSettingsStore.getState();
return (settingsStore.downloadDirectories && settingsStore.downloadDirectories[category]) ||
settingsStore.defaultDownloadPath ||
'~/Downloads';
return resolveCategoryDestination(useSettingsStore.getState(), category);
};
const handleAction = async (action: AddDownloadAction) => {
@@ -554,21 +561,33 @@ export const AddDownloadsModal = () => {
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
const itemLocation = useSharedDestination ? finalLocation : categoryLocationForFile(finalFile);
const itemLocation = useSharedDestination
? finalLocation
: await categoryLocationForFile(finalFile);
const isUrlDupe = store.downloads.some(d => d.url === item.url && d.status !== 'failed' && d.status !== 'completed');
if (isUrlDupe) {
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: 'URL already in queue' }, resolution: 'rename' });
} else {
const fileExistsInStore = store.downloads.some(d => {
const dest = d.destination || settings.defaultDownloadPath || '~/Downloads';
return dest === itemLocation && d.fileName === finalFile && d.status !== 'failed';
});
let fileExistsInStore = false;
for (const download of store.downloads) {
const destination = download.destination ||
await resolveCategoryDestination(settings, download.category);
if (
destination === itemLocation &&
download.fileName === finalFile &&
download.status !== 'failed'
) {
fileExistsInStore = true;
break;
}
}
let fileExistsOnDisk = false;
try {
const cleanLocation = itemLocation.endsWith('/') ? itemLocation.slice(0, -1) : itemLocation;
fileExistsOnDisk = await invoke('check_file_exists', { path: `${cleanLocation}/${finalFile}` });
fileExistsOnDisk = await invoke('check_file_exists', {
path: await resolveDownloadFilePath(itemLocation, finalFile)
});
} catch (e) {}
if (fileExistsInStore || fileExistsOnDisk) {
@@ -607,8 +626,9 @@ export const AddDownloadsModal = () => {
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
const itemLocation = useSharedDestination ? finalLocation : categoryLocationForFile(finalFile);
const cleanLocation = itemLocation.endsWith('/') ? itemLocation.slice(0, -1) : itemLocation;
const itemLocation = useSharedDestination
? finalLocation
: await categoryLocationForFile(finalFile);
let count = 1;
const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
@@ -618,12 +638,26 @@ export const AddDownloadsModal = () => {
while (exists && count < 1000) {
newName = `${base} (${count})${ext}`;
const storeHas = useDownloadStore.getState().downloads.some(d => {
const dest = d.destination || useSettingsStore.getState().defaultDownloadPath || '~/Downloads';
return dest === itemLocation && d.fileName === newName && d.status !== 'failed';
});
let storeHas = false;
const currentSettings = useSettingsStore.getState();
for (const download of useDownloadStore.getState().downloads) {
const destination = download.destination ||
await resolveCategoryDestination(currentSettings, download.category);
if (
destination === itemLocation &&
download.fileName === newName &&
download.status !== 'failed'
) {
storeHas = true;
break;
}
}
let diskHas = false;
try { diskHas = await invoke('check_file_exists', { path: `${cleanLocation}/${newName}` }); } catch(e) {}
try {
diskHas = await invoke('check_file_exists', {
path: await resolveDownloadFilePath(itemLocation, newName)
});
} catch(e) {}
exists = storeHas || diskHas;
count++;
}
@@ -640,15 +674,26 @@ export const AddDownloadsModal = () => {
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
const itemLocation = useSharedDestination ? finalLocation : categoryLocationForFile(finalFile);
const cleanLocation = itemLocation.endsWith('/') ? itemLocation.slice(0, -1) : itemLocation;
const fullPath = `${cleanLocation}/${finalFile}`;
const itemLocation = useSharedDestination
? finalLocation
: await categoryLocationForFile(finalFile);
const fullPath = await resolveDownloadFilePath(itemLocation, finalFile);
const store = useDownloadStore.getState();
const existingItem = store.downloads.find(d => {
const dest = d.destination || useSettingsStore.getState().defaultDownloadPath || '~/Downloads';
return (d.url === item.url || (dest === itemLocation && d.fileName === finalFile)) && d.status !== 'failed';
});
let existingItem;
const currentSettings = useSettingsStore.getState();
for (const download of store.downloads) {
const destination = download.destination ||
await resolveCategoryDestination(currentSettings, download.category);
if (
(download.url === item.url ||
(destination === itemLocation && download.fileName === finalFile)) &&
download.status !== 'failed'
) {
existingItem = download;
break;
}
}
if (existingItem) {
await store.removeDownload(existingItem.id);
@@ -941,6 +986,16 @@ export const AddDownloadsModal = () => {
Browse
</button>
</div>
{parsedItems.length > 1 && !isSaveLocationManual && (
<p className="mt-2 text-[11px] text-text-muted">
Files will be organized into category folders.
</p>
)}
{isSaveLocationManual && (
<p className="mt-2 text-[11px] text-text-muted">
All selected downloads will use this folder.
</p>
)}
</section>
{/* Transfer Settings */}
+6 -17
View File
@@ -5,7 +5,10 @@ import { SidebarFilter } from './Sidebar';
import { Play, Pause, Plus, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, PanelLeft, ArrowDownCircle, Command } from 'lucide-react';
import { DownloadItem as DownloadItemComponent } from './DownloadItem';
import { invokeCommand as invoke } from '../ipc';
import { homeDir } from '@tauri-apps/api/path';
import {
resolveCategoryDestination,
resolveDownloadFilePath
} from '../utils/downloadLocations';
interface DownloadTableProps {
filter: SidebarFilter;
@@ -57,18 +60,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
return () => window.clearTimeout(timeout);
}, [interactionError]);
const resolvePath = async (dir: string, file: string) => {
let resolvedDir = dir;
if (dir.startsWith('~/')) {
const home = await homeDir();
resolvedDir = home + '/' + dir.slice(2);
} else if (dir === '~') {
resolvedDir = await homeDir();
}
const separator = resolvedDir.endsWith('/') ? '' : '/';
return resolvedDir + separator + file;
};
const showInteractionError = (message: string, error: unknown) => {
const detail = typeof error === 'string' ? error : error instanceof Error ? error.message : String(error);
setInteractionError(`${message}: ${detail}`);
@@ -79,10 +70,8 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
if (!fileName) return null;
const settings = useSettingsStore.getState();
const destination = item.destination ||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
return resolvePath(destination, fileName);
await resolveCategoryDestination(settings, item.category);
return resolveDownloadFilePath(destination, fileName);
};
const openProperties = (id: string) => {
+12 -4
View File
@@ -3,6 +3,7 @@ import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore';
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import { resolveCategoryDestination } from '../utils/downloadLocations';
type LoginMode = 'matching' | 'custom' | 'none';
@@ -15,7 +16,7 @@ export const PropertiesModal = () => {
: null
);
const { defaultDownloadPath, perServerConnections } = useSettingsStore();
const { baseDownloadFolder, perServerConnections } = useSettingsStore();
// Form states
const [url, setUrl] = useState('');
@@ -46,7 +47,14 @@ export const PropertiesModal = () => {
if (activeItem) {
setUrl(activeItem.url);
setFileName(activeItem.fileName);
setSaveLocation(activeItem.destination || defaultDownloadPath || '~/Downloads');
if (activeItem.destination) {
setSaveLocation(activeItem.destination);
} else {
void resolveCategoryDestination(
useSettingsStore.getState(),
activeItem.category
).then(setSaveLocation);
}
setConnections(activeItem.connections || 16);
if (activeItem.speedLimit) {
@@ -85,7 +93,7 @@ export const PropertiesModal = () => {
setSelectedPropertiesDownloadId(null);
}
}
}, [selectedPropertiesDownloadId, defaultDownloadPath, setSelectedPropertiesDownloadId]);
}, [selectedPropertiesDownloadId, baseDownloadFolder, setSelectedPropertiesDownloadId]);
if (!selectedPropertiesDownloadId || !item) return null;
@@ -179,7 +187,7 @@ export const PropertiesModal = () => {
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">Last try</span><span className="text-text-secondary truncate">-</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">Date added</span><span className="text-text-secondary truncate">{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">Destination</span><span className="text-text-secondary truncate" title={item.destination}>{item.destination || defaultDownloadPath}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">Destination</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
</div>
</div>
+4 -3
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useDownloadStore } from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore';
import { categoryForFileName } from '../utils/downloads';
import { resolveCategoryDestination } from '../utils/downloadLocations';
const formatBytes = (bytes: number) => {
if (bytes === 0) return 'Unknown size';
@@ -23,7 +24,7 @@ export const QualityModal = React.memo(() => {
if (!isParsing && !activeMetadata && !parsingError) return null;
const handleConfirm = () => {
const handleConfirm = async () => {
if (!activeMetadata || !activeMetadataUrl || !selectedFormatId) return;
const format = activeMetadata.formats.find(f => f.format_id === selectedFormatId);
@@ -34,7 +35,7 @@ export const QualityModal = React.memo(() => {
const filename = `${activeMetadata.title}.${format.ext}`.replace(/[\/\\?%*:|"<>]/g, '-');
const category = categoryForFileName(filename);
const destination = (settings.downloadDirectories && settings.downloadDirectories[category]) || settings.defaultDownloadPath || '~/Downloads';
const destination = await resolveCategoryDestination(settings, category);
const downloadItem = {
id,
@@ -51,7 +52,7 @@ export const QualityModal = React.memo(() => {
mediaFormatSelector: format.format_id
};
void addDownload(downloadItem, { type: 'start-now' });
await addDownload(downloadItem, { type: 'start-now' });
clearMetadata();
};
+64 -55
View File
@@ -5,7 +5,6 @@ import {
SettingsTab,
useSettingsStore
} from '../store/useSettingsStore';
import { useDirectoryPicker } from '../hooks/useDirectoryPicker';
import {
Download, Palette, Globe, Folder, Key,
Moon, Terminal, Puzzle, Info, Plus, Trash2, Copy, RefreshCw, Code
@@ -16,6 +15,11 @@ import { invokeCommand as invoke } from '../ipc';
import type { EngineStatusItem } from '../bindings/EngineStatusItem';
import { WindowDragRegion } from './WindowDragRegion';
import appIcon from '../assets/app-icon.png';
import {
DEFAULT_CATEGORY_SUBFOLDERS,
DOWNLOAD_CATEGORIES,
normalizeCategorySubfolder
} from '../utils/downloadLocations';
const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [
{ type: 'downloads', label: 'Downloads', icon: Download },
@@ -97,7 +101,6 @@ const runEngineStatusCheck = (check: EngineCheck, force: boolean) => {
export default function SettingsView() {
const settings = useSettingsStore();
const { pickDirectory } = useDirectoryPicker();
const activeTab = settings.activeSettingsTab;
// Local state for engine diagnostics
@@ -245,7 +248,7 @@ runEngineChecks(false);
};
const handleBrowseCategory = async (category: string) => {
const currentPath = (settings.downloadDirectories || {})[category] || '';
const currentPath = settings.categoryDirectoryOverrides[category] || settings.baseDownloadFolder;
try {
const selected = await open({
directory: true,
@@ -253,46 +256,42 @@ runEngineChecks(false);
defaultPath: currentPath.startsWith('~') ? undefined : currentPath
});
if (selected && typeof selected === 'string') {
settings.setCategoryDirectory(category, selected);
settings.setCategoryDirectoryOverride(category, selected);
}
} catch (e) {
console.error(`Failed to select folder for ${category}:`, e);
}
};
const handleBrowseBulk = async () => {
const handleBrowseBase = async () => {
try {
const base = await open({
directory: true,
multiple: false
multiple: false,
defaultPath: settings.baseDownloadFolder.startsWith('~')
? undefined
: settings.baseDownloadFolder
});
if (base && typeof base === 'string') {
const cleanBase = base.replace(/\/$/, '');
const paths = [
`${cleanBase}/Musics`,
`${cleanBase}/Movies`,
`${cleanBase}/Compressed`,
`${cleanBase}/Documents`,
`${cleanBase}/Pictures`,
`${cleanBase}/Applications`,
`${cleanBase}/Other`
];
settings.setCategoryDirectory('Musics', paths[0]);
settings.setCategoryDirectory('Movies', paths[1]);
settings.setCategoryDirectory('Compressed', paths[2]);
settings.setCategoryDirectory('Documents', paths[3]);
settings.setCategoryDirectory('Pictures', paths[4]);
settings.setCategoryDirectory('Applications', paths[5]);
settings.setCategoryDirectory('Other', paths[6]);
settings.setBaseDownloadFolder(base);
try {
await invoke('create_category_directories', { paths });
const safeSubfolders = Object.fromEntries(
DOWNLOAD_CATEGORIES.map(category => [
category,
normalizeCategorySubfolder(
settings.categorySubfolders[category] || '',
DEFAULT_CATEGORY_SUBFOLDERS[category]
)
])
);
await invoke('create_category_directories', {
baseFolder: base,
subfolders: safeSubfolders
});
} catch (e) {
console.error("Failed to create directories on disk:", e);
}
showToast("Updated and created all category folders at base");
showToast("Base download folder updated");
}
} catch (e) {
console.error("Failed to browse base path:", e);
@@ -670,20 +669,20 @@ runEngineChecks(false);
<div className="settings-pane max-w-[760px]">
<div className="mac-settings-group">
<div className="mac-settings-row">
<span className="text-[13px] font-semibold text-text-primary">Default Download Path</span>
<div>
<span className="text-[13px] font-semibold text-text-primary">Base Download Folder</span>
<p className="mt-0.5 text-[11px] text-text-muted">Automatic category folders are created inside this folder.</p>
</div>
<div className="flex gap-2">
<input
type="text"
value={settings.defaultDownloadPath || ''}
onChange={(e) => settings.setDefaultDownloadPath(e.target.value)}
value={settings.baseDownloadFolder}
onChange={(e) => settings.setBaseDownloadFolder(e.target.value)}
className="app-control w-64 text-[11px] px-2"
placeholder="~/Downloads"
/>
<button
onClick={async () => {
const path = await pickDirectory(settings.defaultDownloadPath);
if (path) settings.setDefaultDownloadPath(path);
}}
onClick={handleBrowseBase}
className="app-button px-3 text-xs text-text-secondary hover:bg-item-hover"
>
Browse
@@ -706,46 +705,56 @@ runEngineChecks(false);
<div className="mac-settings-group">
<div className="mac-settings-row bg-item-hover/20">
<span className="text-[13px] font-semibold text-text-primary">All Categories Base</span>
<div className="flex gap-2">
<input
type="text" readOnly placeholder="Choose base folder..."
className="app-control w-64 text-text-muted text-[11px] px-2"
/>
<button
onClick={handleBrowseBulk}
className="app-button px-3 text-xs font-semibold text-accent border border-accent/20 bg-accent/10 hover:bg-accent/20"
>
Browse
</button>
</div>
<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>
</div>
{['Musics', 'Movies', 'Compressed', 'Documents', 'Pictures', 'Applications', 'Other'].map((category) => (
{DOWNLOAD_CATEGORIES.map((category) => (
<div key={category} className="mac-settings-row">
<span className="text-[13px] text-text-primary pl-4">{category}</span>
<div className="flex gap-2">
<div className="flex items-center gap-2">
<input
type="text"
value={(settings.downloadDirectories || {})[category] || ''}
onChange={(e) => settings.setCategoryDirectory(category, e.target.value)}
value={settings.categorySubfolders[category] || ''}
onChange={(e) => settings.setCategorySubfolder(category, e.target.value)}
onBlur={(e) => settings.setCategorySubfolder(
category,
normalizeCategorySubfolder(
e.target.value,
DEFAULT_CATEGORY_SUBFOLDERS[category]
)
)}
className="app-control w-64 text-[11px] px-2"
aria-label={`${category} subfolder`}
/>
<button
onClick={() => handleBrowseCategory(category)}
className="app-button px-3 text-xs text-text-secondary hover:bg-item-hover"
>
Browse
Custom folder
</button>
{settings.categoryDirectoryOverrides[category] && (
<button
onClick={() => settings.setCategoryDirectoryOverride(category)}
className="app-button px-3 text-xs text-text-secondary hover:bg-item-hover"
>
Use automatic
</button>
)}
</div>
{settings.categoryDirectoryOverrides[category] && (
<p className="col-span-full pl-4 text-[10px] text-text-muted">
Override: {settings.categoryDirectoryOverrides[category]}
</p>
)}
</div>
))}
<div className="mac-settings-row justify-end border-t-0">
<button
onClick={() => {
settings.resetCategoryDirectories();
showToast("Reset directories to default");
settings.resetCategoryLocations();
showToast("Reset category locations to default");
}}
className="app-control hover:bg-item-hover text-text-secondary px-4 py-1"
>