diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index fed4bff1..d1830d22 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -33,7 +33,7 @@ pub(crate) use load::{load_fig, read_capped, recovered, variables_param, write}; pub use open::MAX_RECENT; #[cfg(test)] use open::append_pages; -use open::{import, open, open_recent}; +use open::{import, open, open_recent, revert}; pub(crate) use save::save_ends_preview; pub use save::{FileSnapshot, file_bytes, thumbnail}; use save::{color_profile, mark_saved, save, save_as}; @@ -72,7 +72,7 @@ pub fn specs() -> Vec { spec!( "file.open", "Open", - "{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)", + "{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file), reload? (a file already open: read it again into its tab, which must have no unsaved changes)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open returns its `index`, with `stale: true` when the file changed on disk since the tab read or saved it, or `reloaded: true` after `reload`)", false, open ), @@ -188,21 +188,7 @@ pub fn specs() -> Vec { Ok(json!({"index": s.active})) }), 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; - if let Some(slot) = s.docs.get_mut(i) { - *slot = DocState::new(loaded.doc, Some(path.clone())) - .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); + revert(s)?; ok() }), spec!( diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs index 6b724ced..169d71fd 100644 --- a/crates/engine/src/cmd/file/open.rs +++ b/crates/engine/src/cmd/file/open.rs @@ -62,11 +62,26 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result { return open_data(s, &d, p, variables.as_ref()); } let path = req_string("file.open", p, "path")?; - // Already open: switch to it. + // Already open: switch to it, and say when the file changed on disk since this tab read or + // saved it (`stale`), or read it again (`reload`). if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) { s.active = i; remember(s, &path); - return Ok(json!({"index": i})); + let stale = s.ds()?.on_disk.as_ref().is_some_and(|read| crate::library::stat(&path).ok().as_ref() != Some(read)); + if !boolean(p, "reload").unwrap_or(false) { + return Ok(if stale { json!({"index": i, "stale": true}) } else { json!({"index": i}) }); + } + if s.ds()?.is_dirty() { + return Err(EngineError::Disabled( + "file.open".into(), + "the open copy has unsaved changes: save them, or Revert to saved to drop them".into(), + )); + } + if s.live().doc_uid.is_some() { + return Err(EngineError::Disabled("file.open".into(), "leave the live session before reading the file again".into())); + } + revert(s)?; + return Ok(json!({"index": i, "reloaded": true})); } // Stamped before reading: a change made during the read makes the next save refuse. let on_disk = crate::library::stat(&path).ok(); @@ -95,6 +110,26 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// Replace the active tab's document with its file as saved (`file.revert`, `file.open {reload}`). +pub(super) fn revert(s: &mut Session) -> Result<()> { + 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; + if let Some(slot) = s.docs.get_mut(i) { + *slot = DocState::new(loaded.doc, Some(path.clone())) + .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); + Ok(()) +} + /// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells /// the format. It opens as a new document with no path, so the first save asks where to put it. fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result { diff --git a/crates/engine/src/save_disk_changes_tests.rs b/crates/engine/src/save_disk_changes_tests.rs index 807daa9e..7a0505c9 100644 --- a/crates/engine/src/save_disk_changes_tests.rs +++ b/crates/engine/src/save_disk_changes_tests.rs @@ -74,3 +74,37 @@ fn auto_save_leaves_changes_made_on_disk_alone() { assert_eq!(page_on_disk(&path), "CLI VENCEU"); let _ = std::fs::remove_file(&path); } + +/// The name of the page the active tab shows. +fn page_in_tab(s: &Session) -> String { + let ds = s.ds().unwrap(); + ds.doc.node(ds.page).unwrap().name.clone() +} + +/// Opening a file that is already open says when it changed on disk since the tab read or saved +/// it, and `reload` reads it again into the tab, unless the tab has unsaved changes (#997). +#[test] +fn opening_an_open_file_says_it_is_stale_and_reloads_on_request() { + let path = temp_file("reopen"); + 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(); + let index = app.execute("file.open", &json!({"path": path})).unwrap()["index"].clone(); + let before = page_in_tab(&app); + assert_eq!(app.execute("file.open", &json!({"path": path})).unwrap(), json!({"index": index}), "unchanged: not stale"); + let mut cli = Session::new(); + cli.execute("file.open", &json!({"path": path})).unwrap(); + cli.execute("page.rename", &json!({"name": "Renamed on disk by the CLI"})).unwrap(); + cli.execute("file.save", &json!({})).unwrap(); + assert_eq!(app.execute("file.open", &json!({"path": path})).unwrap(), json!({"index": index, "stale": true})); + assert_eq!(page_in_tab(&app), before, "without reload the tab keeps what it read"); + assert_eq!(app.execute("file.open", &json!({"path": path, "reload": true})).unwrap(), json!({"index": index, "reloaded": true})); + assert_eq!(page_in_tab(&app), "Renamed on disk by the CLI"); + assert_eq!(app.execute("file.open", &json!({"path": path})).unwrap(), json!({"index": index}), "read again: no longer stale"); + app.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); + let e = app.execute("file.open", &json!({"path": path, "reload": true})).unwrap_err().to_string(); + assert!(e.contains("unsaved changes"), "{e}"); + assert!(app.ds().unwrap().is_dirty(), "the unsaved edit is still there"); + let _ = std::fs::remove_file(&path); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 1b23191e..beca8700 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -259,6 +259,7 @@ pub fn tool_definitions() -> Vec { json!({ "path": string("File path"), "variables": {"description": "REST API file JSON only: the local variables JSON (`GET /v1/files/:key/variables/local`) as a path or the JSON object. Default: `.variables.json` or `variables.json` next to the file."}, + "reload": boolean("A file that is already open: read it again from disk into its tab (refused while the tab has unsaved changes). Without it, the result says `stale: true` when the file changed on disk since the tab read or saved it"), }), &["path"], ), @@ -992,6 +993,9 @@ fn open_file(b: &mut dyn Backend, a: &Map) -> Result params["variables"] = v.clone(), Some(_) => return Err("`variables` must be a path or the local variables JSON object".into()), } + if let Some(reload) = a.get("reload").filter(|v| !v.is_null()) { + params["reload"] = reload.clone(); + } let r = exec(b, "file.open", params)?; let doc = b.call("document.inspect", json!({"depth": 1}))?; Ok(ToolResult::json(&json!({"opened": r, "document": doc}))) diff --git a/crates/mcp/tests/it/agent_tasks.rs b/crates/mcp/tests/it/agent_tasks.rs index ae576e12..b338642a 100644 --- a/crates/mcp/tests/it/agent_tasks.rs +++ b/crates/mcp/tests/it/agent_tasks.rs @@ -236,6 +236,10 @@ fn task_export_png_and_svg() { c.ok("save_file", json!({"path": file.to_string_lossy()})); let opened = c.ok("open_file", json!({"path": file.to_string_lossy()})); assert!(opened["document"]["pages"][0]["children"].to_string().contains("Banner")); + // The tab is that file: `reload` reads it again from disk (#997). + let reread = c.ok("open_file", json!({"path": file.to_string_lossy(), "reload": true})); + assert_eq!(reread["opened"]["reloaded"], true, "{reread}"); + assert!(reread["document"]["pages"][0]["children"].to_string().contains("Banner")); std::fs::remove_dir_all(&dir).ok(); } diff --git a/docs/mcp.md b/docs/mcp.md index 05dfd1e6..876efe99 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -317,7 +317,7 @@ Or in a project's `.mcp.json`: | `select {ids}` | Replace the selection (`[]` deselects). | | `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?, vdCompat?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text, XML as text followed by `{format, bytes, warnings}`. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). | | `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. | -| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.sketch` (Sketch 48 and newer, best effort), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open just gives its `index`) and the document's `document` outline. | +| `open_file {path, variables?, reload?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.sketch` (Sketch 48 and newer, best effort), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open gives its `index`, with `stale: true` when the file changed on disk since that tab read or saved it; `reload: true` reads it again into the tab, refused while the tab has unsaved changes) and the document's `document` outline. | | `undo` / `redo` | Step through history. | | `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd, pressure?, pressureTo?}` | Mouse gestures in page coordinates with the current tool: `click`, `double`, `drag`, `down`, `move`, `up`, `hover`. A dragged layer drops into the frame or auto layout slot under the pointer, except one inside a group, a boolean group or an instance (outside its slots), which only moves. `pressure` (0–1) is pen pressure; a drag goes from `pressure` to `pressureTo` (see Brushes and pen pressure). | | `key {key, shift, alt, cmd}` | A shortcut or key as if typed on the canvas (every default shortcut, tool keys, Escape, Delete, arrows, number keys for opacity, arrows that pan the view when nothing is selected; while editing text, the text shortcuts and then text editing keys). Keys are names like `A`, `0`, `Enter`, `ArrowLeft`, `=`, `/`, or egui's (`Plus`, `Questionmark`); `cmd` is Cmd on macOS and Ctrl elsewhere. |