LRC-LIB-FOLDERS: rename and move folders on disk

folder.rename {path, name} and folder.move {path, into}: the folder is renamed / moved on disk
in one step (its files and sidecars go along) and the photos inside are relinked (and a
browsed folder follows); like the file manager it is not an undo step. Refuses clashes, moves
into itself and the library's own folders. Local folder tree: Rename Folder…, Move Folder To…,
Show in Finder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 02:40:44 -04:00
parent df4c7d7c3a
commit 1b4f623e55
4 changed files with 153 additions and 4 deletions

View File

@ -55,6 +55,40 @@ fn add_to_library(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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<usize, String> {
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<lightcraft_catalog::Op> = 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)
}

View File

@ -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"), "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>").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);
}

View File

@ -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);

View File

@ -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.
<!-- /parity:summary -->
## 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 | ⬜ | | |