From f3b332c484c902b011923eba4e4a0b9c78587e45 Mon Sep 17 00:00:00 2001 From: Bruno-Alumn <171815746+Bruno-Alumn@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:24:46 +0100 Subject: [PATCH 1/3] =?UTF-8?q?LRC-LIB-FOLDERS:=20Move=20Folder=20To?= =?UTF-8?q?=E2=80=A6=20asks=20for=20the=20destination=20without=20freezing?= =?UTF-8?q?=20the=20window=20(#3140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Folder To… in the Folders and Local menus opened the folder dialog synchronously on the UI thread, so the window stopped drawing until it closed, and on Linux X11 the app could hang. Both menus now go through the shared picker, like Import and Move to Folder…: the dialog runs off the UI thread where the host can do that and folder.move runs when it closes. The "Moved; N photo(s) relinked" message is shown either way, now translated in both menus. --- crates/ui-egui/src/panels/left/folders.rs | 48 +++++--- crates/ui-egui/src/pick.rs | 18 ++- crates/ui-egui/src/tests.rs | 1 + crates/ui-egui/src/tests/move_folder.rs | 138 ++++++++++++++++++++++ docs/parity-checklist.md | 2 +- docs/whats-new.md | 8 ++ 6 files changed, 193 insertions(+), 22 deletions(-) create mode 100644 crates/ui-egui/src/tests/move_folder.rs diff --git a/crates/ui-egui/src/panels/left/folders.rs b/crates/ui-egui/src/panels/left/folders.rs index 20c1bbce1..6e6d8a651 100644 --- a/crates/ui-egui/src/panels/left/folders.rs +++ b/crates/ui-egui/src/panels/left/folders.rs @@ -79,15 +79,7 @@ pub(super) fn folder_tree( key: "name".into(), }); } - if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() { - let into = app.services.pick_folder.as_mut().and_then(|f| f()); - if let Some(into) = into { - match app.run("folder.move", json!({"path": path, "into": into})) { - Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved; {} photo(s) relinked", r["relinked"])), - Err(e) => app.toast(ui.ctx(), e), - } - } - } + move_folder_item(app, ui, path); if app.services.reveal.is_some() && ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked() && let Some(f) = app.services.reveal.as_mut() @@ -560,6 +552,33 @@ fn library_node_for(tree: &[FolderNode], path: &str) -> Option<(FolderNode, Stri Some((node, name)) } +/// 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`]). +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…")); + register(ui, "folderMoveTo", r.rect); + if !r.clicked() { + 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])); + if let crate::pick::Picked::Now(v) = picked + && let Some(into) = v.into_iter().next() + { + let said = app.run("folder.move", json!({"path": path, "into": into})).map(|r| crate::pick::answered_message("folder.move", &r)); + match said { + Ok(Some(text)) | Err(text) => app.toast(ui.ctx(), text), + Ok(None) => {} + } + } + ui.close(); +} + /// Synchronize Folder… of a Folders row (widget `folderSynchronize`): opens its dialog, which /// scans the folder in the background. fn synchronize_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, name: &str) { @@ -609,16 +628,7 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F }); ui.close(); } - if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move Folder To…")).clicked() { - let into = app.services.pick_folder.as_mut().and_then(|f| f()); - if let Some(into) = into { - match app.run("folder.move", json!({"path": path, "into": into})) { - Ok(r) => app.toast(ui.ctx(), format!("Moved; {} photo(s) relinked", r["relinked"])), - Err(e) => app.toast(ui.ctx(), e), - } - } - ui.close(); - } + move_folder_item(app, ui, path); if ui.button(crate::i18n::tr("New Folder on Disk…")).clicked() { app.ui.dialog = Some(crate::state::Dialog::TextPrompt { title: crate::i18n::tr_format!("New Folder in “{name}”", name = label), diff --git a/crates/ui-egui/src/pick.rs b/crates/ui-egui/src/pick.rs index 7ee3853ed..b9d58cee6 100644 --- a/crates/ui-egui/src/pick.rs +++ b/crates/ui-egui/src/pick.rs @@ -152,12 +152,26 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { app.pending_picks = waiting; for (p, paths) in answered { let params = with_answer(&p.params, p.key, p.kind, &paths); - if let Err(e) = app.run(&p.command, params) { - app.toast_error(ctx, e); + match app.run(&p.command, params) { + Ok(r) => { + if let Some(text) = answered_message(&p.command, &r) { + app.toast(ctx, text); + } + } + Err(e) => app.toast_error(ctx, e), } } } +/// What `command`, run with a dialog's answer, says when it worked (`None`: its result shows +/// for itself). The same words whether the dialog answered at once or later. +pub fn answered_message(command: &str, result: &Value) -> Option { + match command { + "folder.move" => Some(crate::i18n::tr_format!("Moved; {} photo(s) relinked", result["relinked"])), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 3a102a29a..607d8b4ff 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -51,6 +51,7 @@ mod masking_layout; mod menubar; mod merge_preview; mod model_setup; +mod move_folder; mod offline; mod open_lightroom; pub(super) mod panels; diff --git a/crates/ui-egui/src/tests/move_folder.rs b/crates/ui-egui/src/tests/move_folder.rs new file mode 100644 index 000000000..9e6797c76 --- /dev/null +++ b/crates/ui-egui/src/tests/move_folder.rs @@ -0,0 +1,138 @@ +//! Move Folder To… in a folder's menu (issue #3140): the folder dialog runs off the UI thread +//! where the host can show it there, as the other file dialogs do (#191). + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc::{Sender, channel}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::pick::{PickKind, PickRequest}; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// A scratch folder that goes away with the test, however it ends. +struct Scratch(std::path::PathBuf); + +impl Scratch { + fn new(tag: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("lc-ui-move-folder-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } + /// The path as the folder tree writes it (forward slashes; widget ids carry it). + fn path(&self, rel: &str) -> String { + self.0.join(rel).to_string_lossy().replace('\\', "/") + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn write_png(path: &str, seed: u8) { + let img = lightcraft_raster::Rgba8::from_fn(24, 16, |x, y| [(x * 9) as u8, (y * 13) as u8, seed, 255]); + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::create_dir_all(std::path::Path::new(path).parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +type Asked = Arc>)>>>; + +/// The app with `trip/a.png` imported, a host that shows dialogs elsewhere (each request is +/// recorded with the sender that answers it), and a synchronous folder dialog that only counts +/// how often it was opened on the UI thread. +fn app_with_trip(dir: &Scratch) -> (Headless, Asked, Arc) { + write_png(&dir.path("trip/a.png"), 1); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.execute("library.import", &json!({"paths": [dir.path("trip")]})).unwrap(); + let mut app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + let asked: Asked = Default::default(); + let a = asked.clone(); + app.services.picker = Some(Box::new(move |req| { + let (tx, rx) = channel(); + a.lock().unwrap().push((req, tx)); + Ok(rx) + })); + let blocking = Arc::new(AtomicUsize::new(0)); + let b = blocking.clone(); + app.services.pick_folder = Some(Box::new(move || { + b.fetch_add(1, Ordering::Relaxed); + None + })); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + (h, asked, blocking) +} + +/// Right-click the row `row` in the sidebar, scrolled into view first. +fn open_row_menu(h: &mut Headless, row: &str) { + assert!(h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| w == row)), "no row {row}"); + let rect = |h: &Headless, id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r); + let (Some(before), Some(panel)) = (rect(h, row), rect(h, "panel:left_panel")) else { panic!("no row {row} or left panel") }; + if before.intersect(panel).height() < before.height() - 0.5 { + crate::tests_panels::scroll_sidebar(h, panel.center().y - before.center().y); + } + let c = rect(h, row).expect("the row").center(); + let r = h.request("ui.click", json!({"x": c.x, "y": c.y, "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +/// Move Folder To… in the Folders and Local sections asks for the destination off the UI thread, +/// never with the blocking dialog, then moves the folder with the answer and says so. +#[test] +fn move_folder_to_asks_off_the_ui_thread_and_moves_with_the_answer() { + let dir = Scratch::new("folders"); + std::fs::create_dir_all(dir.path("dest")).unwrap(); + let (mut h, asked, blocking) = app_with_trip(&dir); + 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!(blocking.load(Ordering::Relaxed), 0, "the UI thread never waits on a dialog"); + { + let asked = asked.lock().unwrap(); + assert_eq!(asked.len(), 1, "one dialog"); + assert_eq!(asked[0].0.kind, PickKind::Folder); + } + assert!(std::path::Path::new(&dir.path("trip/a.png")).exists(), "nothing moves before the answer"); + // the user chooses the destination: the folder moves there and its photo follows + asked.lock().unwrap()[0].1.send(vec![dir.path("dest")]).unwrap(); + h.settle(SETTLE); + assert!(h.app.pending_picks.is_empty()); + assert!(std::path::Path::new(&dir.path("dest/trip/a.png")).exists(), "moved on disk"); + assert!(!std::path::Path::new(&dir.path("trip")).exists()); + let moved = h.app.session.catalog.photos().filter(|p| p.in_library()).all(|p| match &p.source { + lightcraft_catalog::Source::File { path } => path.replace('\\', "/").starts_with(&dir.path("dest/trip")), + lightcraft_catalog::Source::Demo { .. } => false, + }); + assert!(moved, "the photo is relinked"); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("1 photo(s) relinked"), "{toast:?}"); + + // the same from Local (paths written the way the OS does): back to where it was + let moved_trip = dir.0.join("dest").join("trip").to_string_lossy().to_string(); + let r = h.request("engine.execute", json!({"command": "local.addRoot", "params": {"path": moved_trip}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + open_row_menu(&mut h, &format!("source:local:{moved_trip}")); + let r = h.request("ui.clickWidget", json!({"id": "folderMoveTo"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(blocking.load(Ordering::Relaxed), 0); + assert_eq!(asked.lock().unwrap().len(), 2, "its own dialog"); + asked.lock().unwrap()[1].1.send(vec![dir.0.to_string_lossy().to_string()]).unwrap(); + h.settle(SETTLE); + assert!(std::path::Path::new(&dir.path("trip/a.png")).exists(), "moved back"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 90cfc248f..f6c601ad1 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -702,7 +702,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `cmd:file.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `crates/ui-egui/src/dialogs/lightroom_destination.rs`, `crates/ui-egui/src/tests/lightroom_destination.rs`, `docs/lightroom-catalog-import.md` | before the import starts, File ▸ Import Lightroom Catalog… asks where the catalog goes: merge into the open library (named, with its photo count; preselected when it is empty) or create a new library (named after the catalog file, next to the open library, both editable), which is made and opened following File ▸ New Library…'s rules before the import; Cancel does nothing; Settings ▸ General ▸ "When importing a Lightroom catalog" (Ask where to import it / Always merge into the open library) or the window's "Don't ask again" turns it off; `file.importLightroom` takes `into` (`open` / `new`), `name`, `location` to choose without the window; a session in memory merges without asking (issue #2672); read-only `.lrcat` plus committed WAL pages (also WAL-only tables with main schema format 0), in-tree read-only Rust SQLite B-tree reader; on-demand main-file pages up to 16 GiB with existing table/record bounds (WAL still capped at 1 GiB, issue #798); background inspection/import with progress/cancellation, per-open library identity checks; persistent result with destination library, offline-original counts and Show in Library, dismissed when switching libraries; offline Local records are promoted without probing missing files; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). An image whose file, folder or root folder record is missing from the catalog, or whose original path is empty, is skipped with its virtual copies instead of failing the whole import; the report lists them as data (`skipped`: image id and reason) and the result window counts them in the UI language (PR #2230). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | | LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/dialogs/settings.rs`, `crates/ui-egui/src/panels/left.rs`, `crates/ui-egui/src/panels/library_problem.rs` (`launch_plan`, `show_chooser`), `crates/ui-egui/src/tests/launch_library.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Remove from List ▸ one entry, or `app.recentLibraries {remove}`, which forgets only the path; Clear), the open library's name under My Photos and in the window title (issue #902); Open/New refused during imports and their folder scans; a library that can't be opened leaves the current one and its reference pin intact; a successful switch clears the catalog-local reference pin; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests/keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key; Settings ▸ General ▸ At launch: Open the last library (default) or Ask me, a window listing the recent libraries (name, folder, when last opened; missing, unreadable or no-longer-library ones greyed out with the reason, checked read-only — nothing is locked or written, so a library another program has open is reported, with who has it, when Open is pressed; the last one selected; Open, Open Another…, New Library…, Remove from List, Quit; ↑ ↓ Return Esc), also shown once when Alt/Option is held in the first 2 s after the window appears (macOS reports an Option key held since before launch only once it changes); `--library` and `LIGHTCRAFT_LIBRARY` come first and `lightcraft-cli` never asks (issue #1290) | -| LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | +| LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`), `crates/ui-egui/src/tests/move_folder.rs` | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken); Move Folder To… (here and in Folders) asks for the destination in a folder dialog that doesn't hold up the window (issue #3140) | | LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests/panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes (16 pt while the deepest open row of any sidebar tree fits the panel with room for its name and count; narrower in bands of 14, 12, 10 and 8 pt when it would not, the same for every tree, chosen from the last frame; issue #2298): past 8 pt the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu, and photos dragged from the grid onto a Folders row: the row lights up, the dragged label says “Move 3 photos to “Trip””, a closed folder held under the pointer opens, a row covering several disks and Local photos are refused with the reason, photos already there say so; a drop asks first (“Move 2 photos to “Porto”?”, Don't ask again, Settings → Confirm before moving photos to another folder on disk), and photos that leave the view leave the selection; files and XMP sidecars move, virtual copies follow, across volumes copied (streamed) and verified before the source goes, a name taken in the destination refuses the whole move, one undo step; an undo with the share offline or read-only is refused with the reason and kept until it can run, nothing deleted), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; a folder with none of these changes only says the library is up to date with it, with one Close and no choices, and closing it starts no job; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files stops when the scan is cancelled, and its depth/entry limits are only logged | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/dialogs/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/widgets/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/widgets/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c259200..210f6d96e 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,13 @@ # What's new in LightCraft +## 2026-10-29 + +### Move Folder To… no longer freezes the window +- Move Folder To…, in the menu of a folder in Folders or Local, now opens its folder dialog the way Import and Move to + 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. + ## 2026-10-24 ### Relative-date filters and smart albums follow the clock From 5d92a471debf5afd348f0d74b98ad4523af0c40c Mon Sep 17 00:00:00 2001 From: Bruno-Alumn <171815746+Bruno-Alumn@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:17:08 +0100 Subject: [PATCH 2/3] =?UTF-8?q?LRC-LIB-FOLDERS:=20no=20library=20switch=20?= =?UTF-8?q?while=20Move=20Folder=20To=E2=80=A6=20waits=20for=20its=20desti?= =?UTF-8?q?nation=20(#3140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui-egui/src/dialogs/settings/libraries.rs | 8 +++- crates/ui-egui/src/tests/move_folder.rs | 43 +++++++++++++++++++ docs/whats-new.md | 3 +- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/settings/libraries.rs b/crates/ui-egui/src/dialogs/settings/libraries.rs index 8e41e8dd4..6f63530c1 100644 --- a/crates/ui-egui/src/dialogs/settings/libraries.rs +++ b/crates/ui-egui/src/dialogs/settings/libraries.rs @@ -3,7 +3,10 @@ use super::{LightcraftApp, Value, json}; // ------------------------------------------------------------------------------- Open Library… /// Switching libraries is refused while an import (or the folder scan before one) is running: its -/// remaining batches would be committed into the new library (issue #1509). +/// remaining batches would be committed into the new library (issue #1509). So is a switch while +/// Move Folder To… waits for its destination: the move runs when the dialog closes, and in another +/// library it would move the folder on disk with nothing to relink, leaving this library's photos +/// missing (issue #3140). pub(crate) fn can_switch_library(app: &LightcraftApp) -> Result<(), String> { if crate::lightroom_import::is_running(app) { return Err("wait for Lightroom catalog import to finish before switching libraries".into()); @@ -11,6 +14,9 @@ pub(crate) fn can_switch_library(app: &LightcraftApp) -> Result<(), String> { if app.import.is_some() || app.scan.is_some() { return Err("wait for the import to finish before switching libraries".into()); } + if app.pending_picks.iter().any(|p| p.command == "folder.move") { + return Err("wait for Move Folder To… to finish before switching libraries".into()); + } Ok(()) } diff --git a/crates/ui-egui/src/tests/move_folder.rs b/crates/ui-egui/src/tests/move_folder.rs index 9e6797c76..58358c251 100644 --- a/crates/ui-egui/src/tests/move_folder.rs +++ b/crates/ui-egui/src/tests/move_folder.rs @@ -50,8 +50,16 @@ type Asked = Arc>)>>>; /// recorded with the sender that answers it), and a synchronous folder dialog that only counts /// how often it was opened on the UI thread. fn app_with_trip(dir: &Scratch) -> (Headless, Asked, Arc) { + app_with_trip_in(dir, None) +} + +/// [`app_with_trip`], with the photo imported into the library at `library` (`None`: in memory). +fn app_with_trip_in(dir: &Scratch, library: Option<&str>) -> (Headless, Asked, Arc) { write_png(&dir.path("trip/a.png"), 1); let mut session = lightcraft_engine::Session::new().with_fs(); + if let Some(library) = library { + session.open_library(library, false).unwrap(); + } session.execute("library.import", &json!({"paths": [dir.path("trip")]})).unwrap(); let mut app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); let asked: Asked = Default::default(); @@ -136,3 +144,38 @@ fn move_folder_to_asks_off_the_ui_thread_and_moves_with_the_answer() { h.settle(SETTLE); assert!(std::path::Path::new(&dir.path("trip/a.png")).exists(), "moved back"); } + +/// Issue #3140: while Move Folder To… waits for its destination, the library can't be switched +/// (the move would run in the other library, moving the folder with nothing to relink). The answer +/// moves the folder in the library it was asked in; then switching works again. +#[test] +fn no_library_switch_while_move_folder_to_waits_for_its_destination() { + let dir = Scratch::new("switch"); + std::fs::create_dir_all(dir.path("dest")).unwrap(); + let (first, other) = (dir.path("First"), dir.path("Other")); + let (mut h, asked, _) = app_with_trip_in(&dir, Some(&first)); + 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(), 1, "the dialog is up"); + let library = |h: &Headless| h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().replace('\\', "/")); + // another library (from the control channel or an agent, say) is refused meanwhile + let e = h.app.run("app.openLibrary", json!({"path": other})).expect_err("no switch while the dialog is up"); + assert!(e.contains("Move Folder To…"), "{e}"); + assert_eq!(library(&h), Some(first.clone()), "the first library stays open"); + // the answer moves the folder there, and its photo follows + asked.lock().unwrap()[0].1.send(vec![dir.path("dest")]).unwrap(); + h.settle(SETTLE); + assert!(h.app.pending_picks.is_empty()); + assert!(std::path::Path::new(&dir.path("dest/trip/a.png")).exists(), "moved on disk"); + assert_eq!(library(&h), Some(first)); + let relinked = h.app.session.catalog.photos().filter(|p| p.in_library()).all(|p| match &p.source { + lightcraft_catalog::Source::File { path } => path.replace('\\', "/").starts_with(&dir.path("dest/trip")), + lightcraft_catalog::Source::Demo { .. } => false, + }); + assert!(relinked, "the photo is relinked in the first library"); + // and now the switch goes through + h.app.run("app.openLibrary", json!({"path": other})).expect("the switch works once the move is done"); + assert_eq!(library(&h), Some(other)); +} diff --git a/docs/whats-new.md b/docs/whats-new.md index 210f6d96e..9c197f39c 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -6,7 +6,8 @@ - Move Folder To…, in the menu of a folder in Folders or Local, now opens its folder dialog the way Import and Move to 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. + 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). ## 2026-10-24 From 8b3d56142a68841b5cb1c072187604c83bd1c268 Mon Sep 17 00:00:00 2001 From: Bruno-Alumn <171815746+Bruno-Alumn@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:22:08 +0100 Subject: [PATCH 3/3] =?UTF-8?q?LRC-LIB-FOLDERS:=20Move=20Folder=20To?= =?UTF-8?q?=E2=80=A6=20waits=20while=20Open=20Library=E2=80=A6=20or=20New?= =?UTF-8?q?=20Library=E2=80=A6=20asks=20for=20a=20folder=20(#3140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/panels/left/folders.rs | 21 +++++++++++++++- crates/ui-egui/src/tests/move_folder.rs | 30 +++++++++++++++++++++++ docs/whats-new.md | 3 ++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/left/folders.rs b/crates/ui-egui/src/panels/left/folders.rs index 6e6d8a651..d5b4d2195 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 58358c251..71ff3df62 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 9c197f39c..e121e5b33 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