diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index baf50eee..f93f14f1 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -55,6 +55,40 @@ fn add_to_library(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ + cmd!( + "folder.rename", + "Rename Folder", + [], + None, + "{path, name} — rename a folder on disk (its files and sidecars go along) and relink the photos in it; not an undo step → {path, relinked}", + always, + |s, p| { + let from = str_param(p, "path").ok_or_else(|| bad("folder.rename", "missing path"))?.trim_end_matches(['/', '\\']).to_string(); + let name = str_param(p, "name") + .map(str::trim) + .filter(|n| !n.is_empty() && !n.contains(['/', '\\']) && *n != "." && *n != "..") + .ok_or_else(|| bad("folder.rename", "give a folder name (no slashes)"))?; + let to = Path::new(&from).with_file_name(name).to_string_lossy().to_string(); + let n = move_folder(s, &from, &to).map_err(|e| bad("folder.rename", e))?; + Ok(json!({"path": to, "relinked": n})) + } + ), + cmd!( + "folder.move", + "Move Folder", + [], + None, + "{path, into: destination folder} — move a folder on disk into another and relink the photos in it; not an undo step → {path, relinked}", + always, + |s, p| { + let from = str_param(p, "path").ok_or_else(|| bad("folder.move", "missing path"))?.trim_end_matches(['/', '\\']).to_string(); + let into = str_param(p, "into").ok_or_else(|| bad("folder.move", "missing into"))?; + let name = Path::new(&from).file_name().ok_or_else(|| bad("folder.move", "bad path"))?; + let to = Path::new(into).join(name).to_string_lossy().to_string(); + let n = move_folder(s, &from, &to).map_err(|e| bad("folder.move", e))?; + Ok(json!({"path": to, "relinked": n})) + } + ), cmd!( "library.browse", "Browse Folder", @@ -67,3 +101,59 @@ pub fn specs() -> Vec { cmd!("photo.addToLibrary", "Add to My Photos", ["Photo"], None, "{ids?} — browsed (Local) photos join the library", always, add_to_library), ] } + +/// Rename or move a folder on disk (everything in it goes along, sidecars included) and relink +/// the photos inside. Like a rename in the file manager it is not an undo step: undoing would +/// point the photos back at a folder that no longer exists. +pub(crate) fn move_folder(s: &mut crate::Session, from: &str, to: &str) -> std::result::Result { + use std::path::Path; + let (src, dst) = (Path::new(from), Path::new(to)); + if !src.is_dir() { + return Err(format!("{from} is not a folder")); + } + if dst.exists() { + return Err(format!("{to} already exists")); + } + if dst.starts_with(src) { + return Err("a folder can't move into itself".into()); + } + if s.library.as_ref().is_some_and(|l| l.dir.starts_with(src) || src.starts_with(&l.dir)) { + return Err("the library's own folders can't be moved here".into()); + } + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; + } + std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}"))?; + let mut n = 0; + let ops: Vec = s + .catalog + .photos() + .filter_map(|p| match &p.source { + lightcraft_catalog::Source::File { path } => Path::new(path).strip_prefix(src).ok().map(|rel| (p.id, p.file_name.clone(), dst.join(rel))), + _ => None, + }) + .map(|(id, file_name, np)| { + n += 1; + lightcraft_catalog::Op::Relink { + id, + file_name, + source: lightcraft_catalog::Source::File { path: np.to_string_lossy().to_string() }, + format: None, + } + }) + .collect(); + for op in ops { + if s.catalog.apply(op.clone()).is_ok() { + s.pending_log.push(op); + } + } + // a browsed folder at or below it follows + if let Some(b) = &mut s.browse + && let Ok(rel) = Path::new(&b.path).strip_prefix(src) + { + b.path = dst.join(rel).to_string_lossy().to_string(); + let f = b.path.clone(); + s.filter.folder = Some(f); + } + Ok(n) +} diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index f074ee90..458f6d50 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -377,3 +377,35 @@ fn cube_luts_become_profiles() { let _ = std::fs::remove_dir_all(&src); let _ = std::fs::remove_dir_all(&lib); } + +/// Folder rename / move on disk: files and sidecars go along, the photos are relinked and +/// still render; clashes and moving into itself are refused. +#[test] +fn folders_rename_and_move_with_their_photos() { + let root = temp_dir("folders"); + let a = root.join("Trip"); + write_png(&a.join("one.png"), 3); + std::fs::write(a.join("one.xmp"), "").unwrap(); + std::fs::create_dir_all(root.join("Taken")).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [a.to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + assert!(s.execute("folder.rename", &json!({"path": a.to_string_lossy(), "name": "Taken"})).is_err(), "name taken"); + assert!(s.execute("folder.rename", &json!({"path": a.to_string_lossy(), "name": "a/b"})).is_err()); + let r = s.execute("folder.rename", &json!({"path": a.to_string_lossy(), "name": "Italy 2026"})).unwrap(); + assert_eq!(r["relinked"], 1); + let b = root.join("Italy 2026"); + assert!(b.join("one.png").exists() && b.join("one.xmp").exists() && !a.exists()); + let path = |s: &Session| match &s.catalog.photo(id).unwrap().source { + lightcraft_catalog::Source::File { path } => path.clone(), + _ => panic!(), + }; + assert_eq!(path(&s), b.join("one.png").to_string_lossy()); + s.media.forget(id); + assert!(s.render_now(id, 16, 16).is_ok()); + assert!(s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": b.join("deeper").to_string_lossy()})).is_err(), "not into itself"); + let r = s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": root.join("Archive").to_string_lossy()})).unwrap(); + assert_eq!(r["relinked"], 1); + assert_eq!(path(&s), root.join("Archive/Italy 2026/one.png").to_string_lossy()); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index c7ffc5af..df8e620b 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -256,6 +256,33 @@ fn folder_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str, path: &st { app.toast(ui.ctx(), e); } + resp.context_menu(|ui| { + if ui.button("Rename Folder…").clicked() { + app.ui.dialog = Some(crate::state::Dialog::TextPrompt { + title: format!("Rename “{name}”"), + hint: "Folder name (renamed on disk; its photos follow)".into(), + value: name.to_string(), + command: "folder.rename".into(), + params: json!({"path": path}), + key: "name".into(), + }); + } + if app.services.pick_folder.is_some() && ui.button("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), + } + } + } + if app.services.reveal.is_some() + && ui.button("Show in Finder").clicked() + && let Some(f) = app.services.reveal.as_mut() + { + let _ = f(path); + } + }); if open && indent < 12.0 * 8.0 { for (n, p) in subfolders(ui, path) { folder_tree(app, ui, &n, &p, indent + 12.0, current); diff --git a/docs/parity.md b/docs/parity.md index 8b6ce537..b7ea41cb 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -47,10 +47,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | X. Cross-cutting behaviours (BEHAV) | 14 | 1 | 2 | 1 | 8/8 (100%) | 5/5 (100%) | | Y. Menus | 80 | 1 | 4 | 8 | 46/47 (98%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | -| Lightroom Classic extras | 23 | 9 | 47 | 9 | — | 19/21 (90%) | -| **Total** | 373 | 24 | 103 | 37 | 193/198 (97%) | 135/144 (94%) | +| Lightroom Classic extras | 24 | 8 | 47 | 9 | — | 20/21 (95%) | +| **Total** | 374 | 23 | 103 | 37 | 193/198 (97%) | 136/144 (94%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **77.0%** of 500 in-scope rows — P0 98.7% of 198 · P1 95.8% of 144 · P2 32.6% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **77.1%** of 500 in-scope rows — P0 98.7% of 198 · P1 96.2% of 144 · P2 32.6% of 158. ## Top gaps @@ -693,7 +693,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | | LRC-LIB-REFVIEW | Reference view | P2 | ⬜ | | | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | -| LRC-LIB-FOLDERS | Disk folder tree | P1 | 🟡 | `cmd:library.browse`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders (opened on the way to the folder in view), breadcrumb navigation; no folder rename / move on disk | +| 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; like the file manager, not an undo step) | | 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), 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/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 26 fields (rating, flag, label, type, edits, keywords, any text, filename, format, title, caption, camera, lens, location, creator, copyright, capture / import / edit date, ISO, aperture, focal length, megapixels, GPS, virtual copy, album) with text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`) | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | |