diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index cf56d490..3501de29 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -166,6 +166,15 @@ pub enum Op { #[serde(default, skip_serializing_if = "Option::is_none")] format: Option, }, + /// What a photo's file is now (after it changed on disk: Reload). + SetContent { + id: PhotoId, + width: u32, + height: u32, + file_size: u64, + #[serde(default)] + content_hash: Option, + }, /// The name shown for a colour label (`None` = its colour's name). SetLabelName { label: ColorLabel, @@ -496,6 +505,16 @@ impl Catalog { let old_format = format.map(|f| std::mem::replace(&mut p.format, f)); Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source), format: old_format } } + Op::SetContent { id, width, height, file_size, content_hash } => { + let p = self.photo_mut(id)?; + Op::SetContent { + id, + width: std::mem::replace(&mut p.width, width), + height: std::mem::replace(&mut p.height, height), + file_size: std::mem::replace(&mut p.file_size, file_size), + content_hash: std::mem::replace(&mut p.content_hash, content_hash), + } + } Op::SetFile { id, file_name, source } => { if file_name.trim().is_empty() { return Err(CatalogError::Invalid("empty file name".into())); diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index ba85e0d8..1d3b3ab9 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -148,14 +148,60 @@ fn edit_external(s: &mut Session, p: &Value) -> Result { Ok(json!({"path": out, "id": new, "original": id.0})) } -pub fn edit_specs() -> Vec { - vec![cmd!( - "photo.editExternal", - "Edit Copy for External Editor", - [], - None, - "{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, dir?} — render the active photo with its edits as a 16-bit TIFF `-Edit.tif` next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)", - has_active, - edit_external - )] +/// Re-read photos whose files changed on disk (an external editor saved them): new size, +/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]} +fn reload(s: &mut Session, p: &Value) -> Result { + let ids = s.targets(p); + let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids + .iter() + .filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) { + Some(Source::File { path }) => Some((*id, path)), + _ => None, + }) + .collect(); + let probed = crate::import::probe_paths(s, &paths.iter().map(|(_, p)| p.clone()).collect::>()); + let mut ops = Vec::new(); + let mut reloaded = Vec::new(); + for ((id, _), info) in paths.into_iter().zip(probed) { + let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue }; + if info.content_hash == ph.content_hash && info.file_size == ph.file_size && (info.width, info.height) == (ph.width, ph.height) { + continue; + } + ops.push(Op::SetContent { id, width: info.width, height: info.height, file_size: info.file_size, content_hash: info.content_hash }); + // virtual copies share the file + for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) { + reloaded.push(c.id); + } + reloaded.push(id); + } + for id in &reloaded { + s.media.forget(*id); + } + if !ops.is_empty() { + s.commit("Reload Changed Files", Op::Batch { ops })?; + } + Ok(json!({"reloaded": reloaded.iter().map(|i| i.0).collect::>()})) +} + +pub fn edit_specs() -> Vec { + vec![ + cmd!( + "photo.reload", + "Reload from Disk", + [], + None, + "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor) → {reloaded}", + has_selection, + reload + ), + cmd!( + "photo.editExternal", + "Edit Copy for External Editor", + [], + None, + "{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, dir?} — render the active photo with its edits as a 16-bit TIFF `-Edit.tif` next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)", + has_active, + edit_external + ), + ] } diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 40303c8c..a41422e0 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -253,6 +253,11 @@ pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec { out } +/// Probe files (headers + content hash) as an import would. +pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec> { + probe_all(s, paths) +} + fn probe_all(s: &Session, paths: &[String]) -> Vec> { let Some(probe) = s.media.file_probe.clone() else { return paths diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index bd4b060d..5cb9155d 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -511,12 +511,15 @@ impl crate::Session { let request = RenderRequest { apply_crop, ..RenderRequest::fit(max_w, max_h) }; // the photo id is part of the key: two photos with the same settings and size must not // share a result (a view slot showing photo A would otherwise look current for photo B) + // …and so is the file's content: a file changed on disk (Reload) renders afresh + let content = Hasher128::new().str(&content_key(&p)).finish().0 as u64; let key = settings.hash64() ^ ((max_w as u64) << 40) ^ ((max_h as u64) << 20) ^ (apply_crop as u64) ^ (level as u64) << 60 - ^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15); + ^ id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) + ^ content.rotate_left(17); let cache = thumb_bucket.map(|b| { let k = Hasher128::new().str(&content_key(&p)).u64(settings.hash64()).u64(b as u64).u64(RENDER_CACHE_VERSION).finish(); (self.media.rendered.clone(), k) diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 3cb7d4cb..685d439b 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -354,3 +354,24 @@ fn external_edit_copy_is_stacked() { assert!(r2["path"].as_str().unwrap().ends_with("beach-Edit-Edit.tif") || r2["path"].as_str().unwrap().contains("-2"), "{r2}"); let _ = std::fs::remove_dir_all(&dir); } + +/// Reload: a file changed on disk (an external editor saved it) gets its new size / hash, the +/// cached source is dropped and renders change; unchanged files are left alone. +#[test] +fn reload_picks_up_changed_files() { + let dir = temp_dir("reload"); + let f = dir.join("x.png"); + write_png(&f, 10); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [f.to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + let before = s.render_now(id, 32, 32).unwrap().image.data; + let key0 = s.thumb_job(id, 128).unwrap().key; + assert_eq!(s.execute("photo.reload", &json!({})).unwrap()["reloaded"], json!([]), "unchanged"); + write_png(&f, 200); + let r = s.execute("photo.reload", &json!({})).unwrap(); + assert_eq!(r["reloaded"], json!([id.0])); + assert_ne!(s.thumb_job(id, 128).unwrap().key, key0, "the UI sees a new render key"); + assert_ne!(s.render_now(id, 32, 32).unwrap().image.data, before, "renders the new pixels"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ae422c6d..ecafde7a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -403,6 +403,17 @@ impl LightcraftApp { import::tick(self, ctx); self.preview_build_status(ctx); self.slideshow_tick(ctx); + // back from an external editor: pick up the files it saved + let focused = ctx.input(|i| i.focused); + if focused && !self.ui.was_focused && !self.ui.external_edits.is_empty() { + let ids = self.ui.external_edits.clone(); + if let Ok(r) = self.session.execute("photo.reload", &serde_json::json!({"ids": ids})) + && r["reloaded"].as_array().is_some_and(|a| !a.is_empty()) + { + self.toast(ctx, "Updated the edits saved in the external editor"); + } + } + self.ui.was_focused = focused; self.session.persist_if_dirty(); self.collect_screenshots(ctx); self.issue_screenshots(ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 129eec97..ec58f0f1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -681,6 +681,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option return Some(Err(e.to_string())), }; let path = r["path"].as_str().unwrap_or_default().to_string(); + if let Some(id) = r["id"].as_u64() { + app.ui.external_edits.push(id); + } let editor = p.get("app").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| app.ui.settings.external_editor.clone()); if let Some(f) = app.services.open_with.as_mut() && let Err(e) = f(&path, &editor) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c7dc6963..397eca71 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -213,6 +213,12 @@ pub struct UiState { pub show_filenames: bool, /// Photo counts next to sources and albums in the left panel. pub show_counts: bool, + /// Copies opened in an external editor this session (reloaded when the window is focused + /// again), and whether the window had focus last frame. + #[serde(skip)] + pub external_edits: Vec, + #[serde(skip)] + pub was_focused: bool, /// The develop control whose slider is being dragged (geometry sliders show a grid). #[serde(skip)] pub dragging_control: Option, @@ -452,6 +458,8 @@ impl Default for UiState { show_filenames: true, show_counts: true, dragging_control: None, + external_edits: Vec::new(), + was_focused: true, search: String::new(), focus_search: false, renaming_mask: None,