mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 05:16:56 -06:00
M1: opening a file that is already open says when it changed on disk, and can read it again
`file.open` on a path already open in a tab switched to it and returned its index, even when another process had changed the file since, so the caller kept looking at the old copy with nothing saying so (#997). The tab's disk stamp from #1330 (`DocState::on_disk`) now tells: - the result says `stale: true` when the file on disk differs from what the tab read or last saved; - `reload: true` reads the file again into the tab, as Revert to saved does (refused while the tab has unsaved changes or is in a live session), and the result says `reloaded: true`. MCP's `open_file` takes `reload` and passes it on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
90054a89c4
commit
40b9288f70
@@ -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<CommandSpec> {
|
||||
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 `<name>.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 `<name>.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<CommandSpec> {
|
||||
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!(
|
||||
|
||||
@@ -62,11 +62,26 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -259,6 +259,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
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: `<name>.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<String, Value>) -> Result<ToolResult,
|
||||
Some(v @ (Value::String(_) | Value::Object(_))) => 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})))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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 `<text>`, `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 `<layer><suffix>.<format>`, 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. |
|
||||
|
||||
Reference in New Issue
Block a user