diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index 684cb316bd..736c4d2901 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -142,10 +142,15 @@ impl Headless { warnings } }; - let is_native = format - .map(|f| f.trim_start_matches('.').eq_ignore_ascii_case("pcraft")) - .unwrap_or_else(|| target.extension().is_some_and(|e| e.eq_ignore_ascii_case("pcraft"))); - if is_native { + // A layered document save (PSD, PSB or .pcraft) is a full write, not a flattened copy: + // record the current revision as saved so the session stops reporting `dirty`, and make + // the file the document's path. A flat export (PNG, JPEG, …) is a copy and leaves both + // alone (#1547). + let ext = format + .map(|f| f.trim_start_matches('.').to_ascii_lowercase()) + .or_else(|| target.extension().map(|e| e.to_string_lossy().to_ascii_lowercase())); + let layered = matches!(ext.as_deref(), Some("psd" | "psb")) || ext.as_deref() == Some(photocraft_format::EXTENSION); + if layered { // Saving by index must not retarget the next automation command. let previously_active = self.session.active_index(); self.session.set_active(i); diff --git a/crates/automation/tests/layered_save_dirty.rs b/crates/automation/tests/layered_save_dirty.rs new file mode 100644 index 0000000000..0a19277f40 --- /dev/null +++ b/crates/automation/tests/layered_save_dirty.rs @@ -0,0 +1,82 @@ +//! Regression for #1547: a successful save back to the document's own PSD or PSB file must +//! record the current revision as saved, so the session stops reporting `dirty` (as `.pcraft` +//! already did). A flat export stays a copy and leaves the dirty state and path alone. + +use photocraft_automation::Headless; +use serde_json::json; + +fn tmp(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "pc-layered-save-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// The issue's scenario: create `name`, reopen it, edit, then save with no path. The save writes +/// the document's own file and must clear `dirty`. +fn pathless_save_clears_dirty(dir: &std::path::Path, name: &str) { + let path = dir.join(name); + let mut headless = Headless::trusted_local(); + headless.handle("doc.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap(); + headless.handle("doc.save", json!({"path": path.to_str().unwrap()})).unwrap(); + assert!(path.is_file(), "{name} was written"); + + // Reopen the saved file and edit it, so the pathless save below writes back over its own file. + headless.handle("doc.open", json!({"path": path.to_str().unwrap()})).unwrap(); + headless.handle("engine.execute", json!({"command": "image.adjustments.invert", "params": {}})).unwrap(); + assert!(headless.session.active().unwrap().is_dirty(), "{name}: the edit dirties"); + + // The reported symptom: the save succeeds but the session still reports `dirty`. + headless.handle("doc.save", json!({})).unwrap(); + assert!(!headless.session.active().unwrap().is_dirty(), "{name}: a pathless save is clean"); + let revision = headless.session.active().unwrap().revision; + assert_eq!(headless.session.active().unwrap().saved_revision, revision, "{name}: the current revision is recorded"); + assert_eq!(headless.session.active().unwrap().path.as_deref(), path.to_str(), "{name}: the file is still the path"); + + // A second save, with no further edit, stays clean. + headless.handle("doc.save", json!({})).unwrap(); + assert!(!headless.session.active().unwrap().is_dirty(), "{name}: a repeat save stays clean"); + + let bytes = std::fs::read(&path).unwrap(); + if name.ends_with("psd") { + assert!(photocraft_io::is_psd(&bytes), "{name}: PSD magic"); + } else { + assert_eq!(&bytes[..4], b"8BPS", "{name}: PSB magic"); + } +} + +#[test] +fn pathless_psd_and_psb_saves_clear_dirty() { + let dir = tmp("clean"); + pathless_save_clears_dirty(&dir, "a.psd"); + pathless_save_clears_dirty(&dir, "a.psb"); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// The control from the issue: exporting a flat copy is not a document save, so it must neither +/// clear `dirty` nor retarget the document's path. A `.pcraft` save sets the path on both the old +/// and the new code, so this test isolates the flat-export behaviour. +#[test] +fn a_flat_export_is_a_copy_and_leaves_the_document_dirty() { + let dir = tmp("copy"); + let native = dir.join("doc.pcraft"); + let png = dir.join("copy.png"); + let mut headless = Headless::trusted_local(); + headless.handle("doc.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap(); + headless.handle("doc.save", json!({"path": native.to_str().unwrap()})).unwrap(); + headless.handle("engine.execute", json!({"command": "image.adjustments.invert", "params": {}})).unwrap(); + assert!(headless.session.active().unwrap().is_dirty(), "the edit dirties"); + + headless.handle("doc.save", json!({"path": png.to_str().unwrap()})).unwrap(); + assert!(png.is_file(), "the PNG copy was written"); + assert!(headless.session.active().unwrap().is_dirty(), "a flat export leaves the document dirty"); + assert_eq!(headless.session.active().unwrap().path.as_deref(), native.to_str(), "the path is not retargeted to the copy"); + + // ...and the document can still be saved back to its own native file. + headless.handle("doc.save", json!({})).unwrap(); + assert!(!headless.session.active().unwrap().is_dirty()); + std::fs::remove_dir_all(dir).unwrap(); +} \ No newline at end of file diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6dcbbb3086..631cc4e987 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -275,7 +275,7 @@ no MCP framing, no app start-up per command. Configure its file access with the | `engine.commands` | `{filter?}`: registry with params docs and enablement | | `session.list` | open documents and the active index | | `doc.open` / `doc.new` | `{path}` / `file.new` params | -| `doc.save` | `{path?, format?, quality?, index?}` (`.pcraft` native, else export by extension). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged | +| `doc.save` | `{path?, format?, quality?, index?}` (`.pcraft` native, else export by extension). Without `path` only a PSD, PSB or `.pcraft` document is written back to its own file, in its own format; anything else is an error and the file is left unchanged. A successful layered save (PSD, PSB or `.pcraft`) records the current revision as saved and makes the file the document's path, so `session.list` reports `dirty: false`; a flat export is a copy and leaves the document dirty | | `doc.inspect` | `{index?}`: same JSON as `document.inspect` | | `doc.render` | `{index?, maxSide? (1024; 0 = full), path?}`: PNG to `path`, else `{mime, base64}` | | `doc.select` / `doc.close` | `{index}` / `{index?}` |