feat: incremental lint with a change log and get_changes
A bounded log (10000 layer changes, each tagged with the client that made it) records what every commit, undo and redo touched; variable edits count as touching the layers bound to them. Write tools return rev, get_basic_info reports it, get_changes lists changes (exclude: self, by), and lint_design takes since / changedOnly to check only changed layers plus what they affect. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+11
@@ -231,6 +231,17 @@ are credited in `THIRD_PARTY.md`.
|
||||
* Agent view lists runs and supports review, whole-run revert, per-change undo and accept all. Review adds canvas outlines and a finish toast; palette commands review or revert the last run.
|
||||
* Id-paired page diffs power review; History version compare UI remains todo. Completed with full restore (including variables, modes and tokens), tool edit classification, and wasm support.
|
||||
|
||||
### 2026-10-28: Incremental lint
|
||||
|
||||
Agents re-linted whole pages after every edit. A bounded change log (`src/editor/changes.rs`, the
|
||||
last 10000 layer changes, each with the client that made it) now records which layers every
|
||||
commit, undo and redo touched; variable edits count as touching the layers bound to them. Write
|
||||
tools return `rev`, `get_basic_info` reports it, `get_changes {since, page?, exclude?, by?}` lists
|
||||
the changes, and `lint_design {since | changedOnly}` checks only those layers plus their
|
||||
ancestors, descendants and siblings (`src/mcp/incremental.rs`), with `covered` saying what ran.
|
||||
Tests: `tests/incremental.rs`, unit tests next to the code, the attribution check in
|
||||
`tests/mcp.rs::parallel_sessions_get_their_own_runs`.
|
||||
|
||||
### 2026-10-21: Design libraries and offline CLI
|
||||
|
||||
* Variables are now collections with modes, slash groups, four types and aliases, stored in `design.json` (project version 2; 0.4.0 refuses migrated projects). Each page keeps a derived cache; every edit is one undo step per page, and the page-only removals are gone. Per-page active mode per collection.
|
||||
|
||||
@@ -119,7 +119,7 @@ Projects live in `~/Documents/papercraft/<name>/` (`project.json`, one JSON file
|
||||
|
||||
## Agents
|
||||
|
||||
PaperCraft exposes **86 tools** (`src/mcp/tools.rs`) three ways. They run the same code inside the app.
|
||||
PaperCraft exposes **87 tools** (`src/mcp/tools.rs`) three ways. They run the same code inside the app.
|
||||
|
||||
| Way | Notes |
|
||||
|---|---|
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ Agents can already build a design system through the tools; people cannot yet ma
|
||||
|
||||
| # | Item | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 15 | MCP and CLI tool surface | **done** | 86 tools, one undo step per call, `papercraft-cli list / schema / call`. |
|
||||
| 15 | MCP and CLI tool surface | **done** | 87 tools, one undo step per call, `papercraft-cli list / schema / call`. |
|
||||
| 16 | Agent skills | **done** | `skills/papercraft` and `skills/papercraft-design`. |
|
||||
| 17 | Accessibility annotations | todo | Reading order, roles and alt text on layers, checked by the linter. |
|
||||
| 18 | Code export from frames | **done** | `get_jsx` supports `format: "inline"`, `"tailwind"` (Tailwind utility classes), and `"html"` (HTML + CSS rules with `:root` variables). |
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
//! A bounded log of which layers changed at which revision, so an agent can ask "what changed
|
||||
//! since I last looked" (`get_changes`) and lint only that (`lint_design {since}`).
|
||||
//!
|
||||
//! The revision here is the log's own counter, not `Editor::revision`: that one also moves when
|
||||
//! the camera does and once per batch of MCP calls, so a read would bump it. This one moves only
|
||||
//! when a committed edit, undo or redo changes layers.
|
||||
|
||||
use super::Editor;
|
||||
use crate::model::{Document, NodeId};
|
||||
use std::collections::{BTreeSet, HashMap, VecDeque};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Entries kept; a caller older than the oldest one gets `truncated` and does a full pass.
|
||||
pub const MAX_ENTRIES: usize = 10_000;
|
||||
|
||||
/// The MCP client behind a change.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Who {
|
||||
/// Name and session: two agents with the same client name stay apart.
|
||||
pub key: String,
|
||||
/// What the canvas tag shows ("Claude Code", "Claude Code \u{b7} 2").
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// One layer changed at one revision.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct Entry {
|
||||
rev: u64,
|
||||
page: String,
|
||||
id: NodeId,
|
||||
/// None for the person using the app (and for calls made without a client).
|
||||
by: Option<Arc<Who>>,
|
||||
}
|
||||
|
||||
/// Which changes `since` returns.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Filter<'a> {
|
||||
All,
|
||||
/// Everything but this client key's own changes.
|
||||
Exclude(&'a str),
|
||||
/// Only this client's changes, by label or key.
|
||||
Only(&'a str),
|
||||
}
|
||||
|
||||
/// Layers one client changed on one page since the asked revision.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Group {
|
||||
pub page: String,
|
||||
pub by: Option<String>,
|
||||
/// The latest revision in the group.
|
||||
pub rev: u64,
|
||||
/// Each layer once, in first-change order.
|
||||
pub ids: Vec<NodeId>,
|
||||
}
|
||||
|
||||
/// The answer to "what changed since revision N".
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Since {
|
||||
/// The log no longer reaches back to the asked revision (or it is from the future): the
|
||||
/// caller cannot trust `changes` and should look at everything.
|
||||
pub truncated: bool,
|
||||
pub changes: Vec<Group>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChangeLog {
|
||||
rev: u64,
|
||||
entries: VecDeque<Entry>,
|
||||
/// Highest revision of an entry dropped for space: asking since less than this is incomplete.
|
||||
floor: u64,
|
||||
max: usize,
|
||||
/// The MCP client whose call is running, set by the run tracker around each call.
|
||||
pub client: Option<Arc<Who>>,
|
||||
/// Revision of the last whole-page lint per (client key, page id), for `changedOnly`.
|
||||
linted: HashMap<(String, String), u64>,
|
||||
}
|
||||
|
||||
impl Default for ChangeLog {
|
||||
fn default() -> Self {
|
||||
ChangeLog::with_capacity(MAX_ENTRIES)
|
||||
}
|
||||
}
|
||||
|
||||
impl ChangeLog {
|
||||
pub fn with_capacity(max: usize) -> ChangeLog {
|
||||
ChangeLog { rev: 0, entries: VecDeque::new(), floor: 0, max, client: None, linted: HashMap::new() }
|
||||
}
|
||||
|
||||
/// The revision after the latest change.
|
||||
pub fn rev(&self) -> u64 {
|
||||
self.rev
|
||||
}
|
||||
|
||||
/// Logs `ids` of `page` as changed in a new revision by the running client. Nothing
|
||||
/// changed, no new revision.
|
||||
pub fn record(&mut self, page: &str, ids: impl IntoIterator<Item = NodeId>) {
|
||||
let mut ids = ids.into_iter().peekable();
|
||||
if ids.peek().is_none() {
|
||||
return;
|
||||
}
|
||||
self.rev += 1;
|
||||
for id in ids {
|
||||
self.entries.push_back(Entry { rev: self.rev, page: page.to_string(), id, by: self.client.clone() });
|
||||
}
|
||||
while self.entries.len() > self.max {
|
||||
if let Some(dropped) = self.entries.pop_front() {
|
||||
self.floor = self.floor.max(dropped.rev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets everything (another project was opened): every caller must start over.
|
||||
pub fn reset(&mut self) {
|
||||
self.rev += 1;
|
||||
self.floor = self.rev;
|
||||
self.entries.clear();
|
||||
self.linted.clear();
|
||||
}
|
||||
|
||||
/// Layers changed after revision `since`, optionally only on `page`, grouped by page and
|
||||
/// client in first-change order.
|
||||
pub fn since(&self, since: u64, page: Option<&str>, filter: Filter) -> Since {
|
||||
if since > self.rev || since < self.floor {
|
||||
return Since { truncated: true, changes: Vec::new() };
|
||||
}
|
||||
let keep = |e: &Entry| match filter {
|
||||
Filter::All => true,
|
||||
Filter::Exclude(k) => e.by.as_ref().is_none_or(|w| w.key != k),
|
||||
Filter::Only(k) => e.by.as_ref().is_some_and(|w| w.key == k || w.label == k),
|
||||
};
|
||||
let mut changes: Vec<Group> = Vec::new();
|
||||
for e in self.entries.iter().filter(|e| e.rev > since && page.is_none_or(|p| p == e.page) && keep(e)) {
|
||||
let by = e.by.as_ref().map(|w| w.label.clone());
|
||||
let at = changes.iter().position(|g| g.page == e.page && g.by == by);
|
||||
let i = at.unwrap_or_else(|| {
|
||||
changes.push(Group { page: e.page.clone(), by, rev: e.rev, ids: Vec::new() });
|
||||
changes.len() - 1
|
||||
});
|
||||
let g = &mut changes[i];
|
||||
g.rev = g.rev.max(e.rev);
|
||||
if !g.ids.contains(&e.id) {
|
||||
g.ids.push(e.id);
|
||||
}
|
||||
}
|
||||
Since { truncated: false, changes }
|
||||
}
|
||||
|
||||
/// Notes that the running client linted all of `page` as of the current revision.
|
||||
pub fn note_lint(&mut self, page: &str) {
|
||||
if let Some(c) = &self.client {
|
||||
self.linted.insert((c.key.clone(), page.to_string()), self.rev);
|
||||
}
|
||||
}
|
||||
|
||||
/// Revision of the running client's last whole-page lint of `page`.
|
||||
pub fn last_lint(&self, page: &str) -> Option<u64> {
|
||||
let key = self.client.as_ref()?.key.clone();
|
||||
self.linted.get(&(key, page.to_string())).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// Layers of `doc` bound to a variable in `names` (CSS names), or to any variable when `names`
|
||||
/// is None: through a fill that follows it or a field binding. Sorted by id.
|
||||
pub fn bound_layers(doc: &Document, names: Option<&BTreeSet<String>>) -> Vec<NodeId> {
|
||||
let hit = |v: &str| names.is_none_or(|n| n.contains(v));
|
||||
let mut out: Vec<NodeId> = doc
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| n.style.fills.iter().filter_map(|p| p.var()).any(hit) || n.bindings.values().any(|v| hit(v)))
|
||||
.map(|n| n.id)
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
/// Logs the layers bound to the variables `names` (all variables when None) on every page:
|
||||
/// an edit to a variable changes how they look or lay out even when no layer was written.
|
||||
pub fn log_variable_users(&mut self, names: Option<&BTreeSet<String>>) {
|
||||
self.finish_loading();
|
||||
for i in 0..self.pages.len() {
|
||||
let ids = bound_layers(self.page_doc(i), names);
|
||||
let page = self.pages[i].id.clone();
|
||||
self.changes.record(&page, ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::geom::Rect;
|
||||
use crate::model::{Color, Node, NodeKind, Paint};
|
||||
|
||||
#[test]
|
||||
fn bound_layers_follow_fills_and_field_bindings() {
|
||||
let mut doc = Document::default();
|
||||
let mut add = |f: &dyn Fn(&mut Node)| {
|
||||
let id = doc.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Frame { clip: false }, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
f(&mut n);
|
||||
doc.nodes.insert(id, n);
|
||||
id
|
||||
};
|
||||
let fill = add(&|n| n.style.fills = vec![Paint::solid_var(Color::rgb(1, 2, 3), "brand")]);
|
||||
let gap = add(&|n| {
|
||||
n.bindings.insert("gap".into(), "space-4".into());
|
||||
});
|
||||
let _plain = add(&|n| n.style.fills = vec![Paint::solid(Color::rgb(1, 2, 3))]);
|
||||
let some = |s: &str| Some(BTreeSet::from([s.to_string()]));
|
||||
assert_eq!(bound_layers(&doc, some("brand").as_ref()), vec![fill]);
|
||||
assert_eq!(bound_layers(&doc, some("space-4").as_ref()), vec![gap]);
|
||||
assert!(bound_layers(&doc, some("other").as_ref()).is_empty());
|
||||
assert_eq!(bound_layers(&doc, None), vec![fill, gap]);
|
||||
}
|
||||
|
||||
fn ids(v: &[u64]) -> Vec<NodeId> {
|
||||
v.iter().map(|n| NodeId(*n)).collect()
|
||||
}
|
||||
|
||||
fn who(key: &str, label: &str) -> Option<Arc<Who>> {
|
||||
Some(Arc::new(Who { key: key.into(), label: label.into() }))
|
||||
}
|
||||
|
||||
fn all(log: &ChangeLog, since: u64) -> Since {
|
||||
log.since(since, None, Filter::All)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_recorded_edit_is_a_revision() {
|
||||
let mut log = ChangeLog::default();
|
||||
assert_eq!(log.rev(), 0);
|
||||
log.record("p1", ids(&[1, 2]));
|
||||
log.record("p1", ids(&[3]));
|
||||
assert_eq!(log.rev(), 2);
|
||||
let g = &all(&log, 0).changes;
|
||||
assert_eq!((g.len(), g[0].ids.clone(), g[0].rev), (1, ids(&[1, 2, 3]), 2));
|
||||
assert_eq!(all(&log, 1).changes[0].ids, ids(&[3]));
|
||||
assert!(all(&log, 2).changes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_edit_keeps_the_revision() {
|
||||
let mut log = ChangeLog::default();
|
||||
log.record("p1", []);
|
||||
assert_eq!(log.rev(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_changed_twice_is_listed_once() {
|
||||
let mut log = ChangeLog::default();
|
||||
log.record("p1", ids(&[1]));
|
||||
log.record("p1", ids(&[1, 2]));
|
||||
assert_eq!(all(&log, 0).changes[0].ids, ids(&[1, 2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changes_can_be_limited_to_a_page() {
|
||||
let mut log = ChangeLog::default();
|
||||
log.record("p1", ids(&[1]));
|
||||
log.record("p2", ids(&[1]));
|
||||
let s = log.since(0, Some("p2"), Filter::All);
|
||||
assert_eq!((s.changes.len(), s.changes[0].page.as_str()), (1, "p2"));
|
||||
assert_eq!(all(&log, 0).changes.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asking_older_than_the_oldest_entry_is_truncated() {
|
||||
let mut log = ChangeLog::with_capacity(3);
|
||||
log.record("p", ids(&[1])); // rev 1
|
||||
log.record("p", ids(&[2, 3])); // rev 2
|
||||
assert!(!all(&log, 0).truncated);
|
||||
log.record("p", ids(&[4])); // rev 3 drops the rev 1 entry
|
||||
assert!(all(&log, 0).truncated);
|
||||
// Everything after the dropped revision is still complete.
|
||||
let s = all(&log, 1);
|
||||
assert!(!s.truncated);
|
||||
assert_eq!(s.changes[0].ids, ids(&[2, 3, 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_log_stays_bounded() {
|
||||
let mut log = ChangeLog::with_capacity(5);
|
||||
for n in 0..20 {
|
||||
log.record("p", ids(&[n]));
|
||||
}
|
||||
assert_eq!(log.entries.len(), 5);
|
||||
assert_eq!(all(&log, 15).changes[0].ids, ids(&[15, 16, 17, 18, 19]));
|
||||
assert!(all(&log, 14).truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_future_revision_is_truncated() {
|
||||
let mut log = ChangeLog::default();
|
||||
log.record("p", ids(&[1]));
|
||||
assert!(all(&log, 9).truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_forces_a_full_pass() {
|
||||
let mut log = ChangeLog::default();
|
||||
log.record("p", ids(&[1]));
|
||||
let old = log.rev();
|
||||
log.reset();
|
||||
assert!(all(&log, old).truncated);
|
||||
assert!(!all(&log, log.rev()).truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lint_revisions_are_per_client_session_and_page() {
|
||||
let mut log = ChangeLog::default();
|
||||
log.record("p", ids(&[1]));
|
||||
assert_eq!(log.last_lint("p"), None, "no client, nothing remembered");
|
||||
log.client = who("rmcp#1", "rmcp");
|
||||
log.note_lint("p");
|
||||
assert_eq!(log.last_lint("p"), Some(1));
|
||||
assert_eq!(log.last_lint("q"), None);
|
||||
// Same client name, another session.
|
||||
log.client = who("rmcp#2", "rmcp \u{b7} 2");
|
||||
assert_eq!(log.last_lint("p"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_clients_changes_are_attributed_separately() {
|
||||
let mut log = ChangeLog::default();
|
||||
log.client = who("rmcp#1", "rmcp");
|
||||
log.record("p", ids(&[1, 2]));
|
||||
log.client = who("rmcp#2", "rmcp \u{b7} 2");
|
||||
log.record("p", ids(&[2, 3]));
|
||||
log.client = None;
|
||||
log.record("p", ids(&[4]));
|
||||
let g = all(&log, 0).changes;
|
||||
assert_eq!(g.len(), 3);
|
||||
assert_eq!((g[0].by.as_deref(), g[0].ids.clone(), g[0].rev), (Some("rmcp"), ids(&[1, 2]), 1));
|
||||
assert_eq!((g[1].by.as_deref(), g[1].ids.clone(), g[1].rev), (Some("rmcp \u{b7} 2"), ids(&[2, 3]), 2));
|
||||
assert_eq!((g[2].by.clone(), g[2].ids.clone()), (None, ids(&[4])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_pick_one_client_or_everyone_else() {
|
||||
let mut log = ChangeLog::default();
|
||||
log.client = who("a#1", "A");
|
||||
log.record("p", ids(&[1]));
|
||||
log.client = who("b#1", "B");
|
||||
log.record("p", ids(&[2]));
|
||||
log.client = None;
|
||||
log.record("p", ids(&[3]));
|
||||
let others = log.since(0, None, Filter::Exclude("a#1"));
|
||||
let labels: Vec<_> = others.changes.iter().map(|g| g.by.as_deref()).collect();
|
||||
assert_eq!(labels, vec![Some("B"), None], "the person's edits are someone else's too");
|
||||
let only = log.since(0, None, Filter::Only("B"));
|
||||
assert_eq!(only.changes.len(), 1);
|
||||
assert_eq!(only.changes[0].ids, ids(&[2]));
|
||||
assert_eq!(log.since(0, None, Filter::Only("b#1")).changes, only.changes);
|
||||
}
|
||||
}
|
||||
+32
-6
@@ -3,6 +3,7 @@
|
||||
//! It consumes platform-agnostic [`InputEvent`]s, so every interaction can be scripted in tests.
|
||||
|
||||
pub mod annotations;
|
||||
pub mod changes;
|
||||
pub mod commands;
|
||||
pub mod design;
|
||||
pub mod hit;
|
||||
@@ -317,6 +318,8 @@ pub struct Editor {
|
||||
/// While set, every committed change appends the (page id, node) pairs it changed: the agent
|
||||
/// run tracker attributes nodes to the run that made the call.
|
||||
pub touch_log: Option<Vec<(String, NodeId)>>,
|
||||
/// Which layers changed at which revision (`get_changes`, incremental lint).
|
||||
pub changes: changes::ChangeLog,
|
||||
}
|
||||
|
||||
impl Default for Editor {
|
||||
@@ -393,6 +396,7 @@ impl Editor {
|
||||
clipboard_style: None,
|
||||
cam_moved: None,
|
||||
touch_log: None,
|
||||
changes: changes::ChangeLog::default(),
|
||||
clipboard_io: None,
|
||||
clipboard_written: None,
|
||||
}
|
||||
@@ -436,24 +440,40 @@ impl Editor {
|
||||
self.flush_cross_pages();
|
||||
}
|
||||
|
||||
/// Appends the nodes `rec` changed to `touch_log`. Always true, so it chains before a commit.
|
||||
/// Appends the nodes `rec` changed to `touch_log` and the change log. Always true, so it
|
||||
/// chains before a commit.
|
||||
pub(crate) fn log_touched(&mut self, rec: &Recording) -> bool {
|
||||
let Some(page) = self.touch_log.as_ref().and_then(|_| self.pages.get(self.edit_page_index())) else {
|
||||
let Some(page) = self.pages.get(self.edit_page_index()).map(|p| p.id.clone()) else {
|
||||
return true;
|
||||
};
|
||||
let page = page.id.clone();
|
||||
let changed: Vec<_> = rec
|
||||
let changed: Vec<NodeId> = rec
|
||||
.touched()
|
||||
.iter()
|
||||
.filter(|id| rec.before(**id).map(Option::as_ref) != Some(self.doc.nodes.get(id)))
|
||||
.map(|id| (page.clone(), *id))
|
||||
.copied()
|
||||
.collect();
|
||||
self.changes.record(&page, changed.iter().copied());
|
||||
if let Some(log) = &mut self.touch_log {
|
||||
log.extend(changed);
|
||||
log.extend(changed.into_iter().map(|id| (page.clone(), id)));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A restored design system moves variables under their layers, which the step's own
|
||||
/// layers do not list.
|
||||
fn log_history_design(&mut self, design_restored: bool) {
|
||||
if design_restored {
|
||||
self.log_variable_users(None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Logs the layers an undo or redo restored.
|
||||
fn log_history(&mut self, nodes: &[NodeId]) {
|
||||
if let Some(page) = self.pages.get(self.edit_page_index()).map(|p| p.id.clone()) {
|
||||
self.changes.record(&page, nodes.iter().copied());
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one edit as a complete transaction.
|
||||
pub fn edit<R>(&mut self, label: &str, f: impl FnOnce(&mut Tx) -> R) -> R {
|
||||
self.begin(label);
|
||||
@@ -494,8 +514,11 @@ impl Editor {
|
||||
let mut changed = Changed::Nodes(Arc::new([]));
|
||||
if let Some(mut u) = self.history.undo(&mut self.doc) {
|
||||
self.status = format!("Undo {}", u.label);
|
||||
self.log_history(&u.nodes);
|
||||
let design = u.design.is_some();
|
||||
changed = Changed::Nodes(std::mem::take(&mut u.nodes).into());
|
||||
self.restore_project_state(u);
|
||||
self.log_history_design(design);
|
||||
}
|
||||
self.after_history(changed);
|
||||
}
|
||||
@@ -507,8 +530,11 @@ impl Editor {
|
||||
let mut changed = Changed::Nodes(Arc::new([]));
|
||||
if let Some(mut u) = self.history.redo(&mut self.doc) {
|
||||
self.status = format!("Redo {}", u.label);
|
||||
self.log_history(&u.nodes);
|
||||
let design = u.design.is_some();
|
||||
changed = Changed::Nodes(std::mem::take(&mut u.nodes).into());
|
||||
self.restore_project_state(u);
|
||||
self.log_history_design(design);
|
||||
}
|
||||
self.after_history(changed);
|
||||
}
|
||||
|
||||
+30
-2
@@ -261,6 +261,11 @@ fn resolve_all(
|
||||
|
||||
/// Run one tool. Errors are returned as messages for the agent.
|
||||
pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
|
||||
let r = dispatch(host, name, args);
|
||||
super::incremental::after_call(host.editor(), name, args, r)
|
||||
}
|
||||
|
||||
fn dispatch(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
|
||||
let empty = Map::new();
|
||||
let a = Args(args.as_object().unwrap_or(&empty));
|
||||
match name {
|
||||
@@ -341,6 +346,7 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
|
||||
"set_text_content" => set_text_content(ed, &a),
|
||||
"get_variables" => get_variables(ed, &a),
|
||||
"lint_design" => lint_design(ed, &a),
|
||||
"get_changes" => super::incremental::get_changes(ed, a.0),
|
||||
"get_feedback" => get_feedback(ed, &a),
|
||||
"add_feedback" => add_feedback(ed, &a),
|
||||
"resolve_feedback" => resolve_feedback(ed, &a),
|
||||
@@ -745,6 +751,7 @@ fn basic_info(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
"page": {"id": ed.pages[page].id, "name": ed.pages[page].name, "isActive": page == ed.active_page},
|
||||
"pages": pages,
|
||||
"nodeCount": doc.nodes.len(),
|
||||
"rev": ed.changes.rev(),
|
||||
"artboards": artboards,
|
||||
"screens": crate::editor::screens::declared(doc).iter().map(|id| {
|
||||
let n = doc.get(*id).unwrap();
|
||||
@@ -5012,18 +5019,35 @@ fn lint_design(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
Some(v) => v.iter().filter_map(Value::as_f64).collect(),
|
||||
None => super::lint::DEFAULT_SCALE.to_vec(),
|
||||
};
|
||||
let whole_page = a.str("nodeId").is_none_or(|n| n.is_empty() || n == "root");
|
||||
let since = a.num("since").filter(|s| *s >= 0.0).map(|s| s as u64);
|
||||
let scope = super::incremental::lint_scope(ed, page, &roots, since, a.bool("changedOnly").unwrap_or(false));
|
||||
let total: usize = roots.iter().map(|r| ed.page_doc(page).subtree(*r).len()).sum();
|
||||
let fixed = if a.bool("fix").unwrap_or(false) {
|
||||
ed.with_page(page, |ed| {
|
||||
ed.edit("Auto-fix design lint", |tx| {
|
||||
super::lint::autofix(tx, &roots, &scale)
|
||||
ed.edit("Auto-fix design lint", |tx| match &scope.ids {
|
||||
None => super::lint::autofix(tx, &roots, &scale),
|
||||
Some(ids) => {
|
||||
let mut ids: Vec<_> = ids.iter().copied().collect();
|
||||
ids.sort();
|
||||
ids.into_iter().filter(|id| super::lint::autofix_node(tx, *id, &scale)).count()
|
||||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// The next `changedOnly` lint starts from here. A subtree lint leaves the rest unchecked.
|
||||
if whole_page {
|
||||
let page_id = ed.pages[page].id.clone();
|
||||
ed.changes.note_lint(&page_id);
|
||||
}
|
||||
let doc = ed.page_doc(page);
|
||||
let mut issues = super::lint::lint(doc, &roots, &scale);
|
||||
issues.extend(super::lint::not_an_instance(doc, &roots, &super::lint::component_mains(ed)));
|
||||
if let Some(ids) = &scope.ids {
|
||||
issues.retain(|i| ids.contains(&i.node));
|
||||
}
|
||||
// Layers hidden behind a drawer or modal in their screen are not fix-me findings: nobody sees them.
|
||||
let before = issues.len();
|
||||
issues.retain(|i| !crate::editor::screens::coverage(doc, i.node).is_some_and(|c| c.is_hidden()));
|
||||
@@ -5042,9 +5066,13 @@ fn lint_design(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
"name": doc.get(i.node).map(|n| n.name.clone()), "message": i.message, "fix": i.fix})
|
||||
})
|
||||
.collect();
|
||||
let checked = scope.ids.as_ref().map_or(total, |ids| ids.len());
|
||||
text(json!({
|
||||
"errors": count("error"), "warnings": count("warning"), "info": count("info"),
|
||||
"fixedNodes": fixed,
|
||||
"rev": ed.changes.rev(),
|
||||
"covered": {"checked": checked, "total": total, "since": scope.since,
|
||||
"partial": scope.ids.is_some(), "fullPassBecause": scope.fallback},
|
||||
"byRule": by_rule, "issues": list,
|
||||
"truncated": issues.len() > 200,
|
||||
"skippedHidden": skipped_hidden,
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Incremental lint: `get_changes`, the `rev` every write tool reports, and the scope
|
||||
//! `lint_design {since, changedOnly}` checks. The log itself is `editor::changes`.
|
||||
|
||||
use super::exec::{find_page, node_key};
|
||||
use super::{Out, ToolResult, tools};
|
||||
use crate::design_system::sanitize_name;
|
||||
use crate::editor::Editor;
|
||||
use crate::editor::changes::Filter;
|
||||
use crate::model::{Document, NodeId};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
/// What a lint pass covers.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Scope {
|
||||
/// The layers to check, or None for all of them.
|
||||
pub ids: Option<HashSet<NodeId>>,
|
||||
/// The revision the scope was computed from.
|
||||
pub since: Option<u64>,
|
||||
/// Why a `since` or `changedOnly` request was answered with a full pass.
|
||||
pub fallback: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
fn full(fallback: Option<&'static str>) -> Scope {
|
||||
Scope { ids: None, since: None, fallback }
|
||||
}
|
||||
}
|
||||
|
||||
/// The layers a change to `changed` can affect: themselves, their ancestors (overflow, layout),
|
||||
/// descendants, and siblings (spacing, reading order). Layers that no longer exist are skipped;
|
||||
/// their parent is in the log too, because deleting a child edits its parent's list.
|
||||
pub fn expand(doc: &Document, changed: &[NodeId]) -> HashSet<NodeId> {
|
||||
let mut out = HashSet::new();
|
||||
for id in changed.iter().filter(|id| doc.get(**id).is_some()) {
|
||||
out.insert(*id);
|
||||
out.extend(doc.ancestors(*id));
|
||||
out.extend(doc.subtree(*id));
|
||||
let parent = doc.get(*id).and_then(|n| n.parent);
|
||||
out.extend(doc.children(parent).iter().copied());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The scope for linting `roots` of page `page`: everything, or what changed since `since`
|
||||
/// (or since the running client's last whole-page lint when `changed_only`).
|
||||
pub fn lint_scope(ed: &Editor, page: usize, roots: &[NodeId], since: Option<u64>, changed_only: bool) -> Scope {
|
||||
let page_id = &ed.pages[page].id;
|
||||
let since = match (since, changed_only) {
|
||||
(Some(s), _) => s,
|
||||
(None, true) => match ed.changes.last_lint(page_id) {
|
||||
Some(s) => s,
|
||||
None => return Scope::full(Some("no earlier lint by this client")),
|
||||
},
|
||||
(None, false) => return Scope::full(None),
|
||||
};
|
||||
let log = ed.changes.since(since, Some(page_id), Filter::All);
|
||||
if log.truncated {
|
||||
return Scope::full(Some("change log no longer reaches that revision"));
|
||||
}
|
||||
let doc = ed.page_doc(page);
|
||||
let changed: Vec<NodeId> = log.changes.iter().flat_map(|g| g.ids.iter().copied()).collect();
|
||||
let inside: HashSet<NodeId> = roots.iter().flat_map(|r| doc.subtree(*r)).collect();
|
||||
let ids = expand(doc, &changed).into_iter().filter(|id| inside.contains(id)).collect();
|
||||
Scope { ids: Some(ids), since: Some(since), fallback: None }
|
||||
}
|
||||
|
||||
/// Which variables a variable tool edits: Some(names) in CSS form, None for "any".
|
||||
fn variable_names(name: &str, args: &Value) -> Option<Option<BTreeSet<String>>> {
|
||||
let keys = |field: &str| -> Option<BTreeSet<String>> {
|
||||
let o = args.get(field)?;
|
||||
let names: BTreeSet<String> = match o {
|
||||
Value::Object(m) => m.keys().map(|k| sanitize_name(k)).collect(),
|
||||
Value::Array(a) => a.iter().filter_map(Value::as_str).map(sanitize_name).collect(),
|
||||
_ => return None,
|
||||
};
|
||||
(!names.is_empty()).then_some(names)
|
||||
};
|
||||
match name {
|
||||
"set_variables" => Some(keys("variables")),
|
||||
"set_tokens" => Some(keys("tokens")),
|
||||
"delete_variables" => Some(keys("names")),
|
||||
"set_variable_mode" | "import_design_system" => Some(None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-processing of every tool call: variable edits count as touching the layers bound to
|
||||
/// them, a new project starts a new log, and write tools report the revision they left.
|
||||
pub fn after_call(ed: &mut Editor, name: &str, args: &Value, r: ToolResult) -> ToolResult {
|
||||
let mut out = r?;
|
||||
if matches!(name, "open_file" | "create_file") {
|
||||
ed.changes.reset();
|
||||
}
|
||||
if let Some(names) = variable_names(name, args) {
|
||||
ed.log_variable_users(names.as_ref());
|
||||
}
|
||||
if tools::edits_doc(name, args) {
|
||||
add_rev(&mut out, ed.changes.rev());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Puts `rev` into the result's JSON object, or appends a `{"rev"}` result when the tool
|
||||
/// answered with something else.
|
||||
fn add_rev(out: &mut Vec<Out>, rev: u64) {
|
||||
if let Some(Out::Text(t)) = out.first_mut()
|
||||
&& let Ok(Value::Object(mut m)) = serde_json::from_str::<Value>(t)
|
||||
{
|
||||
m.insert("rev".into(), json!(rev));
|
||||
*t = serde_json::to_string_pretty(&Value::Object(m)).unwrap_or_default();
|
||||
return;
|
||||
}
|
||||
out.push(Out::Text(json!({"rev": rev}).to_string()));
|
||||
}
|
||||
|
||||
/// `get_changes`.
|
||||
pub fn get_changes(ed: &mut Editor, args: &Map<String, Value>) -> ToolResult {
|
||||
let since = args
|
||||
.get("since")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|s| *s >= 0.0)
|
||||
.ok_or("`since` must be a revision number (the `rev` of an earlier result)")? as u64;
|
||||
let page = match args.get("page").and_then(Value::as_str).filter(|p| !p.is_empty()) {
|
||||
Some(p) => Some(find_page(ed, p)?),
|
||||
None => None,
|
||||
};
|
||||
let me = ed.changes.client.clone();
|
||||
let by = args.get("by").and_then(Value::as_str).filter(|b| !b.is_empty());
|
||||
let filter = match (args.get("exclude").and_then(Value::as_str), by) {
|
||||
(Some("self"), _) => match &me {
|
||||
Some(w) => Filter::Exclude(&w.key),
|
||||
None => return Err("`exclude: \"self\"` needs to know which client is asking; this connection is anonymous".into()),
|
||||
},
|
||||
(Some(other), _) => return Err(format!("`exclude` must be \"self\", not `{other}`")),
|
||||
(None, Some(b)) => Filter::Only(b),
|
||||
(None, None) => Filter::All,
|
||||
};
|
||||
let page_id = page.map(|p| ed.pages[p].id.clone());
|
||||
let log = ed.changes.since(since, page_id.as_deref(), filter);
|
||||
let changes: Vec<Value> = log
|
||||
.changes
|
||||
.iter()
|
||||
.filter_map(|g| {
|
||||
let p = ed.pages.iter().position(|p| p.id == g.page)?;
|
||||
let ids: Vec<String> = g.ids.iter().map(|id| node_key(ed, p, *id)).collect();
|
||||
Some(json!({"page": g.page, "ids": ids, "by": g.by, "rev": g.rev}))
|
||||
})
|
||||
.collect();
|
||||
Ok(vec![Out::Text(
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"rev": ed.changes.rev(), "since": since, "truncated": log.truncated, "changes": changes,
|
||||
}))
|
||||
.unwrap_or_default(),
|
||||
)])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::geom::Rect;
|
||||
use crate::model::{Node, NodeKind};
|
||||
|
||||
/// root frame 1 > [frame 2 > [3, 4], frame 5 > [6]]
|
||||
fn tree() -> Document {
|
||||
let mut doc = Document::default();
|
||||
let mut add = |parent: Option<NodeId>| {
|
||||
let id = doc.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Frame { clip: false }, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
n.parent = parent;
|
||||
doc.nodes.insert(id, n);
|
||||
match parent {
|
||||
Some(p) => doc.nodes.get_mut(&p).unwrap().children.push(id),
|
||||
None => doc.root.push(id),
|
||||
}
|
||||
id
|
||||
};
|
||||
let r = add(None);
|
||||
let a = add(Some(r));
|
||||
add(Some(a));
|
||||
add(Some(a));
|
||||
let b = add(Some(r));
|
||||
add(Some(b));
|
||||
doc
|
||||
}
|
||||
|
||||
fn set(v: &[u64]) -> HashSet<NodeId> {
|
||||
v.iter().map(|n| NodeId(*n)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_reaches_ancestors_descendants_and_siblings() {
|
||||
let doc = tree();
|
||||
// Layer 2: itself, root 1, children 3 and 4, sibling 5. Not 5's child 6.
|
||||
assert_eq!(expand(&doc, &[NodeId(2)]), set(&[1, 2, 3, 4, 5]));
|
||||
// Layer 3: its parent and root, its sibling 4; not the uncle's subtree.
|
||||
assert_eq!(expand(&doc, &[NodeId(3)]), set(&[1, 2, 3, 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_level_layer_reaches_the_other_top_level_layers() {
|
||||
let mut doc = tree();
|
||||
let id = doc.alloc_id();
|
||||
doc.nodes.insert(id, Node::new(id, NodeKind::Frame { clip: false }, Rect::new(0.0, 0.0, 1.0, 1.0)));
|
||||
doc.root.push(id);
|
||||
assert_eq!(expand(&doc, &[id]), set(&[1, 7]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_layers_are_skipped() {
|
||||
assert!(expand(&tree(), &[NodeId(99)]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_tools_name_the_variables_they_edit() {
|
||||
let some = |v: &[&str]| Some(Some(v.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>()));
|
||||
assert_eq!(variable_names("set_variables", &json!({"variables": {"accent": "#fff", "--bg": "#000"}})), some(&["accent", "bg"]));
|
||||
assert_eq!(variable_names("delete_variables", &json!({"names": ["accent"]})), some(&["accent"]));
|
||||
assert_eq!(variable_names("set_variable_mode", &json!({"mode": "Dark"})), Some(None));
|
||||
assert_eq!(variable_names("set_variables", &json!({})), Some(None), "unreadable arguments mean any variable");
|
||||
assert_eq!(variable_names("update_styles", &json!({})), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rev_goes_into_a_json_object_or_a_result_of_its_own() {
|
||||
let mut out = vec![Out::Text("{\"ok\": true}".into())];
|
||||
add_rev(&mut out, 7);
|
||||
let Out::Text(t) = &out[0] else { panic!() };
|
||||
assert_eq!(serde_json::from_str::<Value>(t).unwrap(), json!({"ok": true, "rev": 7}));
|
||||
let mut out = vec![Out::Text("done".into())];
|
||||
add_rev(&mut out, 8);
|
||||
assert_eq!(out.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ pub mod diff;
|
||||
pub mod enforcer;
|
||||
pub mod exec;
|
||||
pub mod html;
|
||||
pub mod incremental;
|
||||
pub mod lint;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod imagegen;
|
||||
|
||||
+29
-2
@@ -685,8 +685,16 @@ pub fn definitions() -> Vec<Value> {
|
||||
names (Frame, Rectangle), and hard-coded colors that equal a variable. Run it before \
|
||||
finish_working_on_nodes and fix errors and warnings; `info` items are suggestions. \
|
||||
Pass `fix: true` to automatically snap spacing/padding to 4px, relink matching color \
|
||||
variables, snap text sizes, and enlarge small touch targets.",
|
||||
variables, snap text sizes, and enlarge small touch targets. After the first full pass, \
|
||||
lint only what you changed: `changedOnly: true` checks the layers changed since this \
|
||||
client's last whole-page lint, `since: <rev>` since that revision (every write tool \
|
||||
returns `rev`); both add what a change can affect (ancestors, descendants, siblings, \
|
||||
layers bound to an edited variable), work with `fix`, and fall back to a full pass \
|
||||
when the log does not reach back or this client has not linted yet. The result's \
|
||||
`covered` says what was checked: {checked, total, since, partial, fullPassBecause}.",
|
||||
json!({"nodeId": {"type": "string", "description": "Subtree to check; omit for the whole page."},
|
||||
"since": {"type": "number", "description": "Check only layers changed after this revision (`rev` from a write tool, get_basic_info or get_changes), plus the layers a change affects."},
|
||||
"changedOnly": {"type": "boolean", "description": "Same as `since`, with the revision of this client's last whole-page lint of the page."},
|
||||
"scale": {"type": "array", "items": {"type": "number"},
|
||||
"description": "Allowed font sizes. Default 12, 14, 16, 20, 24, 32, 40, 48, 64."},
|
||||
"fix": {"type": "boolean", "description": "Apply safe automatic fixes before reporting remaining issues."},
|
||||
@@ -694,6 +702,25 @@ pub fn definitions() -> Vec<Value> {
|
||||
&[],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"get_changes",
|
||||
"Which layers changed after revision `since`. Every write tool returns `rev`, the \
|
||||
revision after the call (get_basic_info reports the current one); each edit, undo and \
|
||||
redo adds layers to the log, and editing a variable counts as changing every layer \
|
||||
bound to it. Returns {rev, since, truncated, changes: [{page, ids, by, rev}]}: ids use \
|
||||
the same \"<page>:<n>\" form as the other tools, `by` is the client that made them \
|
||||
(null for the person using the app), `rev` the latest revision in the group. Reads \
|
||||
change nothing. `truncated: true` means the log (the last 10000 layer changes) no \
|
||||
longer reaches back to `since`, or `since` is newer than `rev`: do a full pass instead. \
|
||||
`exclude: \"self\"` leaves out your own changes, to see what others changed; `by` keeps \
|
||||
one client's. Layers deleted since are listed too. Use it with lint_design `since`.",
|
||||
json!({"since": {"type": "number", "description": "A revision you have seen; 0 for everything on record."},
|
||||
"page": {"type": "string", "description": "Only changes on this page (id)."},
|
||||
"exclude": {"type": "string", "enum": ["self"], "description": "\"self\": leave out changes made by this client."},
|
||||
"by": {"type": "string", "description": "Only changes made by this client (its `by` label)."}}),
|
||||
&["since"],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"get_variables",
|
||||
"Variables of the page's project. Default JSON: `variables` (name -> hex), `tokens`, and \
|
||||
@@ -1213,7 +1240,7 @@ fn classify(name: &str, args: &Value) -> Option<bool> {
|
||||
| "get_tree_summary" | "get_screenshot" | "get_jsx" | "get_design_spec" | "verify_code"
|
||||
| "get_computed_styles" | "get_fill_image" | "zoom_image" | "ask_user" | "fetch_reference_images" | "generate_image" | "report_issue" | "design_rules" | "delegate" | "find_nodes" | "get_font_family_info"
|
||||
| "get_annotations" | "get_annotation_template" | "get_guide" | "get_feedback" | "get_variables" | "get_components" | "search_icons"
|
||||
| "diff_frames" | "publish_library" | "list_libraries" => false,
|
||||
| "diff_frames" | "publish_library" | "list_libraries" | "get_changes" => false,
|
||||
"select_nodes" | "set_viewport" | "export" | "export_combined_pdf" | "export_design_system" | "open_file"
|
||||
| "create_file" | "rename_file" | "finish_working_on_nodes" => false,
|
||||
"lint_design" => args["fix"] == true,
|
||||
|
||||
+20
-1
@@ -518,7 +518,8 @@ impl Tracker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `exec::call`, recording it in `client`'s open run when it edits the document.
|
||||
/// Runs `exec::call`, recording it in `client`'s open run when it edits the document. The
|
||||
/// change log attributes what the call changes to `client`.
|
||||
pub fn call(
|
||||
&mut self,
|
||||
host: &mut dyn Host,
|
||||
@@ -526,6 +527,24 @@ impl Tracker {
|
||||
name: &str,
|
||||
args: &Value,
|
||||
now: u64,
|
||||
) -> ToolResult {
|
||||
let who = crate::editor::changes::Who {
|
||||
key: format!("{}#{}", client.name, client.session),
|
||||
label: self.label(client),
|
||||
};
|
||||
host.editor().changes.client = Some(std::sync::Arc::new(who));
|
||||
let r = self.call_attributed(host, client, name, args, now);
|
||||
host.editor().changes.client = None;
|
||||
r
|
||||
}
|
||||
|
||||
fn call_attributed(
|
||||
&mut self,
|
||||
host: &mut dyn Host,
|
||||
client: &Client,
|
||||
name: &str,
|
||||
args: &Value,
|
||||
now: u64,
|
||||
) -> ToolResult {
|
||||
let dir = host.project().map(|p| p.dir.clone());
|
||||
self.sync_project(now, dir.as_deref());
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Incremental lint on a headless session: the change log behind `get_changes`, the `rev`
|
||||
//! write tools report, and `lint_design {since, changedOnly}`.
|
||||
|
||||
use papercraft::editor::changes::{ChangeLog, Who};
|
||||
use papercraft::mcp::{Out, exec};
|
||||
use papercraft::project::Store;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn host() -> exec::SessionHost {
|
||||
let s = Store::open(std::env::temp_dir().join(format!("papercraft-inc-{:016x}", fastrand::u64(..)))).unwrap();
|
||||
let mut h = exec::SessionHost::new(s);
|
||||
h.session.create(&mut h.ed, "Incremental").unwrap();
|
||||
h
|
||||
}
|
||||
|
||||
fn call(h: &mut exec::SessionHost, name: &str, args: Value) -> Value {
|
||||
match exec::call(h, name, &args) {
|
||||
Ok(outs) => match outs.into_iter().next() {
|
||||
Some(Out::Text(t)) => serde_json::from_str(&t).unwrap_or(Value::String(t)),
|
||||
_ => Value::Null,
|
||||
},
|
||||
Err(e) => panic!("{name} failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn act_as(h: &mut exec::SessionHost, name: &str, session: &str) {
|
||||
let label = name.to_string();
|
||||
h.ed.changes.client = Some(Arc::new(Who { key: format!("{name}#{session}"), label }));
|
||||
}
|
||||
|
||||
struct Design {
|
||||
/// Layer ids by role.
|
||||
card_a: String,
|
||||
card_b: String,
|
||||
far: String,
|
||||
}
|
||||
|
||||
/// Two artboards: "Near" with two cards (the first bound to the `accent` variable), "Far" with
|
||||
/// one card holding 10px text, which lint flags.
|
||||
fn design(h: &mut exec::SessionHost) -> Design {
|
||||
call(h, "set_variables", json!({"variables": {"accent": "#2563eb"}}));
|
||||
let near = call(h, "create_artboard", json!({"name": "Near", "styles": {"width": "390px", "height": "400px", "padding": "16px", "gap": 16}}));
|
||||
let r = call(
|
||||
h,
|
||||
"write_html",
|
||||
json!({"targetNodeId": near["id"], "mode": "insert-children",
|
||||
"html": "<div layer-name=\"Card A\" style=\"padding:16px;background:var(--accent)\"><p style=\"font-size:16px\">A</p></div><div layer-name=\"Card B\" style=\"padding:16px;background:#eeeeee\"><p style=\"font-size:16px\">B</p></div>"}),
|
||||
);
|
||||
let far = call(h, "create_artboard", json!({"name": "Far", "styles": {"width": "390px", "height": "400px", "padding": "16px"}}));
|
||||
let f = call(
|
||||
h,
|
||||
"write_html",
|
||||
json!({"targetNodeId": far["id"], "mode": "insert-children",
|
||||
"html": "<div layer-name=\"Card F\" style=\"padding:16px;background:#eeeeee\"><p style=\"font-size:10px\">Tiny</p></div>"}),
|
||||
);
|
||||
Design {
|
||||
card_a: r["createdNodeIds"][0].as_str().unwrap().into(),
|
||||
card_b: r["createdNodeIds"][1].as_str().unwrap().into(),
|
||||
far: f["createdNodeIds"][0].as_str().unwrap().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn changed_ids(r: &Value) -> Vec<String> {
|
||||
r["changes"].as_array().unwrap().iter().flat_map(|g| g["ids"].as_array().unwrap().iter().map(|i| i.as_str().unwrap().to_string())).collect()
|
||||
}
|
||||
|
||||
fn issue_nodes(r: &Value) -> Vec<String> {
|
||||
r["issues"].as_array().unwrap().iter().map(|i| i["node"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
fn restyle(h: &mut exec::SessionHost, node: &str, styles: Value) -> Value {
|
||||
call(h, "update_styles", json!({"updates": [{"nodeIds": [node], "styles": styles}]}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_tools_report_the_revision_and_reads_leave_it_alone() {
|
||||
let mut h = host();
|
||||
let d = design(&mut h);
|
||||
let r = restyle(&mut h, &d.card_b, json!({"opacity": 0.9}));
|
||||
let rev = r["rev"].as_u64().expect("write result carries rev");
|
||||
assert!(rev > 0);
|
||||
for (name, args) in [
|
||||
("get_basic_info", json!({})),
|
||||
("get_node_info", json!({"nodeId": d.card_b})),
|
||||
("get_children", json!({"nodeId": d.card_b})),
|
||||
("get_variables", json!({})),
|
||||
("lint_design", json!({})),
|
||||
("get_changes", json!({"since": 0})),
|
||||
] {
|
||||
call(&mut h, name, args);
|
||||
}
|
||||
let info = call(&mut h, "get_basic_info", json!({}));
|
||||
assert_eq!(info["rev"], rev, "reads do not bump the revision");
|
||||
assert_eq!(call(&mut h, "get_changes", json!({"since": rev}))["changes"], json!([]));
|
||||
let again = restyle(&mut h, &d.card_b, json!({"opacity": 0.8}));
|
||||
assert!(again["rev"].as_u64().unwrap() > rev);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_changes_lists_the_layers_a_call_touched() {
|
||||
let mut h = host();
|
||||
let d = design(&mut h);
|
||||
let rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap();
|
||||
restyle(&mut h, &d.card_b, json!({"opacity": 0.5}));
|
||||
let r = call(&mut h, "get_changes", json!({"since": rev}));
|
||||
assert_eq!(r["truncated"], false);
|
||||
assert_eq!(r["since"], rev);
|
||||
assert_eq!(changed_ids(&r), vec![d.card_b.clone()], "{r}");
|
||||
assert!(r["rev"].as_u64().unwrap() > rev);
|
||||
let page = h.ed.pages[0].id.clone();
|
||||
assert_eq!(call(&mut h, "get_changes", json!({"since": rev, "page": page}))["changes"], r["changes"]);
|
||||
let other = call(&mut h, "create_page", json!({"name": "Other"}));
|
||||
let other_id = other["id"].as_str().or(other["pageId"].as_str()).unwrap_or_default().to_string();
|
||||
if !other_id.is_empty() {
|
||||
assert_eq!(call(&mut h, "get_changes", json!({"since": rev, "page": other_id}))["changes"], json!([]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_count_as_changes() {
|
||||
let mut h = host();
|
||||
let d = design(&mut h);
|
||||
restyle(&mut h, &d.card_b, json!({"opacity": 0.5}));
|
||||
let rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap();
|
||||
let r = call(&mut h, "undo", json!({}));
|
||||
assert!(r["rev"].as_u64().unwrap() > rev);
|
||||
assert!(changed_ids(&call(&mut h, "get_changes", json!({"since": rev}))).contains(&d.card_b));
|
||||
let rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap();
|
||||
call(&mut h, "redo", json!({}));
|
||||
assert!(changed_ids(&call(&mut h, "get_changes", json!({"since": rev}))).contains(&d.card_b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_variable_edit_touches_every_layer_bound_to_it() {
|
||||
let mut h = host();
|
||||
let d = design(&mut h);
|
||||
let rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap();
|
||||
call(&mut h, "set_variables", json!({"variables": {"accent": "#ef4444"}}));
|
||||
let ids = changed_ids(&call(&mut h, "get_changes", json!({"since": rev})));
|
||||
assert!(ids.contains(&d.card_a), "the bound card changed: {ids:?}");
|
||||
assert!(!ids.contains(&d.card_b) && !ids.contains(&d.far), "unbound layers did not: {ids:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_log_says_truncated_when_it_no_longer_reaches_back() {
|
||||
let mut h = host();
|
||||
let d = design(&mut h);
|
||||
h.ed.changes = ChangeLog::with_capacity(4);
|
||||
for n in 0..6 {
|
||||
restyle(&mut h, &d.card_b, json!({"opacity": 0.5 + n as f64 / 20.0}));
|
||||
}
|
||||
let r = call(&mut h, "get_changes", json!({"since": 0}));
|
||||
assert_eq!(r["truncated"], true);
|
||||
assert_eq!(r["changes"], json!([]));
|
||||
// A lint that cannot rely on the log does a full pass.
|
||||
let l = call(&mut h, "lint_design", json!({"since": 0}));
|
||||
assert_eq!(l["covered"]["partial"], false, "{l}");
|
||||
assert_eq!(l["covered"]["checked"], l["covered"]["total"]);
|
||||
assert!(l["covered"]["fullPassBecause"].is_string());
|
||||
let fresh = call(&mut h, "get_changes", json!({"since": r["rev"].as_u64().unwrap() - 1}));
|
||||
assert_eq!(fresh["truncated"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lint_since_checks_only_what_a_change_can_affect() {
|
||||
let mut h = host();
|
||||
let d = design(&mut h);
|
||||
let full = call(&mut h, "lint_design", json!({}));
|
||||
assert_eq!(full["covered"]["partial"], false);
|
||||
assert_eq!(full["covered"]["since"], Value::Null);
|
||||
let total = full["covered"]["total"].as_u64().unwrap();
|
||||
let has_tiny = |r: &Value| r["issues"].as_array().unwrap().iter().any(|i| i["rule"] == "text-size");
|
||||
let full_count = full["issues"].as_array().unwrap().len();
|
||||
assert!(has_tiny(&full), "the 10px text is flagged: {full}");
|
||||
|
||||
let rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap();
|
||||
restyle(&mut h, &d.card_b, json!({"opacity": 0.9}));
|
||||
let r = call(&mut h, "lint_design", json!({"since": rev}));
|
||||
let cov = &r["covered"];
|
||||
assert_eq!(cov["partial"], true);
|
||||
assert_eq!(cov["since"], rev);
|
||||
assert_eq!(cov["total"], total);
|
||||
assert!(cov["checked"].as_u64().unwrap() < total, "{cov}");
|
||||
assert!(issue_nodes(&r).iter().all(|n| *n != d.far), "the far card was not checked");
|
||||
assert!(!has_tiny(&r), "the far card's finding is not repeated: {r}");
|
||||
assert!(r["issues"].as_array().unwrap().len() < full_count);
|
||||
|
||||
// Touching the far card brings its finding back.
|
||||
restyle(&mut h, &d.far, json!({"opacity": 0.9}));
|
||||
let r = call(&mut h, "lint_design", json!({"since": rev}));
|
||||
assert!(has_tiny(&r), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_variable_edit_pulls_its_bound_layers_into_the_lint() {
|
||||
let mut h = host();
|
||||
let d = design(&mut h);
|
||||
let rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap();
|
||||
call(&mut h, "set_variables", json!({"variables": {"accent": "#fde68a"}}));
|
||||
let r = call(&mut h, "lint_design", json!({"since": rev}));
|
||||
assert_eq!(r["covered"]["partial"], true);
|
||||
let near_total = h.ed.doc.nodes.len();
|
||||
assert!(r["covered"]["checked"].as_u64().unwrap() as usize <= near_total);
|
||||
let ids = changed_ids(&call(&mut h, "get_changes", json!({"since": rev})));
|
||||
assert!(ids.contains(&d.card_a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_only_follows_this_clients_last_lint() {
|
||||
let mut h = host();
|
||||
let d = design(&mut h);
|
||||
// Unknown client: nothing to compare with, so a full pass that says why.
|
||||
let anon = call(&mut h, "lint_design", json!({"changedOnly": true}));
|
||||
assert_eq!(anon["covered"]["partial"], false);
|
||||
assert!(anon["covered"]["fullPassBecause"].is_string(), "{anon}");
|
||||
|
||||
act_as(&mut h, "rmcp", "s1");
|
||||
let first = call(&mut h, "lint_design", json!({"changedOnly": true}));
|
||||
assert_eq!(first["covered"]["partial"], false, "this client has not linted yet");
|
||||
restyle(&mut h, &d.card_b, json!({"opacity": 0.9}));
|
||||
let second = call(&mut h, "lint_design", json!({"changedOnly": true}));
|
||||
assert_eq!(second["covered"]["partial"], true, "{second}");
|
||||
assert!(second["covered"]["checked"].as_u64().unwrap() < second["covered"]["total"].as_u64().unwrap());
|
||||
// Nothing changed since the last lint: nothing to check.
|
||||
let third = call(&mut h, "lint_design", json!({"changedOnly": true}));
|
||||
assert_eq!(third["covered"]["checked"], 0, "{third}");
|
||||
|
||||
// Another session of the same client name has its own memory.
|
||||
act_as(&mut h, "rmcp", "s2");
|
||||
let other = call(&mut h, "lint_design", json!({"changedOnly": true}));
|
||||
assert_eq!(other["covered"]["partial"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subtree_lint_does_not_count_as_a_whole_page_lint() {
|
||||
let mut h = host();
|
||||
let d = design(&mut h);
|
||||
act_as(&mut h, "rmcp", "s1");
|
||||
call(&mut h, "lint_design", json!({"nodeId": d.card_b}));
|
||||
let r = call(&mut h, "lint_design", json!({"changedOnly": true}));
|
||||
assert_eq!(r["covered"]["partial"], false, "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fix_works_on_the_scoped_set_only() {
|
||||
let mut h = host();
|
||||
let d = design(&mut h);
|
||||
let rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap();
|
||||
// Both cards get an off-grid gap; only the near one is in scope.
|
||||
restyle(&mut h, &d.card_b, json!({"gap": 13, "display": "flex", "flexDirection": "column"}));
|
||||
restyle(&mut h, &d.far, json!({"gap": 13, "display": "flex", "flexDirection": "column"}));
|
||||
let far_rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap();
|
||||
let r = call(&mut h, "lint_design", json!({"since": far_rev - 1, "fix": true}));
|
||||
assert_eq!(r["covered"]["partial"], true);
|
||||
let gap = |h: &exec::SessionHost, key: &str| {
|
||||
let id = papercraft::model::NodeId(key.rsplit(':').next().unwrap().parse().unwrap());
|
||||
h.ed.doc.get(id).and_then(|n| n.layout).map(|l| l.gap)
|
||||
};
|
||||
let (near, far) = (gap(&h, &d.card_b), gap(&h, &d.far));
|
||||
assert_eq!(far, Some(12.0), "the layer touched last was in scope: {r}");
|
||||
assert_eq!(near, Some(13.0), "the earlier edit was out of scope: {r}");
|
||||
assert!(r["rev"].as_u64().unwrap() > far_rev, "fixing is a change");
|
||||
let _ = rev;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changes_are_attributed_to_the_client_that_made_them() {
|
||||
let mut h = host();
|
||||
let d = design(&mut h);
|
||||
let rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap();
|
||||
act_as(&mut h, "Claude Code", "s1");
|
||||
restyle(&mut h, &d.card_a, json!({"opacity": 0.9}));
|
||||
act_as(&mut h, "Claude Code \u{b7} 2", "s2");
|
||||
restyle(&mut h, &d.card_b, json!({"opacity": 0.9}));
|
||||
let r = call(&mut h, "get_changes", json!({"since": rev}));
|
||||
let groups = r["changes"].as_array().unwrap();
|
||||
assert_eq!(groups.len(), 2, "{r}");
|
||||
assert_eq!(groups[0]["by"], "Claude Code");
|
||||
assert_eq!(groups[0]["ids"], json!([d.card_a]));
|
||||
assert_eq!(groups[1]["by"], "Claude Code \u{b7} 2");
|
||||
assert_eq!(groups[1]["ids"], json!([d.card_b]));
|
||||
assert!(groups[1]["rev"].as_u64() > groups[0]["rev"].as_u64());
|
||||
|
||||
// Session 2 asks what others changed, then session 1 does.
|
||||
let others = call(&mut h, "get_changes", json!({"since": rev, "exclude": "self"}));
|
||||
assert_eq!(changed_ids(&others), vec![d.card_a.clone()]);
|
||||
act_as(&mut h, "Claude Code", "s1");
|
||||
let others = call(&mut h, "get_changes", json!({"since": rev, "exclude": "self"}));
|
||||
assert_eq!(changed_ids(&others), vec![d.card_b.clone()]);
|
||||
let by = call(&mut h, "get_changes", json!({"since": rev, "by": "Claude Code"}));
|
||||
assert_eq!(changed_ids(&by), vec![d.card_a.clone()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclude_self_needs_a_known_client() {
|
||||
let mut h = host();
|
||||
design(&mut h);
|
||||
let e = exec::call(&mut h, "get_changes", &json!({"since": 0, "exclude": "self"})).unwrap_err();
|
||||
assert!(e.contains("exclude"), "{e}");
|
||||
let e = exec::call(&mut h, "get_changes", &json!({})).unwrap_err();
|
||||
assert!(e.contains("since"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_another_project_resets_the_log() {
|
||||
let mut h = host();
|
||||
design(&mut h);
|
||||
let rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap();
|
||||
call(&mut h, "create_file", json!({"name": "Second"}));
|
||||
let r = call(&mut h, "get_changes", json!({"since": rev}));
|
||||
assert_eq!(r["truncated"], true, "{r}");
|
||||
}
|
||||
@@ -830,6 +830,11 @@ async fn parallel_sessions_get_their_own_runs() {
|
||||
for (client, name) in [(&a, "A1"), (&b, "B1"), (&a, "A2"), (&chat, "C1")] {
|
||||
text_of(&client.call_tool(frame(name)).await.unwrap());
|
||||
}
|
||||
// The change log tells the sessions apart: A sees only what B and the chat changed.
|
||||
let others = text_of(&a.call_tool(tool("get_changes", json!({"since": 0, "exclude": "self"}))).await.unwrap());
|
||||
let mut by: Vec<&str> = others["changes"].as_array().unwrap().iter().map(|g| g["by"].as_str().unwrap()).collect();
|
||||
by.sort();
|
||||
assert_eq!(by, ["Agent B", "Chat"], "{others}");
|
||||
for c in [a, b, chat] {
|
||||
c.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user