mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 19:06:47 -06:00
- Dev resources: links for developers (a URL and a name) on any layer, stored on the layer (`Node::dev_resources`, a new synced property `devResources` at the end of the property list), saved in the file and undoable.
- Commands: `devResource.add {id?, url, name?}`, `devResource.edit {id?, index, url?, name?}`, `devResource.remove {id?, index?}`, `devResource.list {id?}` (Spanish labels added).
- An instance lists its own links, then its main component's and that component's set's, read-only with `from`; layers inside an instance show their main layer's and take none of their own (refused with a hint). Instances don't copy the main's links (created empty, own links kept across main edits).
- URLs are data: http or https with a host, no whitespace or control characters, at most 2,048 characters; names clipped to 200; at most 32 per layer. Nothing is fetched: a link added without a name is named after its URL (scheme, `www.` and trailing slash dropped). The app opens a link only when it is clicked.
- Inspect tab: a Dev resources section (after Annotations) lists the links with a link icon, name and URL (or "From <main>"), opens on click, edits name and link inline (pencil), removes (minus) and adds from a "Paste a link" field (plus). New strings in the en and es catalogs; links are announced as links to screen readers.
- Agents: `get_node` / `inspect_document` return `devResources` (own and inherited); docs/mcp.md and docs/control-protocol.md describe them.
- The editor extension stays out of scope: the MCP server already serves design data, code and links to agents and editors.
145 lines
8.3 KiB
Rust
145 lines
8.3 KiB
Rust
//! Dev resources: links for developers on layers, added, edited and removed, shown on a main
|
|
//! component's instances, checked as web links (never fetched), saved, synced, undoable and in
|
|
//! what agents inspect.
|
|
|
|
use figcraft_collab::{Change, Prop, Replica};
|
|
use figcraft_doc::NodeId;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::Session;
|
|
use crate::params::id_of;
|
|
|
|
fn ok(s: &mut Session, cmd: &str, p: Value) -> Value {
|
|
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
|
}
|
|
|
|
fn rect(s: &mut Session) -> NodeId {
|
|
id_of(&ok(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}))["id"]).unwrap()
|
|
}
|
|
|
|
fn names(s: &mut Session, id: NodeId) -> Vec<String> {
|
|
ok(s, "devResource.list", json!({"id": id})).as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap().to_string()).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn links_are_added_edited_and_removed() {
|
|
let mut s = Session::new();
|
|
let r = rect(&mut s);
|
|
// A link with a name, and one without: named after its URL (no page title is looked up).
|
|
let a = ok(&mut s, "devResource.add", json!({"id": r, "url": "https://storybook.example.com/button", "name": "Button stories"}));
|
|
assert_eq!(a["index"], 0);
|
|
ok(&mut s, "devResource.add", json!({"id": r, "url": "https://www.example.com/docs/"}));
|
|
assert_eq!(names(&mut s, r), ["Button stories", "example.com/docs"]);
|
|
// Edit the name or the URL; one undo step each.
|
|
ok(&mut s, "devResource.edit", json!({"id": r, "index": 1, "name": "Docs"}));
|
|
ok(&mut s, "devResource.edit", json!({"id": r, "index": 1, "url": "https://example.com/v2"}));
|
|
let list = ok(&mut s, "devResource.list", json!({"id": r}));
|
|
assert_eq!((&list[1]["name"], &list[1]["url"], &list[1]["index"]), (&json!("Docs"), &json!("https://example.com/v2"), &json!(1)));
|
|
ok(&mut s, "edit.undo", json!({}));
|
|
assert_eq!(ok(&mut s, "devResource.list", json!({"id": r}))[1]["url"], "https://www.example.com/docs/");
|
|
// Remove one, then the rest.
|
|
ok(&mut s, "devResource.remove", json!({"id": r, "index": 0}));
|
|
assert_eq!(names(&mut s, r), ["Docs"]);
|
|
assert_eq!(ok(&mut s, "devResource.remove", json!({"id": r}))["removed"], 1);
|
|
assert!(names(&mut s, r).is_empty());
|
|
assert!(s.execute("devResource.remove", &json!({"id": r})).is_err(), "nothing left to remove");
|
|
// The selection is the default target.
|
|
ok(&mut s, "select.set", json!({"ids": [r]}));
|
|
ok(&mut s, "devResource.add", json!({"url": "http://localhost:6006"}));
|
|
assert_eq!(names(&mut s, r), ["localhost:6006"]);
|
|
}
|
|
|
|
#[test]
|
|
fn only_web_links_are_kept() {
|
|
let mut s = Session::new();
|
|
let r = rect(&mut s);
|
|
let long = format!("https://example.com/{}", "a".repeat(3000));
|
|
for url in ["javascript:alert(1)", "ftp://example.com", "example.com", "https://", "https://exa mple.com", "https://:80/x", "", " ", &long] {
|
|
assert!(s.execute("devResource.add", &json!({"id": r, "url": url})).is_err(), "{url}");
|
|
}
|
|
for p in [json!({"id": r}), json!({"id": r, "url": 5}), json!({"id": r, "url": "https://a.b", "name": ["x"]})] {
|
|
assert!(s.execute("devResource.add", &p).is_err(), "{p}");
|
|
}
|
|
// Spaces around a link are trimmed; HTTPS in capitals is fine; long names are clipped.
|
|
let name = "n".repeat(500);
|
|
ok(&mut s, "devResource.add", json!({"id": r, "url": " HTTPS://Example.com/A ", "name": name}));
|
|
let l = ok(&mut s, "devResource.list", json!({"id": r}));
|
|
assert_eq!(l[0]["url"], "HTTPS://Example.com/A");
|
|
assert_eq!(l[0]["name"].as_str().unwrap().chars().count(), crate::dev_resources::MAX_NAME);
|
|
// Docs silent on a limit: a layer keeps at most 32.
|
|
for i in 1..crate::dev_resources::MAX_PER_LAYER {
|
|
ok(&mut s, "devResource.add", json!({"id": r, "url": format!("https://example.com/{i}")}));
|
|
}
|
|
assert!(s.execute("devResource.add", &json!({"id": r, "url": "https://example.com/more"})).is_err());
|
|
// Pages have none; edits need a link of the layer's own.
|
|
let page = s.page().unwrap();
|
|
assert!(s.execute("devResource.add", &json!({"id": page, "url": "https://example.com"})).is_err());
|
|
assert!(s.execute("devResource.edit", &json!({"id": r, "index": 99, "name": "x"})).is_err());
|
|
assert!(s.execute("devResource.edit", &json!({"id": r, "index": 0})).is_err(), "nothing to change");
|
|
}
|
|
|
|
#[test]
|
|
fn a_main_components_links_show_on_its_instances() {
|
|
let mut s = Session::new();
|
|
let f = id_of(&ok(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40}))["id"]).unwrap();
|
|
let r = id_of(&ok(&mut s, "shape.rectangle", json!({"x": 5, "y": 5, "width": 10, "height": 10, "parent": f}))["id"]).unwrap();
|
|
ok(&mut s, "devResource.add", json!({"id": r, "url": "https://example.com/icon", "name": "Icon"}));
|
|
let main = id_of(&ok(&mut s, "component.create", json!({"ids": [f]}))["id"]).unwrap();
|
|
ok(&mut s, "devResource.add", json!({"id": main, "url": "https://github.com/acme/ui/blob/main/Button.tsx", "name": "Button.tsx"}));
|
|
let inst = id_of(&ok(&mut s, "instance.create", json!({"component": main, "x": 200, "y": 0}))["id"]).unwrap();
|
|
// The instance lists the main's link, read-only (no index), and can have links of its own.
|
|
let l = ok(&mut s, "devResource.list", json!({"id": inst}));
|
|
assert_eq!((&l[0]["name"], &l[0]["from"], l[0].get("index")), (&json!("Button.tsx"), &json!(main), None));
|
|
ok(&mut s, "devResource.add", json!({"id": inst, "url": "https://jira.example.com/UI-12", "name": "UI-12"}));
|
|
assert_eq!(names(&mut s, inst), ["UI-12", "Button.tsx"]);
|
|
// Editing the main reaches the instance; the instance keeps its own across that.
|
|
ok(&mut s, "devResource.edit", json!({"id": main, "index": 0, "name": "Button source"}));
|
|
ok(&mut s, "node.set", json!({"id": main, "props": {"opacity": 0.5}}));
|
|
assert_eq!(names(&mut s, inst), ["UI-12", "Button source"]);
|
|
assert_eq!(s.doc().unwrap().node(inst).unwrap().dev_resources.len(), 1, "the main's links aren't copied");
|
|
// Layers inside an instance show their main layer's links and take none of their own.
|
|
let inner = s.doc().unwrap().node(inst).unwrap().children[0];
|
|
let l = ok(&mut s, "devResource.list", json!({"id": inner}));
|
|
assert_eq!((&l[0]["name"], l[0].get("index")), (&json!("Icon"), None));
|
|
assert!(l[0]["from"].is_u64());
|
|
assert!(s.execute("devResource.add", &json!({"id": inner, "url": "https://example.com"})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn a_component_sets_links_show_on_its_variants_instances() {
|
|
// Docs silent: links on a component set show on instances of any of its variants.
|
|
let mut s = Session::new();
|
|
let a = rect(&mut s);
|
|
let b = rect(&mut s);
|
|
let ca = id_of(&ok(&mut s, "component.create", json!({"ids": [a]}))["id"]).unwrap();
|
|
let cb = id_of(&ok(&mut s, "component.create", json!({"ids": [b]}))["id"]).unwrap();
|
|
let set = id_of(&ok(&mut s, "component.combineVariants", json!({"ids": [ca, cb]}))["id"]).unwrap();
|
|
ok(&mut s, "devResource.add", json!({"id": set, "url": "https://example.com/button", "name": "Button docs"}));
|
|
let inst = id_of(&ok(&mut s, "instance.create", json!({"component": cb, "x": 300, "y": 0}))["id"]).unwrap();
|
|
let l = ok(&mut s, "devResource.list", json!({"id": inst}));
|
|
assert_eq!((&l[0]["name"], &l[0]["from"]), (&json!("Button docs"), &json!(set)));
|
|
}
|
|
|
|
#[test]
|
|
fn links_save_sync_and_show_in_inspect() {
|
|
let mut s = Session::new();
|
|
let r = rect(&mut s);
|
|
let before = s.doc().unwrap().clone();
|
|
ok(&mut s, "devResource.add", json!({"id": r, "url": "https://example.com/spec", "name": "Spec"}));
|
|
// A change the collaboration layer syncs.
|
|
let d = figcraft_collab::diff(&Replica::new(before), s.doc().unwrap()).unwrap();
|
|
assert!(d.changes.iter().any(|c| matches!(c, Change::Set { id, prop: Prop::DevResources, .. } if *id == r)), "{:?}", d.changes);
|
|
// What agents inspect (MCP get_node / inspect_document).
|
|
let j = crate::inspect::node_json(s.doc().unwrap(), r, 0);
|
|
assert_eq!(j["devResources"], json!([{"name": "Spec", "url": "https://example.com/spec", "index": 0}]));
|
|
// Saved and reopened.
|
|
let dir = std::env::temp_dir().join(format!("figcraft-devres-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("f.figcraft").to_string_lossy().into_owned();
|
|
ok(&mut s, "file.saveAs", json!({"path": path}));
|
|
let mut t = Session::empty();
|
|
ok(&mut t, "file.open", json!({"path": path}));
|
|
assert_eq!(names(&mut t, r), ["Spec"]);
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|