feat(locations): remember last Add-window directory

Remember manually selected Add-window folders during the current session, with a persisted Locations toggle and cross-platform path handling.\n\nRefs #23
This commit is contained in:
NimBold
2026-07-18 11:52:24 +03:30
parent c914eeb7b3
commit 160e6af945
9 changed files with 173 additions and 9 deletions
+1
View File
@@ -291,6 +291,7 @@ pub struct PersistedSettings {
pub proxy_port: u16,
pub custom_user_agent: String,
pub ask_where_to_save_each_file: bool,
pub remember_last_used_download_directory: bool,
pub prevents_sleep_while_downloading: bool,
pub media_cookie_source: MediaCookieSource,
pub site_logins: Vec<SiteLogin>,
+20
View File
@@ -451,6 +451,7 @@ fn default_settings() -> PersistedSettings {
proxy_port: 8080,
custom_user_agent: String::new(),
ask_where_to_save_each_file: false,
remember_last_used_download_directory: true,
prevents_sleep_while_downloading: true,
media_cookie_source: MediaCookieSource::default(),
site_logins: Vec::new(),
@@ -702,6 +703,25 @@ mod tests {
assert!(!default_settings().auto_add_clipboard_links);
}
#[test]
fn remembers_last_used_download_directory_by_default() {
assert!(default_settings().remember_last_used_download_directory);
}
#[test]
fn decodes_disabled_last_used_download_directory_setting() {
let stored = json!({
"state": {
"rememberLastUsedDownloadDirectory": false
},
"version": 3
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert!(!settings.remember_last_used_download_directory);
}
#[test]
fn ignores_legacy_extension_pairing_token_field() {
// Older standard installs persisted `extensionPairingToken` as
+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, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: 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, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
+68 -8
View File
@@ -15,9 +15,11 @@ import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResoluti
import { canonicalizeDownloadFileName, categoryForFileName } from '../utils/downloads';
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
import {
expandTilde,
resolveCategoryDestination,
resolveDownloadFilePath,
downloadLocationEquals
downloadLocationEquals,
resolveInitialAddWindowLocation
} from '../utils/downloadLocations';
import { getPlatformInfo } from '../utils/platform';
import { isTransferLocked } from '../utils/downloadActions';
@@ -130,6 +132,8 @@ export const AddDownloadsModal = () => {
} = useDownloadStore();
const {
baseDownloadFolder,
rememberLastUsedDownloadDirectory,
lastUsedDownloadDirectory,
perServerConnections,
keychainAccessReady,
keychainPromptDismissed,
@@ -158,6 +162,9 @@ export const AddDownloadsModal = () => {
// Right Form
const [saveLocation, setSaveLocation] = useState(baseDownloadFolder);
const [isSaveLocationManual, setIsSaveLocationManual] = useState(false);
const locationResolutionRequestRef = useRef(0);
const folderPickerRequestRef = useRef(0);
const pendingLastUsedDownloadDirectoryRef = useRef<string | null>(null);
const [connections, setConnections] = useState(perServerConnections);
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
const [speedLimit, setSpeedLimit] = useState('1024');
@@ -222,6 +229,8 @@ export const AddDownloadsModal = () => {
useEffect(() => {
if (!isAddModalOpen) {
modalSessionRef.current = false;
++folderPickerRequestRef.current;
pendingLastUsedDownloadDirectoryRef.current = null;
setUrls('');
setPlaylistExpansions({});
playlistRequestsRef.current.clear();
@@ -238,8 +247,13 @@ export const AddDownloadsModal = () => {
? requestContextForUrl(initialUrlLines[0])
: undefined;
setSaveLocation(baseDownloadFolder);
setIsSaveLocationManual(false);
const initialLocation = resolveInitialAddWindowLocation(
baseDownloadFolder,
rememberLastUsedDownloadDirectory,
lastUsedDownloadDirectory
);
setSaveLocation(initialLocation.path);
setIsSaveLocationManual(initialLocation.isManual);
setUrls(initialUrls);
setParsedItems([]);
setPlaylistExpansions({});
@@ -279,6 +293,8 @@ export const AddDownloadsModal = () => {
pendingAddCookies,
pendingAddMediaUrls,
baseDownloadFolder,
rememberLastUsedDownloadDirectory,
lastUsedDownloadDirectory,
perServerConnections
]);
@@ -609,6 +625,13 @@ export const AddDownloadsModal = () => {
]);
useEffect(() => {
if (!rememberLastUsedDownloadDirectory) {
pendingLastUsedDownloadDirectoryRef.current = null;
}
}, [rememberLastUsedDownloadDirectory]);
useEffect(() => {
const requestId = ++locationResolutionRequestRef.current;
if (parsedItems.length === 0) {
setSelectedItemIndex(null);
return;
@@ -618,7 +641,12 @@ export const AddDownloadsModal = () => {
);
if (isSaveLocationManual) return;
if (parsedItems.length > 1) {
setSaveLocation(useSettingsStore.getState().baseDownloadFolder || '~/Downloads');
const baseFolder = useSettingsStore.getState().baseDownloadFolder || '~/Downloads';
void expandTilde(baseFolder).then(location => {
if (requestId === locationResolutionRequestRef.current) {
setSaveLocation(location);
}
});
return;
}
const first = parsedItems[0];
@@ -626,22 +654,35 @@ export const AddDownloadsModal = () => {
void resolveCategoryDestination(
useSettingsStore.getState(),
categoryForFileName(first.file)
).then(setSaveLocation);
).then(location => {
if (requestId === locationResolutionRequestRef.current) {
setSaveLocation(location);
}
});
}, [isSaveLocationManual, parsedItems]);
if (!isAddModalOpen) return null;
const handleBrowse = async () => {
try {
const requestId = ++folderPickerRequestRef.current;
const defaultPath = await expandTilde(saveLocation);
const selected = await open({
directory: true,
multiple: false,
defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation
defaultPath
});
if (requestId !== folderPickerRequestRef.current) return;
if (selected && typeof selected === 'string') {
++locationResolutionRequestRef.current;
const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected);
if (requestId !== folderPickerRequestRef.current) return;
setSaveLocation(approvedPath);
setIsSaveLocationManual(true);
const settings = useSettingsStore.getState();
if (settings.rememberLastUsedDownloadDirectory) {
pendingLastUsedDownloadDirectoryRef.current = approvedPath;
}
}
} catch (e) {
console.error("Failed to select folder:", e);
@@ -663,6 +704,7 @@ export const AddDownloadsModal = () => {
}
isSubmittingRef.current = true;
setIsSubmitting(true);
++folderPickerRequestRef.current;
let finalLocation = saveLocation;
let useSharedDestination = isSaveLocationManual;
const destinationOverrides: Record<number, string> = {};
@@ -679,18 +721,24 @@ export const AddDownloadsModal = () => {
directory: true,
multiple: false,
title: `Choose a folder for ${item.file}`,
defaultPath: suggestedLocation.startsWith('~') ? undefined : suggestedLocation
defaultPath: await expandTilde(suggestedLocation)
});
if (selected && typeof selected === 'string') {
const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected);
destinationOverrides[index] = approvedPath;
const currentSettings = useSettingsStore.getState();
if (currentSettings.rememberLastUsedDownloadDirectory) {
pendingLastUsedDownloadDirectoryRef.current = approvedPath;
}
} else {
pendingLastUsedDownloadDirectoryRef.current = null;
isSubmittingRef.current = false;
setIsSubmitting(false);
return;
}
} catch (e) {
console.error("Failed to select folder:", e);
pendingLastUsedDownloadDirectoryRef.current = null;
isSubmittingRef.current = false;
setIsSubmitting(false);
return;
@@ -981,6 +1029,17 @@ export const AddDownloadsModal = () => {
failures.push(`${item.file}: ${e instanceof Error ? e.message : String(e)}`);
}
}
const currentSettings = useSettingsStore.getState();
if (
addedCount > 0
&& currentSettings.rememberLastUsedDownloadDirectory
&& pendingLastUsedDownloadDirectoryRef.current
) {
currentSettings.setLastUsedDownloadDirectory(
pendingLastUsedDownloadDirectoryRef.current
);
}
pendingLastUsedDownloadDirectoryRef.current = null;
toggleAddModal(false);
if (failures.length > 0) {
addToast({
@@ -1357,7 +1416,8 @@ export const AddDownloadsModal = () => {
/>
<button
onClick={handleBrowse}
className="add-download-button add-download-button-secondary px-3 text-xs font-medium"
disabled={isSubmitting}
className="add-download-button add-download-button-secondary px-3 text-xs font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{t($ => $.addDownloads.browse)}
</button>
+15
View File
@@ -1069,6 +1069,21 @@ runEngineChecks(false);
</label>
</div>
<div className="mac-settings-group">
<label className="mac-settings-row cursor-default">
<div>
<span className="text-[13px] text-text-primary">{t($ => $.settings.locations.rememberLastUsedDownloadDirectory)}</span>
<p className="mt-0.5 text-[11px] text-text-muted">{t($ => $.settings.locations.rememberLastUsedDownloadDirectoryDescription)}</p>
</div>
<input
type="checkbox"
checked={settings.rememberLastUsedDownloadDirectory}
onChange={(e) => settings.setRememberLastUsedDownloadDirectory(e.target.checked)}
className="mac-switch"
/>
</label>
</div>
<div className="mac-settings-group">
<label className="mac-settings-row cursor-default">
<div>
+2
View File
@@ -591,6 +591,8 @@ const common = {
baseFolderCreateFailed: 'Base folder saved, but category folders could not be created: {{detail}}',
baseFolderUpdated: 'Base download folder updated',
askWhereToSave: 'Ask where to save when adding downloads',
rememberLastUsedDownloadDirectory: 'Remember the last used download directory',
rememberLastUsedDownloadDirectoryDescription: 'Use the last folder selected in the Add window for the next download until the app restarts.',
automaticCategorySubfolders: 'Automatically save files to category subfolders',
automaticCategorySubfoldersDescription: 'When off, automatic downloads use the base folder.',
categorySubfolders: 'Category Subfolders',
+26
View File
@@ -168,6 +168,9 @@ export interface SettingsState {
categorySubfoldersEnabled: boolean;
categorySubfolders: Record<string, string>;
categoryDirectoryOverrides: Record<string, string>;
rememberLastUsedDownloadDirectory: boolean;
/** Session-only path selected in the Add window; intentionally not persisted. */
lastUsedDownloadDirectory: string | null;
approvedDownloadRoots: string[];
maxConcurrentDownloads: number;
globalSpeedLimit: string;
@@ -245,6 +248,8 @@ export interface SettingsState {
setAskWhereToSaveEachFile: (ask: boolean) => void;
setPreventsSleepWhileDownloading: (prevent: boolean) => void;
setMediaCookieSource: (source: MediaCookieSource) => void;
setRememberLastUsedDownloadDirectory: (enabled: boolean) => void;
setLastUsedDownloadDirectory: (path: string) => void;
setCategorySubfoldersEnabled: (enabled: boolean) => void;
setCategorySubfolder: (category: string, subfolder: string) => void;
setCategoryDirectoryOverride: (category: string, path?: string) => void;
@@ -268,6 +273,8 @@ export const useSettingsStore = create<SettingsState>()(
categorySubfoldersEnabled: true,
categorySubfolders: { ...DEFAULT_CATEGORY_SUBFOLDERS },
categoryDirectoryOverrides: {},
rememberLastUsedDownloadDirectory: true,
lastUsedDownloadDirectory: null,
approvedDownloadRoots: [],
maxConcurrentDownloads: 3,
globalSpeedLimit: '',
@@ -390,6 +397,18 @@ export const useSettingsStore = create<SettingsState>()(
set({ preventsSleepWhileDownloading });
},
setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); },
setRememberLastUsedDownloadDirectory: (rememberLastUsedDownloadDirectory) => {
info('Settings updated: rememberLastUsedDownloadDirectory');
set({
rememberLastUsedDownloadDirectory,
...(rememberLastUsedDownloadDirectory ? {} : { lastUsedDownloadDirectory: null })
});
},
setLastUsedDownloadDirectory: (path) => {
const trimmedPath = path.trim();
if (!trimmedPath) return;
set({ lastUsedDownloadDirectory: trimmedPath });
},
setCategorySubfoldersEnabled: (categorySubfoldersEnabled) => {
info('Settings updated: categorySubfoldersEnabled');
set({ categorySubfoldersEnabled });
@@ -515,6 +534,7 @@ export const useSettingsStore = create<SettingsState>()(
categorySubfoldersEnabled: state.categorySubfoldersEnabled,
categorySubfolders: state.categorySubfolders,
categoryDirectoryOverrides: state.categoryDirectoryOverrides,
rememberLastUsedDownloadDirectory: state.rememberLastUsedDownloadDirectory,
approvedDownloadRoots: state.approvedDownloadRoots,
maxConcurrentDownloads: state.maxConcurrentDownloads,
globalSpeedLimit: state.globalSpeedLimit,
@@ -562,6 +582,8 @@ export const useSettingsStore = create<SettingsState>()(
...persisted,
...locations,
extensionPairingToken: currentState.extensionPairingToken,
// Never hydrate the remembered Add-window path from persisted data.
lastUsedDownloadDirectory: currentState.lastUsedDownloadDirectory,
keychainAccessReady: currentState.keychainAccessReady,
theme: isAllowedSetting(THEME_VALUES, persisted.theme)
? persisted.theme
@@ -587,6 +609,10 @@ export const useSettingsStore = create<SettingsState>()(
persisted.autoAddClipboardLinks,
currentState.autoAddClipboardLinks
),
rememberLastUsedDownloadDirectory: persistedBoolean(
persisted.rememberLastUsedDownloadDirectory,
currentState.rememberLastUsedDownloadDirectory
),
showDockBadge: persistedBoolean(persisted.showDockBadge, currentState.showDockBadge),
showMenuBarIcon: persistedBoolean(persisted.showMenuBarIcon, currentState.showMenuBarIcon),
askWhereToSaveEachFile: persistedBoolean(
+20
View File
@@ -14,6 +14,7 @@ import {
formatDerivedCategoryPath,
normalizeCategorySubfolder,
normalizeDownloadLocationSettings,
resolveInitialAddWindowLocation,
resolveCategoryDestination,
subfolderFromDerivedCategoryPath
} from './downloadLocations';
@@ -24,6 +25,25 @@ describe('download locations', () => {
expect(downloadLocationEquals('/Users/Test', 'Movie.MP4', '/users/test', 'movie.mp4', 'macos')).toBe(false);
expect(downloadLocationEquals('/home/Test', 'Movie.MP4', '/home/test', 'movie.mp4', 'linux')).toBe(false);
});
it('uses a remembered Add-window directory only when the setting is enabled', () => {
expect(resolveInitialAddWindowLocation(
'D:\\Downloads',
true,
'D:\\Course_Videos'
)).toEqual({ path: 'D:\\Course_Videos', isManual: true });
expect(resolveInitialAddWindowLocation(
'D:\\Downloads',
false,
'D:\\Course_Videos'
)).toEqual({ path: 'D:\\Downloads', isManual: false });
});
it('falls back to the normalized base folder when no directory was remembered', () => {
expect(resolveInitialAddWindowLocation(' ', true, null))
.toEqual({ path: '~/Downloads', isManual: false });
});
beforeEach(() => {
vi.clearAllMocks();
});
+20
View File
@@ -48,6 +48,26 @@ interface LegacyDownloadLocationSettings {
downloadDirectories?: unknown;
}
export interface AddWindowLocationSuggestion {
path: string;
isManual: boolean;
}
export const resolveInitialAddWindowLocation = (
baseDownloadFolder: string,
rememberLastUsedDownloadDirectory: boolean,
lastUsedDownloadDirectory: string | null
): AddWindowLocationSuggestion => {
const rememberedPath = rememberLastUsedDownloadDirectory
? lastUsedDownloadDirectory?.trim()
: undefined;
const basePath = baseDownloadFolder.trim() || '~/Downloads';
return {
path: rememberedPath || basePath,
isManual: Boolean(rememberedPath)
};
};
const stringRecord = (value: unknown): Record<string, string> => {
if (!value || typeof value !== 'object') return {};
return Object.fromEntries(