diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ba635d5..9b008a2 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -630,6 +630,23 @@ fn data_dir_has_documents(dir: String) -> bool { PathBuf::from(dir).join("documents.json").exists() } +/// The folder the user last chose in the native picker. `migrate_data_dir` +/// writes to and prunes whatever it is handed, and the frontend can name any +/// path over IPC, so the target has to be proven user-chosen. +#[derive(Default)] +struct PickedDataDir(Mutex>); + +#[tauri::command] +fn pick_data_dir(app: AppHandle) -> Result, String> { + let Some(picked) = app.dialog().file().blocking_pick_folder() else { + return Ok(None); + }; + let path = picked.into_path().map_err(|e| e.to_string())?; + let canon = fs::canonicalize(&path).map_err(|e| e.to_string())?; + *app.state::().0.lock().unwrap() = Some(canon); + Ok(Some(path.to_string_lossy().into_owned())) +} + /// strategy: "move" (copy mine, delete originals), "overwrite" (same, target /// had documents), "use_existing" (just switch — keep the target's files) #[tauri::command] @@ -639,12 +656,19 @@ fn migrate_data_dir( new_dir: String, strategy: String, ) -> Result<(), String> { - let old = docs_dir(&app, &old_dir); let new = PathBuf::from(&new_dir); - fs::create_dir_all(&new).map_err(|e| e.to_string())?; // canonicalize: "C:\Data" and "c:\data" are the same folder on Windows + let canon_new = fs::canonicalize(&new).map_err(|e| e.to_string())?; + let state = app.state::(); + { + let mut picked = state.0.lock().unwrap(); + if picked.as_deref() != Some(canon_new.as_path()) { + return Err("target folder was not chosen by the user".into()); + } + *picked = None; + } + let old = docs_dir(&app, &old_dir); let canon_old = fs::canonicalize(&old).unwrap_or_else(|_| old.clone()); - let canon_new = fs::canonicalize(&new).unwrap_or_else(|_| new.clone()); if canon_old == canon_new { return Ok(()); } @@ -675,8 +699,20 @@ fn migrate_data_dir( if old_docs.exists() { fs::remove_file(&old_docs).ok(); } + // one level at a time, files only — `old` comes from the frontend, and a + // recursive delete of anything named "backups" is too big a hammer to hand + // over. run_backups/backup_deleted_sheet only ever write flat files here. if all_copied { - fs::remove_dir_all(old.join("backups")).ok(); + for sub in ["backups", "backups/deleted"] { + if let Ok(entries) = fs::read_dir(old.join(sub)) { + for entry in entries.flatten() { + let p = entry.path(); + if p.is_file() { + fs::remove_file(&p).ok(); + } + } + } + } } Ok(()) } @@ -834,6 +870,7 @@ fn main() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .manage(DroppedFiles::default()) + .manage(PickedDataDir::default()) .invoke_handler(tauri::generate_handler![ save_file, load_file, @@ -843,6 +880,7 @@ fn main() { backup_deleted_sheet, open_backups_folder, data_dir_has_documents, + pick_data_dir, migrate_data_dir, fetch_rates, fetch_historical_rates, diff --git a/src/storage.test.ts b/src/storage.test.ts index 12a9dc2..35e00b2 100644 --- a/src/storage.test.ts +++ b/src/storage.test.ts @@ -1,16 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { isTauri, loadSettings, saveSettings, defaultSettingsData, loadAppData, saveAppData, flushAppData, setDataDir, runBackups, backupDeletedSheet, openBackupsFolder, chooseFolder, dataDirHasDocuments, migrateDataDir, fetchRates, fetchHistoricalRates } from './storage'; import { invoke } from '@tauri-apps/api/core'; -import { open } from '@tauri-apps/plugin-dialog'; vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), })); -vi.mock('@tauri-apps/plugin-dialog', () => ({ - open: vi.fn(), -})); - describe('storage', () => { beforeEach(() => { vi.resetAllMocks(); @@ -310,14 +305,14 @@ describe('storage', () => { it('returns path when in Tauri and folder is chosen', async () => { vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); - vi.mocked(open).mockResolvedValue('chosen_path'); + vi.mocked(invoke).mockResolvedValue('chosen_path'); expect(await chooseFolder()).toBe('chosen_path'); - expect(open).toHaveBeenCalledWith({ directory: true, multiple: false }); + expect(invoke).toHaveBeenCalledWith('pick_data_dir', undefined); }); it('returns null when in Tauri and folder choice is cancelled', async () => { vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); - vi.mocked(open).mockResolvedValue(null); + vi.mocked(invoke).mockResolvedValue(null); expect(await chooseFolder()).toBeNull(); }); }); diff --git a/src/storage.ts b/src/storage.ts index 833aab1..9544485 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -167,11 +167,10 @@ export async function openBackupsFolder(dataDir: string): Promise { if (isTauri()) await invoke("open_backups_folder", { dir: dirArg(dataDir) }); } +/** Rust-side picker: migrateDataDir only accepts a folder chosen through this. */ export async function chooseFolder(): Promise { if (!isTauri()) return null; - const { open } = await import("@tauri-apps/plugin-dialog"); - const picked = await open({ directory: true, multiple: false }); - return typeof picked === "string" ? picked : null; + return (await invoke("pick_data_dir")) ?? null; } export async function dataDirHasDocuments(dir: string): Promise {