mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
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:
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user