diff --git a/crates/ui-egui/src/panels/left/folders.rs b/crates/ui-egui/src/panels/left/folders.rs index 6e6d8a651a..d5b4d21956 100644 --- a/crates/ui-egui/src/panels/left/folders.rs +++ b/crates/ui-egui/src/panels/left/folders.rs @@ -552,18 +552,37 @@ fn library_node_for(tree: &[FolderNode], path: &str) -> Option<(FolderNode, Stri Some((node, name)) } +/// Move Folder To… can't start while Open Library… or New Library… waits for its folder: the +/// dialogs answer in the order they were opened ([`crate::pick::poll`]), so the library could +/// switch first and the move then run in the other library, moving the folder on disk with +/// nothing to relink (issue #3140). +fn can_move_folder(app: &LightcraftApp) -> Result<(), String> { + for (command, label) in [("app.openLibrary", "Open Library…"), ("app.newLibrary", "New Library…")] { + if app.pending_picks.iter().any(|p| p.command == command) { + return Err(format!("wait for {label} to finish before moving a folder")); + } + } + Ok(()) +} + /// Move Folder To… of a folder row (widget `folderMoveTo`): asks for the destination through the /// shared folder dialog, off the UI thread where the host has one (issue #3140: a dialog on the UI /// thread froze the window); `folder.move` then runs when it closes ([`crate::pick::poll`]). +/// Disabled while a library dialog is up ([`can_move_folder`]). fn move_folder_item(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) { if app.services.pick_folder.is_none() && app.services.picker.is_none() { return; } - let r = ui.button(crate::i18n::tr("Move Folder To…")); + let r = ui.add_enabled(can_move_folder(app).is_ok(), egui::Button::new(crate::i18n::tr("Move Folder To…"))); register(ui, "folderMoveTo", r.rect); if !r.clicked() { return; } + if let Err(e) = can_move_folder(app) { + app.toast(ui.ctx(), e); + ui.close(); + return; + } let req = crate::pick::PickRequest::folder(crate::i18n::tr("Move Folder To…")); let params = json!({"path": path}); let picked = crate::pick::ask(app, "folder.move", ¶ms, "into", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])); diff --git a/crates/ui-egui/src/tests/move_folder.rs b/crates/ui-egui/src/tests/move_folder.rs index 58358c2512..71ff3df620 100644 --- a/crates/ui-egui/src/tests/move_folder.rs +++ b/crates/ui-egui/src/tests/move_folder.rs @@ -179,3 +179,33 @@ fn no_library_switch_while_move_folder_to_waits_for_its_destination() { h.app.run("app.openLibrary", json!({"path": other})).expect("the switch works once the move is done"); assert_eq!(library(&h), Some(other)); } + +/// Issue #3140: while Open Library… waits for its folder, Move Folder To… can't start (both +/// dialogs answer in the order opened, so the library could switch before the move ran, which +/// would then move the folder in the other library with nothing to relink): the item is disabled, +/// no destination is asked for, and the folder stays. Once the library dialog is cancelled it works. +#[test] +fn move_folder_to_waits_for_open_library_to_finish() { + let dir = Scratch::new("after-open"); + std::fs::create_dir_all(dir.path("dest")).unwrap(); + let (mut h, asked, blocking) = app_with_trip(&dir); + assert_eq!(h.app.run("app.openLibrary", json!({})).ok(), Some(serde_json::Value::Null)); + assert_eq!(asked.lock().unwrap().len(), 1, "the Open Library dialog is up"); + open_row_menu(&mut h, &format!("source:libfolder:{}", dir.path("trip"))); + let _ = h.request("ui.clickWidget", json!({"id": "folderMoveTo"}), T); + h.settle(SETTLE); + assert_eq!(asked.lock().unwrap().len(), 1, "no destination is asked for"); + assert_eq!(blocking.load(Ordering::Relaxed), 0, "not on the UI thread either"); + assert!(!h.app.pending_picks.iter().any(|p| p.command == "folder.move"), "nothing waits to move"); + assert!(std::path::Path::new(&dir.path("trip/a.png")).exists(), "the folder stays"); + // the Open Library dialog is cancelled: Move Folder To… asks for its destination again + asked.lock().unwrap()[0].1.send(vec![]).unwrap(); + h.settle(SETTLE); + assert!(h.app.pending_picks.is_empty()); + open_row_menu(&mut h, &format!("source:libfolder:{}", dir.path("trip"))); + let r = h.request("ui.clickWidget", json!({"id": "folderMoveTo"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(asked.lock().unwrap().len(), 2, "the destination is asked for"); + assert!(h.app.pending_picks.iter().any(|p| p.command == "folder.move")); +} diff --git a/docs/whats-new.md b/docs/whats-new.md index 9c197f39c0..e121e5b33a 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -7,7 +7,8 @@ Folder… do: the window keeps drawing and answering while you choose, and the folder moves when the dialog closes, with the same "Moved; N photo(s) relinked" message. Before, the dialog held up the whole window until it closed; on Linux with X11 the app could stop responding altogether. While the dialog is open, another library can't be - opened (the move belongs to the library it was started in). + opened (the move belongs to the library it was started in), and while Open Library… or New Library… waits for + its folder, Move Folder To… is unavailable. ## 2026-10-24