LR-EXP-PSD: pick up files saved by the external editor (photo.reload)

Op::SetContent records a photo's file facts (size, dimensions, content hash; undoable).
photo.reload re-probes the photos' files and, for those that changed on disk, updates them and
drops their cached sources; render keys now include the file's content key, so the grid and
loupe re-render. The app reloads this session's external-edit copies whenever its window
regains focus (with a toast).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 23:03:39 -04:00
parent 9affd0e8d2
commit 27387af44c
8 changed files with 127 additions and 11 deletions

View File

@ -166,6 +166,15 @@ pub enum Op {
#[serde(default, skip_serializing_if = "Option::is_none")]
format: Option<String>,
},
/// 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<String>,
},
/// 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()));

View File

@ -148,14 +148,60 @@ fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"path": out, "id": new, "original": id.0}))
}
pub fn edit_specs() -> Vec<CommandSpec> {
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 `<name>-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<Value> {
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::<Vec<_>>());
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::<Vec<_>>()}))
}
pub fn edit_specs() -> Vec<CommandSpec> {
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 `<name>-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
),
]
}

View File

@ -253,6 +253,11 @@ pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec<String> {
out
}
/// Probe files (headers + content hash) as an import would.
pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
probe_all(s, paths)
}
fn probe_all(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo, String>> {
let Some(probe) = s.media.file_probe.clone() else {
return paths

View File

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

View File

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

View File

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

View File

@ -681,6 +681,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Err(e) => 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)

View File

@ -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<u64>,
#[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<String>,
@ -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,