diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 2379026b..e9caaa63 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -56,7 +56,7 @@ pub fn specs() -> Vec { spec!( "file.save", "Save", - "{path?} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", + "{path?, overwrite? (replace what another app wrote to the file since it was opened or saved: without it that save is refused)} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", false, save ), @@ -159,6 +159,8 @@ pub fn specs() -> Vec { }), spec!("file.revert", "Revert to saved", "{} leave the live session first, if any", false, |s, _| { let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?; + // Stamped before reading: a change made during the read makes the next save refuse. + let on_disk = crate::library::stat(&path).ok(); let (loaded, _) = load_path(&path, None)?; let i = s.active; let uid = s.ds()?.uid; @@ -167,6 +169,7 @@ pub fn specs() -> Vec { .with_versions(loaded.versions) .with_branches(loaded.branches) .with_images_left_in_file(&path, loaded.images_past_budget); + slot.on_disk = on_disk; } // Edits on its branch tabs are reverted too. crate::branches::close_tabs(s, uid, None); @@ -945,6 +948,8 @@ fn open(s: &mut Session, p: &Value) -> Result { remember(s, &path); return Ok(json!({"index": i})); } + // Stamped before reading: a change made during the read makes the next save refuse. + let on_disk = crate::library::stat(&path).ok(); let (loaded, rest) = load_path(&path, variables.as_ref())?; remember(s, &path); let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json"); @@ -958,12 +963,12 @@ fn open(s: &mut Session, p: &Value) -> Result { } let summary = import_summary(&loaded.doc); let images_left_out = if native { loaded.images_past_budget } else { 0 }; - s.docs.push( - DocState::new(loaded.doc, native.then(|| path.clone())) - .with_versions(loaded.versions) - .with_branches(loaded.branches) - .with_images_left_in_file(&path, images_left_out), - ); + let mut ds = DocState::new(loaded.doc, native.then(|| path.clone())) + .with_versions(loaded.versions) + .with_branches(loaded.branches) + .with_images_left_in_file(&path, images_left_out); + ds.on_disk = on_disk.filter(|_| native); + s.docs.push(ds); s.active = s.docs.len() - 1; let mut out = json!({"index": s.active, "summary": summary}); out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); @@ -1053,7 +1058,9 @@ impl FileSnapshot { }; let path = self.path.as_deref().ok_or(EngineError::NoDocument)?; refuse_dropping_images(ds, path)?; + refuse_overwriting_changes(ds, path)?; write(path, bytes)?; + ds.note_on_disk(); // The history or branches may have changed since: then they still count as unsaved. if ds.revision == self.revision { ds.versions_changed = false; @@ -1075,6 +1082,17 @@ fn refuse_dropping_images(ds: &DocState, path: &str) -> Result<()> { } } +/// Saving over the tab's own file would lose what another app wrote to it since (#1330). +fn refuse_overwriting_changes(ds: &DocState, path: &str) -> Result<()> { + match (&ds.on_disk, crate::library::stat(path)) { + (Some(was), Ok(now)) if now != *was => Err(EngineError::Other(format!( + "{} wasn't saved: the file changed on disk since it was opened or saved here (another app or figcraft-cli wrote to it), and saving would replace those changes. Revert to saved to load them, or Save as to keep this version as well.", + file_stem(path) + ))), + _ => Ok(()), + } +} + /// The branch tabs of the file in tab `uid` were saved with it. fn branch_tabs_saved(s: &mut Session, uid: u64) { for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) { @@ -1137,6 +1155,7 @@ fn save_to(s: &mut Session, path: Option) -> Result { write(&path, &bytes)?; ds.doc = snapshot.doc; ds.path = Some(path.clone()); + ds.note_on_disk(); mark_saved(s, &json!({}))?; remember(s, &path); Ok(json!({"path": path, "bytes": bytes.len()})) @@ -1157,13 +1176,19 @@ fn save(s: &mut Session, p: &Value) -> Result { if let Some(path) = string(p, "path") { return save_to(s, Some(path)); } - match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() { + let ds = s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?; + match ds.path.clone() { // Earlier builds wrote native data under a .fig name: never overwrite that file with an export. Some(path) if ext(&path) == "fig" => { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into())) } - Some(path) => save_to(s, Some(path)), + Some(path) => { + if p.get("overwrite").and_then(Value::as_bool) != Some(true) { + refuse_overwriting_changes(ds, &path)?; + } + save_to(s, Some(path)) + } None => { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into())) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 548f96ed..0670946b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -375,6 +375,9 @@ pub struct DocState { /// [`cmd::edit::Duplicated`]; not saved). pub(crate) last_duplicate: Option, images_left_in_file: Option<(String, usize)>, + /// The file on disk as this tab last read or wrote it: a save refuses to replace what another + /// app (figcraft-cli, say) wrote to it since (#1330). + on_disk: Option, } /// A version shown on the canvas in place of the document: read-only until it's restored or the @@ -438,6 +441,7 @@ impl DocState { view_from_prefs: false, last_duplicate: None, images_left_in_file: None, + on_disk: None, }; st.zoom_to_fit(); st @@ -448,6 +452,11 @@ impl DocState { self } + /// The tab's file now holds what this tab read or wrote. + pub(crate) fn note_on_disk(&mut self) { + self.on_disk = self.path.as_deref().and_then(|p| library::stat(p).ok()); + } + pub fn images_lost_saving_to(&self, path: &str) -> Option { self.images_left_in_file.as_ref().filter(|(file, _)| file_access::same_file(file, path)).map(|(_, count)| *count) } @@ -1874,6 +1883,8 @@ mod rotated_select_tests; #[cfg(test)] mod rotation_origin_tests; #[cfg(test)] +mod save_disk_changes_tests; +#[cfg(test)] mod save_format_tests; #[cfg(test)] mod scale_tool_tests; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index cc6c3856..40d6b3ca 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -439,9 +439,9 @@ pub fn publish(doc: &Document, prev: Option<&PublishedLibrary>, sel: &Selection, Ok(PublishedLibrary { manifest, doc: Arc::new(snap) }) } -/// When a library file was last read. +/// When a file was last read: its length and modification time (also a document's own file, #1330). #[derive(Clone, Debug, PartialEq)] -struct Stamp { +pub(crate) struct Stamp { len: u64, modified: Option, } @@ -545,7 +545,7 @@ fn now() -> Option { } #[cfg(not(target_arch = "wasm32"))] -fn stat(path: &str) -> std::result::Result { +pub(crate) fn stat(path: &str) -> std::result::Result { let m = crate::file_access::metadata(path).map_err(|e| e.to_string())?; if !m.is_file() { return Err(format!("{path} is not a file")); @@ -554,7 +554,7 @@ fn stat(path: &str) -> std::result::Result { } #[cfg(target_arch = "wasm32")] -fn stat(_path: &str) -> std::result::Result { +pub(crate) fn stat(_path: &str) -> std::result::Result { Err(NO_FILES.into()) } diff --git a/crates/engine/src/save_disk_changes_tests.rs b/crates/engine/src/save_disk_changes_tests.rs new file mode 100644 index 00000000..807daa9e --- /dev/null +++ b/crates/engine/src/save_disk_changes_tests.rs @@ -0,0 +1,76 @@ +//! Saving never silently replaces what another app wrote to the open file since it was opened or +//! saved here (#1330): Save and auto-save refuse, `overwrite` and Revert to saved settle it. + +use serde_json::json; + +use crate::Session; +use crate::cmd::file::FileSnapshot; + +fn temp_file(name: &str) -> String { + let dir = std::env::temp_dir().join(format!("figcraft-disk-changes-{}-{name}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("TESTE.figcraft").to_string_lossy().into_owned() +} + +/// What the file on disk names its first page. +fn page_on_disk(path: &str) -> String { + let mut s = Session::new(); + s.execute("file.open", &json!({"path": path})).unwrap(); + let ds = s.ds().unwrap(); + ds.doc.node(ds.page).unwrap().name.clone() +} + +/// The app has `path` open with an unsaved edit, after figcraft-cli renamed its page on disk. +fn app_and_cli(path: &str) -> Session { + let mut setup = Session::new(); + setup.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + setup.execute("file.save", &json!({"path": path})).unwrap(); + let mut app = Session::new(); + app.execute("file.open", &json!({"path": path})).unwrap(); + let mut cli = Session::new(); + cli.execute("file.open", &json!({"path": path})).unwrap(); + cli.execute("page.rename", &json!({"name": "CLI VENCEU"})).unwrap(); + cli.execute("file.save", &json!({})).unwrap(); + app.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); + app +} + +#[test] +fn saving_over_changes_made_on_disk_is_refused_until_asked() { + let path = temp_file("save"); + let mut app = app_and_cli(&path); + let err = app.execute("file.save", &json!({})).unwrap_err().to_string(); + assert!(err.contains("changed on disk"), "{err}"); + assert_eq!(page_on_disk(&path), "CLI VENCEU", "the CLI's change is still in the file"); + assert!(app.ds().unwrap().is_dirty(), "the app's edit stays unsaved"); + // Asked to, the save replaces it; from then on the file is the app's again. + app.execute("file.save", &json!({"overwrite": true})).unwrap(); + assert_ne!(page_on_disk(&path), "CLI VENCEU"); + app.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap(); + app.execute("file.save", &json!({})).unwrap(); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn reverting_loads_the_changes_made_on_disk_and_saves_again() { + let path = temp_file("revert"); + let mut app = app_and_cli(&path); + app.execute("file.revert", &json!({})).unwrap(); + let ds = app.ds().unwrap(); + assert_eq!(ds.doc.node(ds.page).unwrap().name, "CLI VENCEU"); + app.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); + app.execute("file.save", &json!({})).unwrap(); + assert_eq!(page_on_disk(&path), "CLI VENCEU", "saved on top of the CLI's change"); + let _ = std::fs::remove_file(&path); +} + +#[test] +fn auto_save_leaves_changes_made_on_disk_alone() { + let path = temp_file("auto"); + let mut app = app_and_cli(&path); + let snapshot = FileSnapshot::of(app.ds().unwrap()); + let bytes = snapshot.encode().unwrap(); + assert!(snapshot.save(&mut app, &bytes).is_err()); + assert_eq!(page_on_disk(&path), "CLI VENCEU"); + let _ = std::fs::remove_file(&path); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 48cacbbb..b834ed9b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (`.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -21,7 +21,7 @@ and prototype interactions are still unchecked there. | `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) | | `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | `.jam`: not read or written (boards are FigCraft files, see [boards-parity](boards-parity.md)); `.deck` out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | -| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Dev Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — | +| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); Save and auto-save refuse to write over a file another app (figcraft-cli, say) changed since it was opened or saved here, judged by its size and modification time: Revert to saved loads those changes, Save as keeps both, `file.save {overwrite: true}` replaces them (#1330); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Dev Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — | | Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) | ## Import (place / paste / drop) @@ -166,6 +166,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Changes made to the open file by another app are no longer lost on save (#1330): the app now remembers the file's size and modification time when it opens, reverts or saves it, and Save and auto-save refuse while the file on disk differs, with a message pointing to Revert to saved or Save as (the tab stays unsaved; closing it stays open). Scripts pass `file.save {overwrite: true}` to replace the file anyway; Save as and a save to an explicit `path` write as before. Not detected: a change of the same size inside one modification-time tick of a coarse file system. No prompt with choices yet, and a change isn't noticed until a save (`save_disk_changes_tests`). Numbers unchanged | | 2026-10-25 | patch | PNG: the export settings were listed as "contents only" and "include bounding box (the layer's own box)"; the row now names ignore overlapping layers, on by default, and says that the Export section offers include bounding box for text layers (#1307), while `file.export` and `export.update` take it for any layer. Numbers unchanged | | 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged | | 2026-10-24 | minor | Slices and contents only (Figma's Ignore overlapping layers): with it on, a slice inside a frame or group exports only that frame or group and the layers in it, and its Export preview shows the same; with it off, or for a slice on the page or in a section, the slice exports everything in its area as before |