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:
parent
9affd0e8d2
commit
27387af44c
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue